pptx-browser 4.1.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/src/writer.js ADDED
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * writer.js — PPTX editor and template engine.
3
+ *
4
+ * Loads an existing PPTX (via a PptxRenderer instance or raw bytes),
5
+ * provides a fluent API to mutate its content, and serializes back to
6
+ * a valid PPTX file that PowerPoint / Keynote / LibreOffice can open.
7
+ *
8
+ * ── Quick start ──────────────────────────────────────────────────────────────
9
+ *
10
+ * import { PptxWriter } from 'pptx-canvas-renderer';
11
+ *
12
+ * // From a loaded renderer:
13
+ * const writer = PptxWriter.fromRenderer(renderer);
14
+ *
15
+ * // Or from raw bytes:
16
+ * const writer = await PptxWriter.fromBytes(arrayBuffer);
17
+ *
18
+ * // Template substitution ({{tokens}} in shapes / speaker notes)
19
+ * writer.applyTemplate({ name: 'Acme Corp', year: '2025' });
20
+ *
21
+ * // Replace text everywhere
22
+ * writer.replaceText('Old Text', 'New Text');
23
+ *
24
+ * // Set the text of a specific shape
25
+ * writer.setShapeText(0, 'Title 1', 'My New Title');
26
+ *
27
+ * // Swap an image on slide 2 (shape named "Picture 1")
28
+ * await writer.setShapeImage(1, 'Picture 1', jpegBytes, 'image/jpeg');
29
+ *
30
+ * // Duplicate slide 0 as a new slide at the end
31
+ * writer.duplicateSlide(0);
32
+ *
33
+ * // Remove slide 3
34
+ * writer.removeSlide(3);
35
+ *
36
+ * // Reorder slides
37
+ * writer.reorderSlides([2, 0, 1]);
38
+ *
39
+ * // Change a theme colour
40
+ * writer.setThemeColor('accent1', 'FF0000');
41
+ *
42
+ * // Export
43
+ * const bytes = await writer.save(); // → Uint8Array
44
+ * writer.download('edited.pptx'); // trigger browser download
45
+ *
46
+ * ── API reference ─────────────────────────────────────────────────────────────
47
+ *
48
+ * PptxWriter.fromRenderer(renderer) — clone from loaded PptxRenderer
49
+ * PptxWriter.fromBytes(buffer) — parse PPTX bytes fresh
50
+ *
51
+ * .applyTemplate(data, opts) — {{token}} substitution
52
+ * .replaceText(find, replace, opts) — global find-and-replace
53
+ * .setShapeText(slideIdx, name, text) — set text of named shape
54
+ * .getShapeText(slideIdx, name) — read text from named shape
55
+ * .addTextBox(slideIdx, text, style) — add a new text box
56
+ * .setShapeImage(slideIdx, name, bytes, mime) — swap shape image
57
+ * .addImage(slideIdx, bytes, mime, rect) — add new image shape
58
+ * .setSlideBackground(slideIdx, color) — solid background color
59
+ * .setThemeColor(key, hexRgb) — change theme colour (no #)
60
+ * .duplicateSlide(fromIdx, toIdx?) — copy slide
61
+ * .removeSlide(slideIdx) — delete slide
62
+ * .reorderSlides(newOrder) — reorder by index array
63
+ * .setSlideNotes(slideIdx, text) — set speaker notes
64
+ * .getSlidePaths() — current slide file paths
65
+ * .getSlideCount() — current slide count
66
+ * .save() → Promise<Uint8Array> PPTX bytes
67
+ * .download(filename) — save file in browser
68
+ */
69
+
70
+ import { readZip } from './zip.js';
71
+ import { writeZip } from './zip-writer.js';
72
+
73
+ const dec = new TextDecoder();
74
+ const enc = new TextEncoder();
75
+ const NS = {
76
+ p: 'http://schemas.openxmlformats.org/presentationml/2006/main',
77
+ a: 'http://schemas.openxmlformats.org/drawingml/2006/main',
78
+ r: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
79
+ rel: 'http://schemas.openxmlformats.org/package/2006/relationships',
80
+ ct: 'http://schemas.openxmlformats.org/package/2006/content-types',
81
+ };
82
+
83
+ // ── XML helpers ───────────────────────────────────────────────────────────────
84
+
85
+ function parseXml(str) {
86
+ return new DOMParser().parseFromString(str, 'application/xml');
87
+ }
88
+ function serializeXml(doc) {
89
+ const s = new XMLSerializer().serializeToString(doc);
90
+ // Ensure declaration
91
+ if (s.startsWith('<?xml')) return s;
92
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n' + s;
93
+ }
94
+ function xmlBytes(doc) { return enc.encode(serializeXml(doc)); }
95
+ function readXml(files, path) {
96
+ const raw = files[path];
97
+ if (!raw) return null;
98
+ return parseXml(dec.decode(raw));
99
+ }
100
+ function g1(node, name) {
101
+ if (!node) return null;
102
+ const all = node.getElementsByTagName('*');
103
+ for (let i = 0; i < all.length; i++) if (all[i].localName === name) return all[i];
104
+ return null;
105
+ }
106
+ function gtn(node, name) {
107
+ if (!node) return [];
108
+ const r = [];
109
+ const all = node.getElementsByTagName('*');
110
+ for (let i = 0; i < all.length; i++) if (all[i].localName === name) r.push(all[i]);
111
+ return r;
112
+ }
113
+ function attr(el, name, def = null) {
114
+ if (!el) return def;
115
+ const v = el.getAttribute(name);
116
+ return v !== null ? v : def;
117
+ }
118
+
119
+ // ── Relationship helpers ──────────────────────────────────────────────────────
120
+
121
+ function relsPath(filePath) {
122
+ const parts = filePath.split('/');
123
+ const name = parts.pop();
124
+ return [...parts, '_rels', name + '.rels'].join('/');
125
+ }
126
+
127
+ function parseRels(files, filePath) {
128
+ const doc = readXml(files, relsPath(filePath));
129
+ if (!doc) return {};
130
+ const map = {};
131
+ for (const rel of Array.from(doc.getElementsByTagName('Relationship'))) {
132
+ const id = rel.getAttribute('Id');
133
+ const target = rel.getAttribute('Target');
134
+ const type = rel.getAttribute('Type') || '';
135
+ let fullPath = target;
136
+ if (!target.startsWith('/') && !target.startsWith('http')) {
137
+ const dir = filePath.split('/').slice(0, -1).join('/');
138
+ fullPath = dir ? dir + '/' + target.replace(/^\.\.\//, '') : target;
139
+ // Handle ../ traversal
140
+ const parts = fullPath.split('/');
141
+ const resolved = [];
142
+ for (const p of parts) {
143
+ if (p === '..') resolved.pop();
144
+ else resolved.push(p);
145
+ }
146
+ fullPath = resolved.join('/');
147
+ }
148
+ map[id] = { id, target, type, fullPath };
149
+ }
150
+ return map;
151
+ }
152
+
153
+ function buildRelsDoc(rels) {
154
+ const doc = parseXml('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>');
155
+ const root = doc.documentElement;
156
+ for (const rel of Object.values(rels)) {
157
+ const el = doc.createElementNS(NS.rel, 'Relationship');
158
+ el.setAttribute('Id', rel.id);
159
+ el.setAttribute('Type', rel.type);
160
+ el.setAttribute('Target', rel.target);
161
+ if (rel.targetMode) el.setAttribute('TargetMode', rel.targetMode);
162
+ root.appendChild(el);
163
+ }
164
+ return doc;
165
+ }
166
+
167
+ function nextRId(rels) {
168
+ const nums = Object.keys(rels)
169
+ .map(id => parseInt(id.replace('rId', ''), 10))
170
+ .filter(n => !isNaN(n));
171
+ return 'rId' + ((nums.length ? Math.max(...nums) : 0) + 1);
172
+ }
173
+
174
+ // ── Shape lookup helpers ──────────────────────────────────────────────────────
175
+
176
+ function findShapeByName(spTree, name) {
177
+ for (const child of spTree.children) {
178
+ const ln = child.localName;
179
+ if (ln === 'sp' || ln === 'pic' || ln === 'cxnSp') {
180
+ const nvEl = g1(child, 'nvSpPr') || g1(child, 'nvPicPr') || g1(child, 'nvCxnSpPr');
181
+ const cNvPr = nvEl ? g1(nvEl, 'cNvPr') : null;
182
+ if (cNvPr) {
183
+ const shapeName = cNvPr.getAttribute('name') || '';
184
+ if (shapeName === name) return child;
185
+ }
186
+ } else if (ln === 'grpSp') {
187
+ const found = findShapeByName(child, name);
188
+ if (found) return found;
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ function findShapeById(spTree, id) {
195
+ const idStr = String(id);
196
+ for (const child of spTree.children) {
197
+ const ln = child.localName;
198
+ if (ln === 'sp' || ln === 'pic' || ln === 'cxnSp') {
199
+ const nvEl = g1(child, 'nvSpPr') || g1(child, 'nvPicPr');
200
+ const cNvPr = nvEl ? g1(nvEl, 'cNvPr') : null;
201
+ if (cNvPr && (cNvPr.getAttribute('id') || '') === idStr) return child;
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+
207
+ function getSpTree(slideDoc) {
208
+ const cSld = g1(slideDoc, 'cSld');
209
+ return cSld ? g1(cSld, 'spTree') : null;
210
+ }
211
+
212
+ // ── Text replacement helpers ──────────────────────────────────────────────────
213
+
214
+ function getAllTextNodes(node) {
215
+ const result = [];
216
+ const walker = node.ownerDocument
217
+ ? node.ownerDocument.createTreeWalker(node, 0x04 /* NodeFilter.SHOW_TEXT */)
218
+ : null;
219
+ if (!walker) return result;
220
+ let n;
221
+ while ((n = walker.nextNode())) result.push(n);
222
+ return result;
223
+ }
224
+
225
+ /** Replace text in a run without disturbing formatting. */
226
+ function replaceInDoc(doc, find, replace, caseSensitive = true) {
227
+ // Collect all <a:t> elements and replace within their text content
228
+ for (const t of gtn(doc, 't')) {
229
+ const orig = t.textContent;
230
+ if (!orig) continue;
231
+ const newText = caseSensitive
232
+ ? orig.split(find).join(replace)
233
+ : orig.replace(new RegExp(escapeRegex(find), 'gi'), replace);
234
+ if (newText !== orig) t.textContent = newText;
235
+ }
236
+ }
237
+
238
+ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
239
+
240
+ // ── Content type helpers ──────────────────────────────────────────────────────
241
+
242
+ const MIME_EXT = {
243
+ 'image/jpeg': 'jpeg', 'image/jpg': 'jpeg',
244
+ 'image/png': 'png', 'image/gif': 'gif',
245
+ 'image/webp': 'webp', 'image/svg+xml': 'svg',
246
+ };
247
+ const CT_MAP = {
248
+ jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
249
+ webp: 'image/webp', svg: 'image/svg+xml',
250
+ };
251
+
252
+ function addContentType(files, ext, partName) {
253
+ const ctPath = '[Content_Types].xml';
254
+ const doc = readXml(files, ctPath);
255
+ if (!doc) return;
256
+ const root = doc.documentElement;
257
+ // Check if Override already exists
258
+ for (const ov of gtn(doc, 'Override')) {
259
+ if (ov.getAttribute('PartName') === '/' + partName) return;
260
+ }
261
+ const ov = doc.createElementNS(NS.ct, 'Override');
262
+ ov.setAttribute('PartName', '/' + partName);
263
+ ov.setAttribute('ContentType', CT_MAP[ext] || 'application/octet-stream');
264
+ root.appendChild(ov);
265
+ files[ctPath] = xmlBytes(doc);
266
+ }
267
+
268
+ // ── PptxWriter ────────────────────────────────────────────────────────────────
269
+
270
+ export class PptxWriter {
271
+ constructor(files) {
272
+ /** @private Mutable copy of all ZIP entries */
273
+ this._files = files;
274
+
275
+ // Parse presentation.xml once
276
+ this._presPath = 'ppt/presentation.xml';
277
+ this._presDoc = readXml(files, this._presPath);
278
+ this._presRels = parseRels(files, this._presPath);
279
+
280
+ // Build ordered slide path list
281
+ this._slidePaths = this._buildSlidePaths();
282
+ }
283
+
284
+ // ── Factory ─────────────────────────────────────────────────────────────────
285
+
286
+ /** Clone from an already-loaded PptxRenderer. O(1) — shares byte arrays. */
287
+ static fromRenderer(renderer) {
288
+ // Deep-copy the files map so mutations don't affect the renderer
289
+ const files = {};
290
+ for (const [k, v] of Object.entries(renderer._files)) {
291
+ files[k] = v instanceof Uint8Array ? v.slice() : v;
292
+ }
293
+ return new PptxWriter(files);
294
+ }
295
+
296
+ /** Parse from raw ArrayBuffer or Uint8Array. */
297
+ static async fromBytes(buffer) {
298
+ const files = await readZip(buffer);
299
+ return new PptxWriter(files);
300
+ }
301
+
302
+ // ── Slide list ──────────────────────────────────────────────────────────────
303
+
304
+ _buildSlidePaths() {
305
+ if (!this._presDoc) return [];
306
+ const sldIdLst = g1(this._presDoc, 'sldIdLst');
307
+ if (!sldIdLst) return [];
308
+ const paths = [];
309
+ for (const sldId of sldIdLst.children) {
310
+ if (sldId.localName !== 'sldId') continue;
311
+ const rId = sldId.getAttribute('r:id') || sldId.getAttribute('id');
312
+ const rel = this._presRels[rId];
313
+ if (rel) paths.push(rel.fullPath);
314
+ }
315
+ return paths;
316
+ }
317
+
318
+ _savePresDoc() {
319
+ this._files[this._presPath] = xmlBytes(this._presDoc);
320
+ }
321
+
322
+ _savePresRels() {
323
+ this._files[relsPath(this._presPath)] = xmlBytes(buildRelsDoc(this._presRels));
324
+ }
325
+
326
+ getSlidePaths() { return [...this._slidePaths]; }
327
+ getSlideCount() { return this._slidePaths.length; }
328
+
329
+ _slideDoc(idx) {
330
+ const path = this._slidePaths[idx];
331
+ if (!path) throw new RangeError(`Slide ${idx} out of range`);
332
+ return readXml(this._files, path);
333
+ }
334
+
335
+ _saveSlideDoc(idx, doc) {
336
+ this._files[this._slidePaths[idx]] = xmlBytes(doc);
337
+ }
338
+
339
+ // ── Template substitution ────────────────────────────────────────────────────
340
+
341
+ /**
342
+ * Replace `{{key}}` placeholders with values from a data object.
343
+ * Applied to every slide, every text shape, and speaker notes.
344
+ *
345
+ * @param {Record<string, string|number>} data
346
+ * @param {object} [opts]
347
+ * @param {string} [opts.open='{{']
348
+ * @param {string} [opts.close='}}']
349
+ * @param {number[]} [opts.slides] limit to specific slide indices
350
+ */
351
+ applyTemplate(data, opts = {}) {
352
+ const { open = '{{', close = '}}', slides } = opts;
353
+ const indices = slides ?? this._slidePaths.map((_, i) => i);
354
+
355
+ for (const idx of indices) {
356
+ const doc = this._slideDoc(idx);
357
+
358
+ for (const [key, value] of Object.entries(data)) {
359
+ const token = open + key + close;
360
+ replaceInDoc(doc, token, String(value));
361
+ }
362
+
363
+ this._saveSlideDoc(idx, doc);
364
+ }
365
+
366
+ // Also apply to speaker notes
367
+ for (const idx of indices) {
368
+ this._applyTemplateToNotes(idx, data, open, close);
369
+ }
370
+ }
371
+
372
+ _applyTemplateToNotes(idx, data, open, close) {
373
+ const slideRels = parseRels(this._files, this._slidePaths[idx]);
374
+ const notesRel = Object.values(slideRels).find(r => r.type?.includes('notesSlide'));
375
+ if (!notesRel) return;
376
+ const notesDoc = readXml(this._files, notesRel.fullPath);
377
+ if (!notesDoc) return;
378
+ for (const [key, value] of Object.entries(data)) {
379
+ replaceInDoc(notesDoc, open + key + close, String(value));
380
+ }
381
+ this._files[notesRel.fullPath] = xmlBytes(notesDoc);
382
+ }
383
+
384
+ // ── Global find-and-replace ──────────────────────────────────────────────────
385
+
386
+ /**
387
+ * Find and replace text across all (or specified) slides.
388
+ * @param {string} find
389
+ * @param {string} replace
390
+ * @param {object} [opts]
391
+ * @param {boolean} [opts.caseSensitive=true]
392
+ * @param {boolean} [opts.includeNotes=false]
393
+ * @param {number[]} [opts.slides]
394
+ */
395
+ replaceText(find, replace, opts = {}) {
396
+ const { caseSensitive = true, includeNotes = false, slides } = opts;
397
+ const indices = slides ?? this._slidePaths.map((_, i) => i);
398
+
399
+ for (const idx of indices) {
400
+ const doc = this._slideDoc(idx);
401
+ replaceInDoc(doc, find, replace, caseSensitive);
402
+ this._saveSlideDoc(idx, doc);
403
+
404
+ if (includeNotes) {
405
+ const slideRels = parseRels(this._files, this._slidePaths[idx]);
406
+ const notesRel = Object.values(slideRels).find(r => r.type?.includes('notesSlide'));
407
+ if (notesRel) {
408
+ const nd = readXml(this._files, notesRel.fullPath);
409
+ if (nd) {
410
+ replaceInDoc(nd, find, replace, caseSensitive);
411
+ this._files[notesRel.fullPath] = xmlBytes(nd);
412
+ }
413
+ }
414
+ }
415
+ }
416
+ }
417
+
418
+ // ── Shape text ───────────────────────────────────────────────────────────────
419
+
420
+ /**
421
+ * Set the text content of a named shape on a slide.
422
+ * Preserves the formatting of the first run; clears all other runs.
423
+ *
424
+ * @param {number} slideIdx
425
+ * @param {string} shapeName exact `name` attribute of the shape
426
+ * @param {string} text new text (use \n for line breaks)
427
+ * @param {object} [opts]
428
+ * @param {boolean} [opts.preserveFormatting=true]
429
+ */
430
+ setShapeText(slideIdx, shapeName, text, opts = {}) {
431
+ const { preserveFormatting = true } = opts;
432
+ const doc = this._slideDoc(slideIdx);
433
+ const spTree = getSpTree(doc);
434
+ if (!spTree) return this;
435
+
436
+ const shape = findShapeByName(spTree, shapeName);
437
+ if (!shape) throw new Error(`Shape "${shapeName}" not found on slide ${slideIdx}`);
438
+
439
+ const txBody = g1(shape, 'txBody');
440
+ if (!txBody) return this;
441
+
442
+ // Get reference run properties
443
+ const firstRun = g1(txBody, 'r');
444
+ const refRPr = firstRun ? g1(firstRun, 'rPr') : null;
445
+ const refPPr = g1(g1(txBody, 'p'), 'pPr');
446
+
447
+ // Remove all existing paragraphs
448
+ for (const p of gtn(txBody, 'p')) p.parentNode.removeChild(p);
449
+
450
+ const lines = text.split('\n');
451
+ const nsA = NS.a;
452
+
453
+ for (const line of lines) {
454
+ const p = doc.createElementNS(nsA, 'a:p');
455
+
456
+ if (refPPr && preserveFormatting) {
457
+ p.appendChild(refPPr.cloneNode(true));
458
+ }
459
+
460
+ const r = doc.createElementNS(nsA, 'a:r');
461
+ if (refRPr && preserveFormatting) {
462
+ r.appendChild(refRPr.cloneNode(true));
463
+ }
464
+ const t = doc.createElementNS(nsA, 'a:t');
465
+ t.textContent = line;
466
+ r.appendChild(t);
467
+ p.appendChild(r);
468
+ txBody.appendChild(p);
469
+ }
470
+
471
+ this._saveSlideDoc(slideIdx, doc);
472
+ return this;
473
+ }
474
+
475
+ /**
476
+ * Read the plain text of a named shape.
477
+ * @param {number} slideIdx
478
+ * @param {string} shapeName
479
+ * @returns {string}
480
+ */
481
+ getShapeText(slideIdx, shapeName) {
482
+ const doc = this._slideDoc(slideIdx);
483
+ const spTree = getSpTree(doc);
484
+ if (!spTree) return '';
485
+ const shape = findShapeByName(spTree, shapeName);
486
+ if (!shape) return '';
487
+ return gtn(shape, 't').map(t => t.textContent).join('');
488
+ }
489
+
490
+ // ── Add text box ─────────────────────────────────────────────────────────────
491
+
492
+ /**
493
+ * Add a new text box to a slide.
494
+ *
495
+ * @param {number} slideIdx
496
+ * @param {string} text
497
+ * @param {object} style
498
+ * @param {number} style.x EMU from left edge
499
+ * @param {number} style.y EMU from top edge
500
+ * @param {number} style.w EMU width
501
+ * @param {number} style.h EMU height
502
+ * @param {string} [style.color] hex colour, no #
503
+ * @param {number} [style.fontSize] pt * 100 (e.g. 2400 = 24pt)
504
+ * @param {boolean}[style.bold]
505
+ * @param {string} [style.align] l|ctr|r
506
+ * @param {string} [style.fontFamily]
507
+ */
508
+ addTextBox(slideIdx, text, style = {}) {
509
+ const {
510
+ x = 914400, y = 914400, w = 4572000, h = 914400,
511
+ color = '000000', fontSize = 1800, bold = false,
512
+ align = 'l', fontFamily = 'Calibri',
513
+ } = style;
514
+
515
+ const doc = this._slideDoc(slideIdx);
516
+ const spTree = getSpTree(doc);
517
+ if (!spTree) return this;
518
+
519
+ // Next shape ID
520
+ const maxId = Math.max(0, ...gtn(spTree, 'cNvPr').map(e => parseInt(e.getAttribute('id') || '0', 10)));
521
+ const newId = maxId + 1;
522
+ const name = `TextBox ${newId}`;
523
+
524
+ const nsA = NS.a, nsP = NS.p;
525
+
526
+ const xml = `<p:sp xmlns:p="${nsP}" xmlns:a="${nsA}">
527
+ <p:nvSpPr>
528
+ <p:cNvPr id="${newId}" name="${name}"/>
529
+ <p:cNvSpPr txBox="1"><a:spLocks noGrp="1"/></p:cNvSpPr>
530
+ <p:nvPr/>
531
+ </p:nvSpPr>
532
+ <p:spPr>
533
+ <a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm>
534
+ <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
535
+ <a:noFill/>
536
+ </p:spPr>
537
+ <p:txBody>
538
+ <a:bodyPr wrap="square" rtlCol="0"><a:spAutoFit/></a:bodyPr>
539
+ <a:lstStyle/>
540
+ <a:p>
541
+ <a:pPr algn="${align}"/>
542
+ <a:r>
543
+ <a:rPr lang="en-US" sz="${fontSize}" b="${bold ? 1 : 0}" dirty="0">
544
+ <a:solidFill><a:srgbClr val="${color}"/></a:solidFill>
545
+ <a:latin typeface="${fontFamily}"/>
546
+ </a:rPr>
547
+ <a:t>${text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</a:t>
548
+ </a:r>
549
+ </a:p>
550
+ </p:txBody>
551
+ </p:sp>`;
552
+
553
+ const frag = parseXml(xml);
554
+ spTree.appendChild(doc.adoptNode(frag.documentElement));
555
+ this._saveSlideDoc(slideIdx, doc);
556
+ return this;
557
+ }
558
+
559
+ // ── Image replacement ─────────────────────────────────────────────────────────
560
+
561
+ /**
562
+ * Replace the image in a named picture shape.
563
+ *
564
+ * @param {number} slideIdx
565
+ * @param {string} shapeName
566
+ * @param {Uint8Array} imageBytes
567
+ * @param {string} [mimeType='image/jpeg']
568
+ */
569
+ async setShapeImage(slideIdx, shapeName, imageBytes, mimeType = 'image/jpeg') {
570
+ const doc = this._slideDoc(slideIdx);
571
+ const spTree = getSpTree(doc);
572
+ if (!spTree) return this;
573
+
574
+ const shape = findShapeByName(spTree, shapeName);
575
+ if (!shape) throw new Error(`Shape "${shapeName}" not found on slide ${slideIdx}`);
576
+
577
+ const slideRels = parseRels(this._files, this._slidePaths[slideIdx]);
578
+
579
+ // Find existing blip rId
580
+ const blipFill = g1(shape, 'blipFill');
581
+ const blip = blipFill ? g1(blipFill, 'blip') : null;
582
+ const oldRId = blip ? (blip.getAttribute('r:embed') || blip.getAttribute('embed')) : null;
583
+ const oldRel = oldRId ? slideRels[oldRId] : null;
584
+
585
+ // Write new media file
586
+ const ext = MIME_EXT[mimeType] || 'jpeg';
587
+ const mediaIdx = Object.keys(this._files).filter(p => p.startsWith('ppt/media/')).length + 1;
588
+ const mediaPath = `ppt/media/image${mediaIdx}.${ext}`;
589
+ this._files[mediaPath] = imageBytes;
590
+
591
+ // Update or create relationship
592
+ let rId;
593
+ if (oldRId && oldRel) {
594
+ // Reuse the old rId, just point to the new file
595
+ rId = oldRId;
596
+ slideRels[rId] = {
597
+ id: rId, type: oldRel.type,
598
+ target: `../media/image${mediaIdx}.${ext}`,
599
+ fullPath: mediaPath,
600
+ };
601
+ } else {
602
+ rId = nextRId(slideRels);
603
+ slideRels[rId] = {
604
+ id: rId,
605
+ type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
606
+ target: `../media/image${mediaIdx}.${ext}`,
607
+ fullPath: mediaPath,
608
+ };
609
+ }
610
+
611
+ // Update the blip element
612
+ if (blip) {
613
+ blip.setAttribute('r:embed', rId);
614
+ }
615
+
616
+ // Save updated rels and slide doc
617
+ this._files[relsPath(this._slidePaths[slideIdx])] = xmlBytes(buildRelsDoc(slideRels));
618
+ this._saveSlideDoc(slideIdx, doc);
619
+
620
+ // Add content type
621
+ addContentType(this._files, ext, mediaPath);
622
+ return this;
623
+ }
624
+
625
+ /**
626
+ * Add a new image shape to a slide.
627
+ *
628
+ * @param {number} slideIdx
629
+ * @param {Uint8Array} imageBytes
630
+ * @param {string} [mimeType='image/jpeg']
631
+ * @param {object} rect { x, y, w, h } in EMU
632
+ */
633
+ async addImage(slideIdx, imageBytes, mimeType = 'image/jpeg', rect = {}) {
634
+ const {
635
+ x = 914400, y = 914400,
636
+ w = 2743200, h = 2057400,
637
+ } = rect;
638
+
639
+ const ext = MIME_EXT[mimeType] || 'jpeg';
640
+ const mediaIdx = Object.keys(this._files).filter(p => p.startsWith('ppt/media/')).length + 1;
641
+ const mediaPath = `ppt/media/image${mediaIdx}.${ext}`;
642
+ this._files[mediaPath] = imageBytes;
643
+
644
+ const slideRels = parseRels(this._files, this._slidePaths[slideIdx]);
645
+ const rId = nextRId(slideRels);
646
+ slideRels[rId] = {
647
+ id: rId,
648
+ type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
649
+ target: `../media/image${mediaIdx}.${ext}`,
650
+ fullPath: mediaPath,
651
+ };
652
+ this._files[relsPath(this._slidePaths[slideIdx])] = xmlBytes(buildRelsDoc(slideRels));
653
+
654
+ const doc = this._slideDoc(slideIdx);
655
+ const spTree = getSpTree(doc);
656
+ if (!spTree) return this;
657
+
658
+ const maxId = Math.max(0, ...gtn(spTree, 'cNvPr').map(e => parseInt(e.getAttribute('id') || '0', 10)));
659
+ const newId = maxId + 1;
660
+ const nsA = NS.a, nsP = NS.p;
661
+
662
+ const xml = `<p:pic xmlns:p="${nsP}" xmlns:a="${nsA}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
663
+ <p:nvPicPr>
664
+ <p:cNvPr id="${newId}" name="Picture ${newId}"/>
665
+ <p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
666
+ <p:nvPr/>
667
+ </p:nvPicPr>
668
+ <p:blipFill>
669
+ <a:blip r:embed="${rId}"/>
670
+ <a:stretch><a:fillRect/></a:stretch>
671
+ </p:blipFill>
672
+ <p:spPr>
673
+ <a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm>
674
+ <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
675
+ </p:spPr>
676
+ </p:pic>`;
677
+
678
+ const frag = parseXml(xml);
679
+ spTree.appendChild(doc.adoptNode(frag.documentElement));
680
+ this._saveSlideDoc(slideIdx, doc);
681
+ addContentType(this._files, ext, mediaPath);
682
+ return this;
683
+ }
684
+
685
+ // ── Slide background ─────────────────────────────────────────────────────────
686
+
687
+ /**
688
+ * Set a solid colour background on a slide.
689
+ * @param {number} slideIdx
690
+ * @param {string} hexRgb 6-digit hex, no '#'
691
+ */
692
+ setSlideBackground(slideIdx, hexRgb) {
693
+ const doc = this._slideDoc(slideIdx);
694
+ const cSld = g1(doc, 'cSld');
695
+ if (!cSld) return this;
696
+
697
+ // Remove existing bg
698
+ const oldBg = g1(cSld, 'bg');
699
+ if (oldBg) cSld.removeChild(oldBg);
700
+
701
+ const nsA = NS.a, nsP = NS.p;
702
+ const xml = `<p:bg xmlns:p="${nsP}" xmlns:a="${nsA}">
703
+ <p:bgPr><a:solidFill><a:srgbClr val="${hexRgb}"/></a:solidFill>
704
+ <a:effectLst/></p:bgPr></p:bg>`;
705
+ const bgEl = doc.adoptNode(parseXml(xml).documentElement);
706
+ // Insert bg as first child of cSld
707
+ cSld.insertBefore(bgEl, cSld.firstChild);
708
+ this._saveSlideDoc(slideIdx, doc);
709
+ return this;
710
+ }
711
+
712
+ // ── Theme colours ─────────────────────────────────────────────────────────────
713
+
714
+ /**
715
+ * Override a theme colour.
716
+ * Key: dk1|lt1|dk2|lt2|accent1…accent6|hlink|folHlink
717
+ * Value: 6-digit hex RGB, no '#'
718
+ *
719
+ * @param {string} key
720
+ * @param {string} hexRgb
721
+ */
722
+ setThemeColor(key, hexRgb) {
723
+ // Find theme file via presentation rels
724
+ const presRels = this._presRels;
725
+ let themePath = Object.values(presRels).find(r => r.type?.includes('theme'))?.fullPath;
726
+
727
+ if (!themePath) {
728
+ // Try via slide master
729
+ const masterRel = Object.values(presRels).find(r => r.type?.includes('slideMaster'));
730
+ if (masterRel) {
731
+ const mr = parseRels(this._files, masterRel.fullPath);
732
+ themePath = Object.values(mr).find(r => r.type?.includes('theme'))?.fullPath;
733
+ }
734
+ }
735
+ if (!themePath) return this;
736
+
737
+ const doc = readXml(this._files, themePath);
738
+ if (!doc) return this;
739
+
740
+ // Map theme key to element path: e.g. accent1 → a:accent1 > a:srgbClr
741
+ const fmtScheme = g1(doc, 'fmtScheme');
742
+ const clrScheme = g1(doc, 'clrScheme');
743
+ if (!clrScheme) return this;
744
+
745
+ // Find the element with matching local name
746
+ for (const child of clrScheme.children) {
747
+ if (child.localName === key) {
748
+ // Replace or set inner colour element
749
+ const srgb = g1(child, 'srgbClr');
750
+ if (srgb) {
751
+ srgb.setAttribute('val', hexRgb);
752
+ } else {
753
+ while (child.firstChild) child.removeChild(child.firstChild);
754
+ const nsA = NS.a;
755
+ const el = doc.createElementNS(nsA, 'a:srgbClr');
756
+ el.setAttribute('val', hexRgb);
757
+ child.appendChild(el);
758
+ }
759
+ break;
760
+ }
761
+ }
762
+
763
+ this._files[themePath] = xmlBytes(doc);
764
+ return this;
765
+ }
766
+
767
+ // ── Slide operations ──────────────────────────────────────────────────────────
768
+
769
+ /**
770
+ * Duplicate a slide.
771
+ * @param {number} fromIdx source slide index
772
+ * @param {number} [toIdx] insert position (default: end)
773
+ */
774
+ duplicateSlide(fromIdx, toIdx) {
775
+ const insertAt = toIdx ?? this._slidePaths.length;
776
+ const srcPath = this._slidePaths[fromIdx];
777
+ if (!srcPath) throw new RangeError(`Slide ${fromIdx} out of range`);
778
+
779
+ // Find next available slide number
780
+ const nums = Object.keys(this._files)
781
+ .map(p => p.match(/ppt\/slides\/slide(\d+)\.xml/))
782
+ .filter(Boolean).map(m => parseInt(m[1], 10));
783
+ const nextNum = (nums.length ? Math.max(...nums) : 0) + 1;
784
+
785
+ const newSlidePath = `ppt/slides/slide${nextNum}.xml`;
786
+ const newRelsPath = relsPath(newSlidePath);
787
+
788
+ // Copy slide XML
789
+ this._files[newSlidePath] = this._files[srcPath].slice();
790
+
791
+ // Copy slide rels (images etc. are shared)
792
+ const srcRelsPath = relsPath(srcPath);
793
+ if (this._files[srcRelsPath]) {
794
+ this._files[newRelsPath] = this._files[srcRelsPath].slice();
795
+ }
796
+
797
+ // Add relationship in presentation.xml.rels
798
+ const newRId = nextRId(this._presRels);
799
+ const target = `slides/slide${nextNum}.xml`;
800
+ this._presRels[newRId] = {
801
+ id: newRId,
802
+ type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide',
803
+ target,
804
+ fullPath: newSlidePath,
805
+ };
806
+ this._savePresRels();
807
+
808
+ // Add sldId to presentation.xml sldIdLst
809
+ const sldIdLst = g1(this._presDoc, 'sldIdLst');
810
+ if (sldIdLst) {
811
+ const ids = gtn(sldIdLst, 'sldId').map(el => parseInt(el.getAttribute('id') || '0', 10));
812
+ const nextId = (ids.length ? Math.max(...ids) : 255) + 1;
813
+ const nsP = NS.p;
814
+ const sldIdEl = this._presDoc.createElementNS(nsP, 'p:sldId');
815
+ sldIdEl.setAttribute('id', String(nextId));
816
+ sldIdEl.setAttributeNS(NS.r, 'r:id', newRId);
817
+
818
+ // Insert at correct position
819
+ const children = Array.from(sldIdLst.children);
820
+ if (insertAt >= children.length) {
821
+ sldIdLst.appendChild(sldIdEl);
822
+ } else {
823
+ sldIdLst.insertBefore(sldIdEl, children[insertAt]);
824
+ }
825
+ }
826
+ this._savePresDoc();
827
+
828
+ // Rebuild slide path list
829
+ this._slidePaths = this._buildSlidePaths();
830
+
831
+ // Add content type for new slide
832
+ const ctPath = '[Content_Types].xml';
833
+ const ctDoc = readXml(this._files, ctPath);
834
+ if (ctDoc) {
835
+ const root = ctDoc.documentElement;
836
+ const ov = ctDoc.createElementNS(NS.ct, 'Override');
837
+ ov.setAttribute('PartName', '/' + newSlidePath);
838
+ ov.setAttribute('ContentType', 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml');
839
+ root.appendChild(ov);
840
+ this._files[ctPath] = xmlBytes(ctDoc);
841
+ }
842
+
843
+ return this;
844
+ }
845
+
846
+ /**
847
+ * Remove a slide.
848
+ * @param {number} slideIdx
849
+ */
850
+ removeSlide(slideIdx) {
851
+ if (this._slidePaths.length <= 1) throw new Error('Cannot remove the last slide');
852
+ const path = this._slidePaths[slideIdx];
853
+ if (!path) throw new RangeError(`Slide ${slideIdx} out of range`);
854
+
855
+ // Remove from sldIdLst
856
+ const sldIdLst = g1(this._presDoc, 'sldIdLst');
857
+ if (sldIdLst) {
858
+ for (const sldId of Array.from(sldIdLst.children)) {
859
+ const rId = sldId.getAttribute('r:id') || sldId.getAttribute('id');
860
+ const rel = this._presRels[rId];
861
+ if (rel && rel.fullPath === path) {
862
+ sldIdLst.removeChild(sldId);
863
+ delete this._presRels[rId];
864
+ break;
865
+ }
866
+ }
867
+ }
868
+ this._savePresDoc();
869
+ this._savePresRels();
870
+ this._slidePaths = this._buildSlidePaths();
871
+ return this;
872
+ }
873
+
874
+ /**
875
+ * Reorder slides.
876
+ * @param {number[]} newOrder e.g. [2, 0, 1] to put slide 2 first
877
+ */
878
+ reorderSlides(newOrder) {
879
+ if (newOrder.length !== this._slidePaths.length) {
880
+ throw new Error('newOrder must have the same length as the current slide count');
881
+ }
882
+
883
+ const sldIdLst = g1(this._presDoc, 'sldIdLst');
884
+ if (!sldIdLst) return this;
885
+
886
+ const children = Array.from(sldIdLst.children).filter(el => el.localName === 'sldId');
887
+ // Detach all
888
+ for (const c of children) sldIdLst.removeChild(c);
889
+ // Re-attach in new order
890
+ for (const idx of newOrder) {
891
+ if (children[idx]) sldIdLst.appendChild(children[idx]);
892
+ }
893
+ this._savePresDoc();
894
+ this._slidePaths = this._buildSlidePaths();
895
+ return this;
896
+ }
897
+
898
+ // ── Speaker notes ─────────────────────────────────────────────────────────────
899
+
900
+ /**
901
+ * Set the speaker notes for a slide. Creates the notes slide if absent.
902
+ * @param {number} slideIdx
903
+ * @param {string} text
904
+ */
905
+ setSlideNotes(slideIdx, text) {
906
+ const slidePath = this._slidePaths[slideIdx];
907
+ const slideRels = parseRels(this._files, slidePath);
908
+ const notesRel = Object.values(slideRels).find(r => r.type?.includes('notesSlide'));
909
+
910
+ if (notesRel) {
911
+ const nd = readXml(this._files, notesRel.fullPath);
912
+ if (nd) {
913
+ for (const sp of gtn(nd, 'sp')) {
914
+ const nvPr = g1(g1(sp, 'nvSpPr'), 'nvPr');
915
+ const ph = nvPr ? g1(nvPr, 'ph') : null;
916
+ if (ph && attr(ph, 'type') !== 'sldNum') {
917
+ for (const t of gtn(sp, 't')) t.textContent = '';
918
+ const firstT = g1(sp, 't');
919
+ if (firstT) firstT.textContent = text;
920
+ break;
921
+ }
922
+ }
923
+ this._files[notesRel.fullPath] = xmlBytes(nd);
924
+ }
925
+ } else {
926
+ // Create a minimal notes slide
927
+ this._createNotesSlide(slideIdx, slidePath, slideRels, text);
928
+ }
929
+ return this;
930
+ }
931
+
932
+ _createNotesSlide(slideIdx, slidePath, slideRels, text) {
933
+ const num = Object.keys(this._files).filter(p => p.startsWith('ppt/notesSlides/')).length + 1;
934
+ const nsP = NS.p, nsA = NS.a;
935
+ const notesPath = `ppt/notesSlides/notesSlide${num}.xml`;
936
+
937
+ const notesXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
938
+ <p:notes xmlns:p="${nsP}" xmlns:a="${nsA}">
939
+ <p:cSld><p:spTree>
940
+ <p:sp>
941
+ <p:nvSpPr><p:cNvPr id="2" name="Notes Placeholder 1"/>
942
+ <p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
943
+ <p:nvPr><p:ph type="body" idx="1"/></p:nvPr>
944
+ </p:nvSpPr>
945
+ <p:spPr/>
946
+ <p:txBody><a:bodyPr/><a:lstStyle/>
947
+ <a:p><a:r><a:t>${text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</a:t></a:r></a:p>
948
+ </p:txBody>
949
+ </p:sp>
950
+ </p:spTree></p:cSld>
951
+ </p:notes>`;
952
+
953
+ this._files[notesPath] = enc.encode(notesXml);
954
+
955
+ const newRId = nextRId(slideRels);
956
+ slideRels[newRId] = {
957
+ id: newRId,
958
+ type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide',
959
+ target: `../notesSlides/notesSlide${num}.xml`,
960
+ fullPath: notesPath,
961
+ };
962
+ this._files[relsPath(slidePath)] = xmlBytes(buildRelsDoc(slideRels));
963
+ }
964
+
965
+ // ── Serialisation ─────────────────────────────────────────────────────────────
966
+
967
+ /**
968
+ * Serialize the edited PPTX to bytes.
969
+ * @returns {Promise<Uint8Array>}
970
+ */
971
+ async save() {
972
+ return writeZip(this._files);
973
+ }
974
+
975
+ /**
976
+ * Download as a PPTX file in the browser.
977
+ * @param {string} [filename='edited.pptx']
978
+ */
979
+ async download(filename = 'edited.pptx') {
980
+ const bytes = await this.save();
981
+ const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' });
982
+ const url = URL.createObjectURL(blob);
983
+ const a = document.createElement('a');
984
+ a.href = url;
985
+ a.download = filename;
986
+ a.click();
987
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
988
+ }
989
+
990
+ // ── Utility ───────────────────────────────────────────────────────────────────
991
+
992
+ /**
993
+ * List all shape names on a slide.
994
+ * @param {number} slideIdx
995
+ * @returns {Array<{id, name, type}>}
996
+ */
997
+ listShapes(slideIdx) {
998
+ const doc = this._slideDoc(slideIdx);
999
+ const spTree = getSpTree(doc);
1000
+ if (!spTree) return [];
1001
+ const shapes = [];
1002
+ for (const child of spTree.children) {
1003
+ const ln = child.localName;
1004
+ if (!['sp','pic','cxnSp','graphicFrame'].includes(ln)) continue;
1005
+ const nvEl = g1(child, 'nvSpPr') || g1(child, 'nvPicPr') || g1(child, 'nvGraphicFramePr') || g1(child, 'nvCxnSpPr');
1006
+ const cNvPr = nvEl ? g1(nvEl, 'cNvPr') : null;
1007
+ shapes.push({
1008
+ id: cNvPr?.getAttribute('id') || '',
1009
+ name: cNvPr?.getAttribute('name') || '',
1010
+ type: ln,
1011
+ });
1012
+ }
1013
+ return shapes;
1014
+ }
1015
+ }