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/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';
|