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/index.js ADDED
@@ -0,0 +1,751 @@
1
+ /**
2
+ * index.js — PptxRenderer
3
+ *
4
+ * Load and render PPTX files onto an HTML Canvas element.
5
+ * Zero dependencies — uses native browser APIs only.
6
+ *
7
+ * Usage
8
+ * ─────
9
+ * import { PptxRenderer } from 'pptx-browser';
10
+ *
11
+ * const renderer = new PptxRenderer();
12
+ * await renderer.load(fileOrArrayBuffer); // load the PPTX
13
+ * console.log(renderer.slideCount); // e.g. 12
14
+ * await renderer.renderSlide(0, canvasElement); // render slide 0
15
+ * renderer.destroy(); // free blob URLs
16
+ *
17
+ * Compatible environments
18
+ * ────────────────────────
19
+ * Chrome 80+, Firefox 113+, Safari 16.4+, Node.js 18+ (with node-canvas)
20
+ */
21
+
22
+ import { readZip } from './zip.js';
23
+ import { parseXml, g1, gtn, attr, attrInt } from './utils.js';
24
+ import { parseTheme, parseClrMap, buildThemeColors } from './theme.js';
25
+ import {
26
+ collectUsedFonts, loadGoogleFontsFor,
27
+ registerFont, registerFonts,
28
+ detectEmbeddedFonts, listRegisteredFonts,
29
+ } from './fonts.js';
30
+ import {
31
+ getRels, loadImages,
32
+ renderBackground,
33
+ renderShape, renderPicture, renderGroupShape,
34
+ renderGraphicFrame, renderConnector,
35
+ renderSpTree, buildPlaceholderMap,
36
+ } from './render.js';
37
+ import { PptxWriter } from './writer.js';
38
+ import { exportToPdf, downloadAsPdf, exportSlideToPdf } from './pdf.js';
39
+
40
+ export default class PptxRenderer {
41
+ constructor() {
42
+ /** @type {Record<string, Uint8Array>} raw files extracted from the ZIP */
43
+ this._files = {};
44
+ this.slideSize = { cx: 9144000, cy: 5143500 }; // EMU
45
+ this.slidePaths = [];
46
+ /** Total number of slides */
47
+ this.slideCount = 0;
48
+
49
+ this.themeData = null;
50
+ this.themeColors = {};
51
+ this.masterDoc = null;
52
+ this.masterRels = {};
53
+ this.masterImages = {};
54
+
55
+ /** Blob URLs to revoke on destroy() */
56
+ this._blobUrls = [];
57
+
58
+ /**
59
+ * Fonts embedded in the PPTX (detected during load).
60
+ * @type {Array}
61
+ */
62
+ this.embeddedFonts = [];
63
+ }
64
+
65
+ // ── Loading ──────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Load a PPTX file.
69
+ * @param {File|Blob|ArrayBuffer|Uint8Array} source
70
+ * @param {(progress: number, message: string) => void} [onProgress]
71
+ */
72
+ async load(source, onProgress = () => {}) {
73
+ // Normalise input to ArrayBuffer
74
+ let buf;
75
+ if (source instanceof Uint8Array) {
76
+ buf = source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
77
+ } else if (source instanceof ArrayBuffer) {
78
+ buf = source;
79
+ } else if (typeof source.arrayBuffer === 'function') {
80
+ buf = await source.arrayBuffer(); // File / Blob
81
+ } else {
82
+ throw new TypeError('PptxRenderer.load(): expected File, Blob, ArrayBuffer, or Uint8Array');
83
+ }
84
+
85
+ onProgress(0.05, 'Decompressing…');
86
+ this._files = await readZip(buf);
87
+
88
+ // ── presentation.xml ─────────────────────────────────────────────────
89
+ onProgress(0.1, 'Parsing presentation…');
90
+ const presXml = this._readText('ppt/presentation.xml');
91
+ if (!presXml) throw new Error('Invalid PPTX: missing ppt/presentation.xml');
92
+ const presDoc = parseXml(presXml);
93
+
94
+ const sldSz = g1(presDoc, 'sldSz');
95
+ if (sldSz) {
96
+ this.slideSize = {
97
+ cx: attrInt(sldSz, 'cx', 9144000),
98
+ cy: attrInt(sldSz, 'cy', 5143500),
99
+ };
100
+ }
101
+
102
+ const presRels = await getRels(this._files, 'ppt/presentation.xml');
103
+
104
+ // Detect embedded fonts (informational)
105
+ this.embeddedFonts = detectEmbeddedFonts(presDoc, presRels);
106
+ if (this.embeddedFonts.length > 0) {
107
+ const names = this.embeddedFonts.map(f => f.family).join(', ');
108
+ console.info('[pptx-browser] PPTX contains embedded fonts: ' + names + '. Use registerFont() to supply woff2/ttf versions.');
109
+ }
110
+
111
+ const sldIdLst = g1(presDoc, 'sldIdLst');
112
+ if (sldIdLst) {
113
+ for (const sldId of sldIdLst.children) {
114
+ if (sldId.localName !== 'sldId') continue;
115
+ const rId = sldId.getAttribute('r:id') || sldId.getAttribute('id');
116
+ const rel = presRels[rId];
117
+ if (rel) this.slidePaths.push(rel.fullPath);
118
+ }
119
+ }
120
+ this.slideCount = this.slidePaths.length;
121
+
122
+ // ── Theme ─────────────────────────────────────────────────────────────
123
+ onProgress(0.2, 'Loading theme…');
124
+ let themePath = Object.values(presRels).find(r => r.type?.includes('theme'))?.fullPath;
125
+ if (!themePath) {
126
+ const masterRel2 = Object.values(presRels).find(r => r.type?.includes('slideMaster'));
127
+ if (masterRel2) {
128
+ const mr2 = await getRels(this._files, masterRel2.fullPath);
129
+ themePath = Object.values(mr2).find(r => r.type?.includes('theme'))?.fullPath;
130
+ }
131
+ }
132
+ if (themePath) {
133
+ const themeXml = this._readText(themePath);
134
+ if (themeXml) this.themeData = parseTheme(parseXml(themeXml));
135
+ }
136
+ if (!this.themeData) {
137
+ this.themeData = { colors: {}, majorFont: 'Calibri Light', minorFont: 'Calibri' };
138
+ }
139
+ this.themeColors = { ...this.themeData.colors };
140
+
141
+ // ── Slide master ──────────────────────────────────────────────────────
142
+ onProgress(0.3, 'Loading master…');
143
+ const masterRel = Object.values(presRels).find(r => r.type?.includes('slideMaster'));
144
+ if (masterRel) {
145
+ const masterXml = this._readText(masterRel.fullPath);
146
+ if (masterXml) {
147
+ this.masterDoc = parseXml(masterXml);
148
+ this.masterRels = await getRels(this._files, masterRel.fullPath);
149
+ this.masterImages = await loadImages(this._files, this.masterRels);
150
+ this._trackBlobs(this.masterImages);
151
+ const clrMap = parseClrMap(this.masterDoc);
152
+ this.themeColors = buildThemeColors(this.themeData, clrMap);
153
+ }
154
+ }
155
+
156
+ onProgress(1, 'Ready');
157
+ }
158
+
159
+ // ── Rendering ─────────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Render a single slide onto a canvas element.
163
+ *
164
+ * @param {number} slideIndex 0-based slide index
165
+ * @param {HTMLCanvasElement} canvas
166
+ * @param {number} [width=1280] output canvas width in pixels
167
+ */
168
+ async renderSlide(slideIndex, canvas, width = 1280) {
169
+ if (slideIndex < 0 || slideIndex >= this.slidePaths.length) {
170
+ throw new RangeError(`Slide ${slideIndex} out of range (0–${this.slidePaths.length - 1})`);
171
+ }
172
+
173
+ const slidePath = this.slidePaths[slideIndex];
174
+ const slideXml = this._readText(slidePath);
175
+ if (!slideXml) throw new Error(`Could not read slide: ${slidePath}`);
176
+
177
+ const slideDoc = parseXml(slideXml);
178
+ const slideRels = await getRels(this._files, slidePath);
179
+ const slideImages = await loadImages(this._files, slideRels);
180
+ this._trackBlobs(slideImages);
181
+
182
+ // Layout
183
+ let layoutDoc = null, layoutRels = {}, layoutImages = {};
184
+ const layoutRel = Object.values(slideRels).find(r => r.type?.includes('slideLayout'));
185
+ if (layoutRel) {
186
+ const layoutXml = this._readText(layoutRel.fullPath);
187
+ if (layoutXml) {
188
+ layoutDoc = parseXml(layoutXml);
189
+ layoutRels = await getRels(this._files, layoutRel.fullPath);
190
+ layoutImages = await loadImages(this._files, layoutRels);
191
+ this._trackBlobs(layoutImages);
192
+ }
193
+ }
194
+
195
+ const allImages = { ...this.masterImages, ...layoutImages, ...slideImages };
196
+ const placeholderMap = buildPlaceholderMap([layoutDoc, this.masterDoc]);
197
+
198
+ // Load Google Font substitutes for MS fonts used in this slide
199
+ const usedFonts = collectUsedFonts([slideDoc, layoutDoc, this.masterDoc]);
200
+ await loadGoogleFontsFor(usedFonts, this.themeData);
201
+
202
+ // Canvas setup
203
+ const scale = width / this.slideSize.cx;
204
+ const height = Math.round(this.slideSize.cy * scale);
205
+ canvas.width = width;
206
+ canvas.height = height;
207
+ const ctx = canvas.getContext('2d');
208
+ ctx.clearRect(0, 0, width, height);
209
+
210
+ // Background
211
+ await renderBackground(
212
+ ctx, slideDoc, this.masterDoc, layoutDoc,
213
+ slideRels, this.masterRels,
214
+ allImages, this.themeColors,
215
+ scale, this.slideSize.cx, this.slideSize.cy,
216
+ );
217
+
218
+ // Master decorative shapes (non-placeholder)
219
+ await this._renderNonPlaceholders(ctx, this.masterDoc, this.masterRels, this.masterImages, scale);
220
+
221
+ // Layout decorative shapes (non-placeholder)
222
+ await this._renderNonPlaceholders(ctx, layoutDoc, layoutRels, layoutImages, scale);
223
+
224
+ // Slide content
225
+ const cSld = g1(slideDoc, 'cSld');
226
+ const spTree = cSld ? g1(cSld, 'spTree') : null;
227
+ if (spTree) {
228
+ await renderSpTree(ctx, spTree, slideRels, allImages,
229
+ this.themeColors, this.themeData, scale, placeholderMap, this._files);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Render all slides and return an array of canvas elements.
235
+ * Useful for generating thumbnails.
236
+ *
237
+ * @param {number} [width=320]
238
+ * @returns {Promise<HTMLCanvasElement[]>}
239
+ */
240
+ async renderAllSlides(width = 320) {
241
+ const canvases = [];
242
+ for (let i = 0; i < this.slideCount; i++) {
243
+ const c = document.createElement('canvas');
244
+ await this.renderSlide(i, c, width);
245
+ canvases.push(c);
246
+ }
247
+ return canvases;
248
+ }
249
+
250
+ // ── Font management ────────────────────────────────────────────────────────
251
+
252
+ /**
253
+ * Register a custom font for this rendering session.
254
+ * Call before renderSlide() for fonts that should be used in the PPTX.
255
+ *
256
+ * Accepts woff, woff2, ttf, otf files.
257
+ *
258
+ * @param {string} family
259
+ * The font name exactly as it appears in the PPTX, OR the MS Office name
260
+ * it should replace (e.g. "Calibri" to override the default substitute).
261
+ * @param {string|URL|File|Blob|ArrayBuffer|Uint8Array} source
262
+ * @param {object} [descriptors] — FontFace descriptors: { weight, style, unicodeRange }
263
+ * @returns {Promise<FontFace>}
264
+ *
265
+ * @example
266
+ * // Load regular weight
267
+ * await renderer.registerFont('Acme Sans', '/fonts/acme-sans.woff2');
268
+ *
269
+ * // Load bold variant
270
+ * await renderer.registerFont('Acme Sans', '/fonts/acme-sans-bold.woff2', { weight: '700' });
271
+ *
272
+ * // From a File object (e.g. dropped by user)
273
+ * await renderer.registerFont('Acme Sans', file);
274
+ *
275
+ * // Override Calibri with your brand font
276
+ * await renderer.registerFont('Calibri', '/fonts/brand.woff2');
277
+ */
278
+ registerFont(family, source, descriptors) {
279
+ return registerFont(family, source, descriptors);
280
+ }
281
+
282
+ /**
283
+ * Register multiple custom fonts at once.
284
+ *
285
+ * @param {Record<string, string | Array<{url: string, weight?: string, style?: string}>>} fontMap
286
+ * @returns {Promise<void>}
287
+ *
288
+ * @example
289
+ * await renderer.registerFonts({
290
+ * 'Brand Sans': '/fonts/brand-sans.woff2',
291
+ * 'Brand Serif': [
292
+ * { url: '/fonts/brand-serif.woff2', weight: '400' },
293
+ * { url: '/fonts/brand-serif-bold.woff2', weight: '700' },
294
+ * ],
295
+ * });
296
+ */
297
+ registerFonts(fontMap) {
298
+ return registerFonts(fontMap);
299
+ }
300
+
301
+ /**
302
+ * List all custom fonts currently registered.
303
+ * @returns {{ family: string, weight: string, style: string, status: string }[]}
304
+ */
305
+ listRegisteredFonts() {
306
+ return listRegisteredFonts();
307
+ }
308
+
309
+ // ── Private helpers ───────────────────────────────────────────────────────
310
+
311
+ _readText(path) {
312
+ const data = this._files[path];
313
+ return data ? new TextDecoder().decode(data) : null;
314
+ }
315
+
316
+ _trackBlobs(imageCache) {
317
+ for (const img of Object.values(imageCache)) {
318
+ if (img?.src?.startsWith('blob:')) this._blobUrls.push(img.src);
319
+ }
320
+ }
321
+
322
+ async _renderNonPlaceholders(ctx, doc, rels, images, scale) {
323
+ if (!doc) return;
324
+ const cSld = g1(doc, 'cSld');
325
+ const spTree = cSld ? g1(cSld, 'spTree') : null;
326
+ if (!spTree) return;
327
+
328
+ for (const child of spTree.children) {
329
+ const ln = child.localName;
330
+ if (!['sp','pic','grpSp','graphicFrame','cxnSp'].includes(ln)) continue;
331
+
332
+ // Skip placeholders — those are filled by slide content
333
+ const nvSpPr = g1(child, 'nvSpPr');
334
+ const nvPr = nvSpPr ? g1(nvSpPr, 'nvPr') : null;
335
+ if (nvPr && g1(nvPr, 'ph')) continue;
336
+
337
+ try {
338
+ if (ln === 'sp') await renderShape(ctx, child, rels, images, this.themeColors, this.themeData, scale);
339
+ else if (ln === 'pic') await renderPicture(ctx, child, rels, images, this.themeColors, scale);
340
+ else if (ln === 'grpSp') await renderGroupShape(ctx, child, rels, images, this.themeColors, this.themeData, scale);
341
+ else if (ln === 'graphicFrame') await renderGraphicFrame(ctx, child, this.themeColors, this.themeData, scale);
342
+ else if (ln === 'cxnSp') await renderConnector(ctx, child, this.themeColors, scale);
343
+ } catch (e) {
344
+ console.warn(`[PptxRenderer] master/layout shape error (${ln}):`, e);
345
+ }
346
+ }
347
+ }
348
+
349
+ // ── Metadata ─────────────────────────────────────────────────────────────
350
+
351
+ /**
352
+ * Parse animation steps for a slide.
353
+ * Returns AnimStep[] sorted by clickNum then delay.
354
+ * @param {number} slideIndex
355
+ */
356
+ getAnimations(slideIndex) {
357
+ const slidePath = this.slidePaths[slideIndex];
358
+ if (!slidePath || !this._files) return [];
359
+ const raw = this._files[slidePath];
360
+ if (!raw) return [];
361
+ const slideDoc = parseXml(new TextDecoder().decode(raw));
362
+ return parseAnimations(slideDoc);
363
+ }
364
+
365
+ /**
366
+ * Parse slide transition info.
367
+ * @param {number} slideIndex
368
+ * @returns {{ type, duration, dir }|null}
369
+ */
370
+ getTransition(slideIndex) {
371
+ const slidePath = this.slidePaths[slideIndex];
372
+ if (!slidePath || !this._files) return null;
373
+ const raw = this._files[slidePath];
374
+ if (!raw) return null;
375
+ const slideDoc = parseXml(new TextDecoder().decode(raw));
376
+ return parseTransition(slideDoc);
377
+ }
378
+
379
+ /**
380
+ * Create a PptxPlayer bound to this renderer and a canvas element.
381
+ * Drives animation playback and slide transitions.
382
+ *
383
+ * @param {HTMLCanvasElement} canvas
384
+ * @returns {PptxPlayer}
385
+ * @example
386
+ * const player = renderer.createPlayer(canvas);
387
+ * await player.loadSlide(0);
388
+ * player.play();
389
+ * nextBtn.onclick = () => player.nextClick();
390
+ */
391
+ createPlayer(canvas) {
392
+ return new PptxPlayer(this, canvas);
393
+ }
394
+
395
+ // ── SVG Export ──────────────────────────────────────────────────────────────
396
+
397
+ /**
398
+ * Render a slide to an SVG string.
399
+ * SVG output has vector text (searchable), inline base64 images,
400
+ * and proper gradient fills — matches PowerPoint's "Save as SVG".
401
+ *
402
+ * @param {number} slideIndex
403
+ * @returns {Promise<string>} complete SVG markup
404
+ */
405
+ async toSvg(slideIndex) {
406
+ return renderSlideToSvg(slideIndex, this);
407
+ }
408
+
409
+ /**
410
+ * Render all slides to SVG strings.
411
+ * @returns {Promise<string[]>}
412
+ */
413
+ async allToSvg() {
414
+ return renderAllSlidesToSvg(this);
415
+ }
416
+
417
+ // ── Embedded fonts ──────────────────────────────────────────────────────────
418
+
419
+ /**
420
+ * Decode and load any embedded fonts from the PPTX.
421
+ * Fonts are loaded via FontFace API and become available to the renderer.
422
+ *
423
+ * @returns {Promise<EmbeddedFontResult[]>} per-variant load results
424
+ */
425
+ async loadEmbeddedFonts() {
426
+ const presRels = this._presRels || {};
427
+ return loadEmbeddedFonts(this._files, presRels);
428
+ }
429
+
430
+ /**
431
+ * List embedded fonts without loading them.
432
+ * @returns {EmbeddedFontInfo[]}
433
+ */
434
+ listEmbeddedFonts() {
435
+ return listEmbeddedFonts(this._files);
436
+ }
437
+
438
+ // ── Text extraction ─────────────────────────────────────────────────────────
439
+
440
+ /**
441
+ * Extract structured content from a slide.
442
+ * Returns title, body text, tables, chart series names, alt text.
443
+ * @param {number} slideIndex
444
+ * @returns {Promise<SlideContent>}
445
+ */
446
+ async extractSlide(slideIndex) {
447
+ return extractSlide(slideIndex, this);
448
+ }
449
+
450
+ /**
451
+ * Extract content from all slides.
452
+ * @returns {Promise<SlideContent[]>}
453
+ */
454
+ async extractAll() {
455
+ return extractAll(this);
456
+ }
457
+
458
+ /**
459
+ * Get all text from a slide as a plain string.
460
+ * @param {number} slideIndex
461
+ * @returns {Promise<string>}
462
+ */
463
+ async extractText(slideIndex) {
464
+ return extractText(slideIndex, this);
465
+ }
466
+
467
+ /**
468
+ * Full-text search across all slides.
469
+ * @param {string} query
470
+ * @returns {Promise<SearchResult[]>}
471
+ */
472
+ async searchSlides(query) {
473
+ return searchSlides(query, this);
474
+ }
475
+
476
+ // ── Slide show ──────────────────────────────────────────────────────────────
477
+
478
+ /**
479
+ * Create a full-screen slide show player.
480
+ *
481
+ * @param {HTMLElement} container — DOM element to mount into
482
+ * @param {object} [opts] — SlideShow options
483
+ * @returns {SlideShow}
484
+ *
485
+ * @example
486
+ * const show = renderer.createShow(document.body, { showNotes: true });
487
+ * await show.start(0);
488
+ * // keyboard: ←→ Space PageUp/Down Home End Esc F
489
+ */
490
+ createShow(container, opts = {}) {
491
+ return new SlideShow(this, container, opts);
492
+ }
493
+
494
+ // ── Clipboard / download ────────────────────────────────────────────────────
495
+
496
+ /**
497
+ * Copy a slide as a PNG image to the system clipboard.
498
+ * @param {number} slideIndex
499
+ * @param {object} [opts]
500
+ * @param {number} [opts.dpi=150] dots per inch
501
+ * @param {number} [opts.width] pixel width override
502
+ * @returns {Promise<{success, method, dataUrl}>}
503
+ */
504
+ async copySlide(slideIndex, opts = {}) {
505
+ return copySlideToClipboard(slideIndex, this, opts);
506
+ }
507
+
508
+ /**
509
+ * Download a slide as a PNG file.
510
+ * @param {number} slideIndex
511
+ * @param {object} [opts]
512
+ * @param {number} [opts.dpi=300] dots per inch
513
+ * @param {number} [opts.width] pixel width override
514
+ * @param {string} [opts.filename]
515
+ */
516
+ async downloadSlide(slideIndex, opts = {}) {
517
+ return downloadSlide(slideIndex, this, opts);
518
+ }
519
+
520
+ /**
521
+ * Download all slides as PNG files.
522
+ * @param {object} [opts]
523
+ * @param {number} [opts.dpi=300] dots per inch
524
+ * @param {number} [opts.width] pixel width override
525
+ * @param {function} [opts.onProgress] (completed, total) => void
526
+ */
527
+ async downloadAllSlides(opts = {}) {
528
+ return downloadAllSlides(this, opts);
529
+ }
530
+
531
+ // ── Progressive deck view ───────────────────────────────────────────────────
532
+
533
+ /**
534
+ * Create a scrollable deck view with progressive lazy rendering.
535
+ * Slides render on-demand as they scroll into the viewport.
536
+ *
537
+ * @param {HTMLElement} container
538
+ * @param {object} [opts] — LazyDeckOpts
539
+ * @returns {LazyDeckController}
540
+ *
541
+ * @example
542
+ * const deck = renderer.createDeck(document.getElementById('viewer'));
543
+ * // Scroll to slide 5:
544
+ * deck.scrollTo(5);
545
+ * // Force render everything:
546
+ * await deck.renderAll();
547
+ * // Clean up:
548
+ * deck.destroy();
549
+ */
550
+ createDeck(container, opts = {}) {
551
+ return createLazyDeck(this, container, opts);
552
+ }
553
+
554
+ // ── PPTX editing ────────────────────────────────────────────────────────────
555
+
556
+ /**
557
+ * Create a PptxWriter from this renderer for editing and re-export.
558
+ *
559
+ * @returns {PptxWriter}
560
+ *
561
+ * @example
562
+ * const writer = renderer.edit();
563
+ * writer.applyTemplate({ company: 'Acme', year: '2025' });
564
+ * writer.setShapeText(0, 'Title 1', 'New Title');
565
+ * writer.duplicateSlide(0);
566
+ * await writer.download('edited.pptx');
567
+ */
568
+ edit() {
569
+ return PptxWriter.fromRenderer(this);
570
+ }
571
+
572
+ // ── PDF export ──────────────────────────────────────────────────────────────
573
+
574
+ /**
575
+ * Export all slides (or a subset) to a PDF binary.
576
+ *
577
+ * @param {object} [opts]
578
+ * @param {number} [opts.dpi=150] dots per inch (96=screen, 150=default, 300=print)
579
+ * @param {number} [opts.width] pixel width — overrides dpi if set
580
+ * @param {number} [opts.quality=0.92] JPEG quality 0..1
581
+ * @param {number[]} [opts.slides] slide indices (default: all)
582
+ * @param {function} [opts.onProgress] (done, total) => void
583
+ * @returns {Promise<Uint8Array>}
584
+ *
585
+ * @example
586
+ * const bytes = await renderer.toPdf({ width: 2560, quality: 0.95 });
587
+ * const blob = new Blob([bytes], { type: 'application/pdf' });
588
+ */
589
+ async toPdf(opts = {}) {
590
+ return exportToPdf(this, opts);
591
+ }
592
+
593
+ /**
594
+ * Export and download as a PDF file.
595
+ * @param {string} [filename='presentation.pdf']
596
+ * @param {object} [opts]
597
+ */
598
+ async downloadPdf(filename = 'presentation.pdf', opts = {}) {
599
+ return downloadAsPdf(this, filename, opts);
600
+ }
601
+
602
+ /**
603
+ * Export a single slide to PDF bytes.
604
+ * @param {number} slideIndex
605
+ * @param {object} [opts]
606
+ * @returns {Promise<Uint8Array>}
607
+ */
608
+ async slideToPdf(slideIndex, opts = {}) {
609
+ return exportSlideToPdf(slideIndex, this, opts);
610
+ }
611
+
612
+ /**
613
+ * Get the speaker notes for a slide as plain text.
614
+ * @param {number} slideIndex
615
+ * @returns {Promise<string>} speaker notes, or empty string
616
+ */
617
+ async getSlideNotes(slideIndex) {
618
+ if (slideIndex < 0 || slideIndex >= this.slidePaths.length) return '';
619
+ const slideRels = await getRels(this._files, this.slidePaths[slideIndex]);
620
+ const notesRel = Object.values(slideRels).find(r => r.type?.includes('notesSlide'));
621
+ if (!notesRel) return '';
622
+ const notesXml = this._readText(notesRel.fullPath);
623
+ if (!notesXml) return '';
624
+ const doc = parseXml(notesXml);
625
+ // Collect all <a:t> text runs in notes, excluding the slide number placeholder
626
+ const texts = [];
627
+ for (const sp of gtn(doc, 'sp')) {
628
+ const nvPr = g1(g1(sp, 'nvSpPr'), 'nvPr');
629
+ const ph = nvPr ? g1(nvPr, 'ph') : null;
630
+ // Skip slide number placeholder (type=sldNum)
631
+ if (ph && (attr(ph, 'type') === 'sldNum')) continue;
632
+ for (const t of gtn(sp, 't')) {
633
+ texts.push(t.textContent);
634
+ }
635
+ }
636
+ return texts.join('').trim();
637
+ }
638
+
639
+ /**
640
+ * Get basic metadata about the presentation.
641
+ * @returns {{ slideCount: number, width: number, height: number, widthEmu: number, heightEmu: number }}
642
+ */
643
+ getInfo() {
644
+ return {
645
+ slideCount: this.slideCount,
646
+ widthEmu: this.slideSize.cx,
647
+ heightEmu: this.slideSize.cy,
648
+ /** Slide width in inches (1 EMU = 1/914400 inch) */
649
+ width: this.slideSize.cx / 914400,
650
+ /** Slide height in inches */
651
+ height: this.slideSize.cy / 914400,
652
+ /** Aspect ratio (width / height) */
653
+ aspectRatio: this.slideSize.cx / this.slideSize.cy,
654
+ };
655
+ }
656
+
657
+ /**
658
+ * Render a slide and return a data URL (PNG by default).
659
+ * @param {number} slideIndex
660
+ * @param {number} [width=1280]
661
+ * @param {string} [format='image/png']
662
+ * @param {number} [quality=0.92] used for image/jpeg
663
+ * @returns {Promise<string>} data URL
664
+ */
665
+ async toDataURL(slideIndex, width = 1280, format = 'image/png', quality = 0.92) {
666
+ const canvas = typeof OffscreenCanvas !== 'undefined'
667
+ ? new OffscreenCanvas(1, 1)
668
+ : document.createElement('canvas');
669
+ await this.renderSlide(slideIndex, canvas, width);
670
+ if (canvas instanceof OffscreenCanvas) {
671
+ const blob = await canvas.convertToBlob({ type: format, quality });
672
+ return new Promise(resolve => {
673
+ const fr = new FileReader();
674
+ fr.onload = () => resolve(fr.result);
675
+ fr.readAsDataURL(blob);
676
+ });
677
+ }
678
+ return canvas.toDataURL(format, quality);
679
+ }
680
+
681
+ /**
682
+ * Render a slide and return a Blob.
683
+ * @param {number} slideIndex
684
+ * @param {number} [width=1280]
685
+ * @param {string} [format='image/png']
686
+ * @returns {Promise<Blob>}
687
+ */
688
+ async toBlob(slideIndex, width = 1280, format = 'image/png') {
689
+ const canvas = typeof OffscreenCanvas !== 'undefined'
690
+ ? new OffscreenCanvas(1, 1)
691
+ : document.createElement('canvas');
692
+ await this.renderSlide(slideIndex, canvas, width);
693
+ if (canvas instanceof OffscreenCanvas) return canvas.convertToBlob({ type: format });
694
+ return new Promise(resolve => canvas.toBlob(resolve, format));
695
+ }
696
+
697
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
698
+
699
+ /** Release all blob: URLs created during rendering. */
700
+ destroy() {
701
+ for (const url of this._blobUrls) {
702
+ try { URL.revokeObjectURL(url); } catch (_) {}
703
+ }
704
+ this._blobUrls = [];
705
+ this._files = {};
706
+ this.masterDoc = null;
707
+ this.masterImages = {};
708
+ }
709
+ }
710
+
711
+ export { PptxRenderer };
712
+ export default PptxRenderer;
713
+
714
+ // Static font utilities — usable without a renderer instance
715
+ export {
716
+ registerFont,
717
+ registerFonts,
718
+ listRegisteredFonts,
719
+ clearRegisteredFonts,
720
+ isFontAvailable,
721
+ } from './fonts.js';
722
+
723
+ // Animation / playback utilities
724
+ export {
725
+ PptxPlayer,
726
+ parseAnimations,
727
+ parseTransition,
728
+ renderTransitionFrame,
729
+ compositeShape,
730
+ } from './animation.js';
731
+
732
+ // SVG export
733
+ export { renderSlideToSvg, renderAllSlidesToSvg } from './svg.js';
734
+
735
+ // Embedded font decoding
736
+ export { loadEmbeddedFonts, listEmbeddedFonts } from './fntdata.js';
737
+
738
+ // Text extraction / search
739
+ export { extractSlide, extractAll, extractText, searchSlides } from './extract.js';
740
+
741
+ // Slide show
742
+ export { SlideShow } from './slideshow.js';
743
+
744
+ // Clipboard / download / progressive rendering
745
+ export { copySlideToClipboard, downloadSlide, downloadAllSlides, createLazyDeck } from './clipboard.js';
746
+
747
+ // PPTX writer / editor
748
+ export { PptxWriter } from './writer.js';
749
+
750
+ // PDF export
751
+ export { exportToPdf, downloadAsPdf, exportSlideToPdf } from './pdf.js';