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/LICENSE +9 -0
- package/README.md +209 -0
- package/package.json +53 -0
- package/src/animation.js +817 -0
- package/src/charts.js +989 -0
- package/src/clipboard.js +416 -0
- package/src/colors.js +297 -0
- package/src/effects3d.js +312 -0
- package/src/extract.js +535 -0
- package/src/fntdata.js +265 -0
- package/src/fonts.js +676 -0
- package/src/index.js +751 -0
- package/src/pdf.js +298 -0
- package/src/render.js +1964 -0
- package/src/shapes.js +666 -0
- package/src/slideshow.js +492 -0
- package/src/smartart.js +696 -0
- package/src/svg.js +732 -0
- package/src/theme.js +88 -0
- package/src/utils.js +50 -0
- package/src/writer.js +1015 -0
- package/src/zip-writer.js +214 -0
- package/src/zip.js +194 -0
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,'&').replace(/</g,'<').replace(/>/g,'>')}</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,'&').replace(/</g,'<').replace(/>/g,'>')}</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
|
+
}
|