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/pdf.js ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * pdf.js — PPTX → PDF exporter. Zero dependencies.
3
+ *
4
+ * Renders each slide onto a canvas at target resolution, encodes as JPEG,
5
+ * and packages into a valid PDF/1.4 binary that any PDF viewer can open.
6
+ *
7
+ * Output is pixel-perfect — same rendering as the canvas renderer, just
8
+ * wrapped in a PDF container.
9
+ *
10
+ * Usage:
11
+ * import { exportToPdf } from 'pptx-canvas-renderer';
12
+ *
13
+ * // Render all slides → PDF bytes
14
+ * const pdfBytes = await exportToPdf(renderer);
15
+ *
16
+ * // Download as file
17
+ * await downloadAsPdf(renderer, 'presentation.pdf');
18
+ *
19
+ * // Or via renderer instance:
20
+ * const bytes = await renderer.toPdf({ quality: 0.92, width: 1920 });
21
+ * await renderer.downloadPdf('presentation.pdf');
22
+ *
23
+ * Options:
24
+ * width {number} render width per slide in pixels (default 1920)
25
+ * quality {number} JPEG quality 0..1 (default 0.92)
26
+ * slides {number[]} only export specified slides
27
+ * onProgress {function} (done, total) => void
28
+ */
29
+
30
+ // ── PDF binary helpers ────────────────────────────────────────────────────────
31
+
32
+ const CR = 0x0D, LF = 0x0A;
33
+ const enc = new TextEncoder();
34
+
35
+ // ── DPI → pixel width conversion ─────────────────────────────────────────────
36
+
37
+ /**
38
+ * Convert a DPI value to a pixel width for the given renderer's slide size.
39
+ * slideSize.cx is in EMU (914400 EMU = 1 inch).
40
+ */
41
+ function dpiToWidth(renderer, dpi) {
42
+ const inches = renderer.slideSize.cx / 914400;
43
+ return Math.round(inches * dpi);
44
+ }
45
+
46
+ class PdfBuf {
47
+ constructor() { this._parts = []; this._size = 0; }
48
+ write(s) {
49
+ const b = typeof s === 'string' ? enc.encode(s) : s;
50
+ this._parts.push(b);
51
+ this._size += b.length;
52
+ return this._size; // returns position AFTER this write
53
+ }
54
+ get size() { return this._size; }
55
+ concat() {
56
+ const out = new Uint8Array(this._size);
57
+ let off = 0;
58
+ for (const p of this._parts) { out.set(p, off); off += p.length; }
59
+ return out;
60
+ }
61
+ }
62
+
63
+ /** Extract raw JPEG bytes from a canvas element. Returns Uint8Array. */
64
+ async function canvasToJpeg(canvas, quality = 0.92) {
65
+ return new Promise((resolve, reject) => {
66
+ canvas.toBlob(blob => {
67
+ if (!blob) { reject(new Error('canvas.toBlob failed')); return; }
68
+ blob.arrayBuffer().then(buf => resolve(new Uint8Array(buf))).catch(reject);
69
+ }, 'image/jpeg', quality);
70
+ });
71
+ }
72
+
73
+ /** Render a slide to an HTMLCanvasElement at given pixel width. */
74
+ async function renderSlide(renderer, slideIndex, widthPx) {
75
+ const { cx, cy } = renderer.slideSize;
76
+ const h = Math.round(widthPx * cy / cx);
77
+ const canvas = typeof OffscreenCanvas !== 'undefined'
78
+ ? new OffscreenCanvas(widthPx, h)
79
+ : Object.assign(document.createElement('canvas'), { width: widthPx, height: h });
80
+ await renderer.renderSlide(slideIndex, canvas, widthPx);
81
+ return canvas;
82
+ }
83
+
84
+ // ── PDF structure constants ───────────────────────────────────────────────────
85
+
86
+ // All measurements in PDF points (1pt = 1/72 inch)
87
+ // Standard slide = 10 inches wide × 7.5 inches tall = 720pt × 540pt
88
+ // Widescreen (16:9) = 13.33 × 7.5 inches
89
+
90
+ function slidePageSize(cx, cy) {
91
+ // Convert EMU to points: 1 inch = 914400 EMU = 72 pt
92
+ const w = (cx / 914400) * 72;
93
+ const h = (cy / 914400) * 72;
94
+ return { w, h };
95
+ }
96
+
97
+ // ── PDF object writer ─────────────────────────────────────────────────────────
98
+
99
+ class PdfWriter {
100
+ constructor() {
101
+ this._buf = new PdfBuf();
102
+ this._xref = []; // byte offsets of each object
103
+ this._objNum = 0; // current object counter (0 = free entry)
104
+ }
105
+
106
+ _nextObj() { return ++this._objNum; }
107
+
108
+ /** Write a PDF object. Returns its object number. */
109
+ _writeObj(num, dictStr, streamData = null) {
110
+ const off = this._buf.size;
111
+ this._xref[num] = off;
112
+
113
+ this._buf.write(`${num} 0 obj\n`);
114
+ if (streamData) {
115
+ const len = streamData.length;
116
+ this._buf.write(`${dictStr.replace('__LEN__', len)}\n`);
117
+ this._buf.write('stream\r\n');
118
+ this._buf.write(streamData);
119
+ this._buf.write('\r\nendstream\n');
120
+ } else {
121
+ this._buf.write(`${dictStr}\n`);
122
+ }
123
+ this._buf.write('endobj\n\n');
124
+ return num;
125
+ }
126
+
127
+ /** Reserve the next N object numbers. Returns first number. */
128
+ _reserveObjs(n) {
129
+ const first = this._objNum + 1;
130
+ this._objNum += n;
131
+ return first;
132
+ }
133
+
134
+ // ── Build PDF ───────────────────────────────────────────────────────────────
135
+
136
+ async build(renderer, opts = {}) {
137
+ const {
138
+ widthPx = 1920,
139
+ quality = 0.92,
140
+ slideList = null,
141
+ onProgress = null,
142
+ } = opts;
143
+
144
+ const indices = slideList ?? Array.from({ length: renderer.slideCount }, (_, i) => i);
145
+ const n = indices.length;
146
+
147
+ const { cx, cy } = renderer.slideSize;
148
+ const { w: pgW, h: pgH } = slidePageSize(cx, cy);
149
+
150
+ // ── PDF header ────────────────────────────────────────────────────────
151
+ this._buf.write('%PDF-1.4\n');
152
+ // Comment with 4 high bytes signals binary content to transfer tools
153
+ this._buf.write('%\xFF\xFE\xFD\xFC\n\n');
154
+
155
+ // ── Pre-allocate object numbers ───────────────────────────────────────
156
+ // Object layout:
157
+ // 1 = Catalog
158
+ // 2 = Pages
159
+ // 3..3+n-1 = Page objects
160
+ // 3+n..3+2n-1 = Image XObjects (one per slide)
161
+ // 3+2n..3+3n-1 = Content streams (one per slide)
162
+
163
+ const catalogNum = this._nextObj(); // 1
164
+ const pagesNum = this._nextObj(); // 2
165
+ const pageNums = Array.from({ length: n }, () => this._nextObj());
166
+ const imageNums = Array.from({ length: n }, () => this._nextObj());
167
+ const contentNums = Array.from({ length: n }, () => this._nextObj());
168
+
169
+ // ── Catalog ───────────────────────────────────────────────────────────
170
+ this._writeObj(catalogNum, `<< /Type /Catalog /Pages ${pagesNum} 0 R >>`);
171
+
172
+ // ── Pages (written later after we know all page refs) ─────────────────
173
+ // We'll write pages dict here with placeholders and rewrite isn't easy
174
+ // in streaming mode — instead we write it now knowing all page nums
175
+ const kidsStr = pageNums.map(n => `${n} 0 R`).join(' ');
176
+ this._writeObj(pagesNum, `<< /Type /Pages /Kids [${kidsStr}] /Count ${n} >>`);
177
+
178
+ // ── Render each slide and write objects ───────────────────────────────
179
+ for (let i = 0; i < n; i++) {
180
+ const slideIdx = indices[i];
181
+ onProgress?.(i, n);
182
+
183
+ // Render
184
+ const canvas = await renderSlide(renderer, slideIdx, widthPx);
185
+ const jpegData = await canvasToJpeg(canvas, quality);
186
+ const imgW = canvas.width;
187
+ const imgH = canvas.height;
188
+
189
+ // Page object
190
+ this._writeObj(pageNums[i],
191
+ `<< /Type /Page /Parent ${pagesNum} 0 R ` +
192
+ `/MediaBox [0 0 ${pgW.toFixed(3)} ${pgH.toFixed(3)}] ` +
193
+ `/Resources << /XObject << /Im${i} ${imageNums[i]} 0 R >> >> ` +
194
+ `/Contents ${contentNums[i]} 0 R >>`
195
+ );
196
+
197
+ // Image XObject
198
+ this._writeObj(imageNums[i],
199
+ `<< /Type /XObject /Subtype /Image ` +
200
+ `/Width ${imgW} /Height ${imgH} ` +
201
+ `/ColorSpace /DeviceRGB /BitsPerComponent 8 ` +
202
+ `/Filter /DCTDecode /Length __LEN__ >>`,
203
+ jpegData
204
+ );
205
+
206
+ // Content stream: place image to fill page
207
+ const contentStr =
208
+ `q ${pgW.toFixed(3)} 0 0 ${pgH.toFixed(3)} 0 0 cm /Im${i} Do Q`;
209
+ this._writeObj(contentNums[i],
210
+ `<< /Length __LEN__ >>`,
211
+ enc.encode(contentStr)
212
+ );
213
+ }
214
+
215
+ onProgress?.(n, n);
216
+
217
+ // ── Cross-reference table ─────────────────────────────────────────────
218
+ const xrefOffset = this._buf.size;
219
+ const totalObjs = this._objNum + 1; // +1 for the free entry at 0
220
+
221
+ this._buf.write(`xref\n0 ${totalObjs}\n`);
222
+ // Entry 0: free list head
223
+ this._buf.write('0000000000 65535 f \r\n');
224
+ for (let i = 1; i < totalObjs; i++) {
225
+ const off = this._xref[i] ?? 0;
226
+ this._buf.write(String(off).padStart(10, '0') + ' 00000 n \r\n');
227
+ }
228
+
229
+ // ── Trailer ───────────────────────────────────────────────────────────
230
+ this._buf.write(`trailer\n<< /Size ${totalObjs} /Root ${catalogNum} 0 R >>\n`);
231
+ this._buf.write(`startxref\n${xrefOffset}\n%%EOF\n`);
232
+
233
+ return this._buf.concat();
234
+ }
235
+ }
236
+
237
+ // ── Public API ────────────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Export all (or selected) slides to a PDF binary.
241
+ *
242
+ * @param {object} renderer — loaded PptxRenderer instance
243
+ * @param {object} [opts]
244
+ * @param {number} [opts.width] render width in pixels (overrides dpi)
245
+ * @param {number} [opts.dpi=150] dots per inch (standard screen=96, print=300)
246
+ * @param {number} [opts.quality=0.92] JPEG quality 0..1
247
+ * @param {number[]} [opts.slides] slide indices to include (default: all)
248
+ * @param {function} [opts.onProgress] (done, total) => void
249
+ * @returns {Promise<Uint8Array>} raw PDF bytes
250
+ */
251
+ export async function exportToPdf(renderer, opts = {}) {
252
+ const {
253
+ width = null,
254
+ dpi = 150,
255
+ quality = 0.92,
256
+ slides = null,
257
+ onProgress = null,
258
+ } = opts;
259
+
260
+ const resolvedWidth = width ?? dpiToWidth(renderer, dpi);
261
+ const writer = new PdfWriter();
262
+ return writer.build(renderer, {
263
+ widthPx: resolvedWidth,
264
+ quality,
265
+ slideList: slides,
266
+ onProgress,
267
+ });
268
+ }
269
+
270
+ /**
271
+ * Export to PDF and trigger a browser download.
272
+ *
273
+ * @param {object} renderer
274
+ * @param {string} [filename='presentation.pdf']
275
+ * @param {object} [opts] — same as exportToPdf options (dpi, quality, slides…)
276
+ * @returns {Promise<void>}
277
+ */
278
+ export async function downloadAsPdf(renderer, filename = 'presentation.pdf', opts = {}) {
279
+ const bytes = await exportToPdf(renderer, opts);
280
+ const blob = new Blob([bytes], { type: 'application/pdf' });
281
+ const url = URL.createObjectURL(blob);
282
+ const a = document.createElement('a');
283
+ a.href = url;
284
+ a.download = filename;
285
+ a.click();
286
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
287
+ }
288
+
289
+ /**
290
+ * Export a single slide to PDF bytes.
291
+ * @param {number} slideIndex
292
+ * @param {object} renderer
293
+ * @param {object} [opts]
294
+ * @returns {Promise<Uint8Array>}
295
+ */
296
+ export async function exportSlideToPdf(slideIndex, renderer, opts = {}) {
297
+ return exportToPdf(renderer, { ...opts, slides: [slideIndex] });
298
+ }