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.
@@ -0,0 +1,416 @@
1
+ /**
2
+ * clipboard.js — Clipboard copy and progressive lazy rendering.
3
+ *
4
+ * CLIPBOARD API
5
+ * ─────────────
6
+ * copySlideToClipboard(slideIndex, renderer)
7
+ * Renders the slide and writes it as a PNG image to the system clipboard.
8
+ * Requires Clipboard API permission (shown to user by browser).
9
+ *
10
+ * copyAllSlidesToClipboard(renderer)
11
+ * Copies all slides as a ZIP of PNGs to clipboard text (data URL list).
12
+ * (Clipboard doesn't support multi-image, so this creates a JSON manifest.)
13
+ *
14
+ * PROGRESSIVE / LAZY RENDERING
15
+ * ─────────────────────────────
16
+ * createLazyDeck(renderer, container, opts)
17
+ * Builds a scrollable deck view where slides are rendered on-demand as
18
+ * they scroll into the viewport, using IntersectionObserver.
19
+ * Shows a low-res placeholder until the full render is ready.
20
+ *
21
+ * Usage:
22
+ * // Copy slide 3 to clipboard
23
+ * await copySlideToClipboard(2, renderer);
24
+ *
25
+ * // Progressive deck view (great for large PPTX files)
26
+ * const deck = createLazyDeck(renderer, document.getElementById('deck'));
27
+ * // Each slide renders as you scroll to it
28
+ * deck.destroy(); // clean up
29
+ *
30
+ * // Or via renderer instance:
31
+ * await renderer.copySlide(2);
32
+ * const deck = renderer.createDeck(container);
33
+ */
34
+
35
+ // ── DPI helper ──────────────────────────────────────────────────────────────────
36
+
37
+ function dpiToWidth(renderer, dpi) {
38
+ const inches = renderer.slideSize.cx / 914400;
39
+ return Math.round(inches * dpi);
40
+ }
41
+
42
+ // ── Clipboard ─────────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Copy a single slide to the clipboard as a PNG image.
46
+ *
47
+ * Requires Clipboard API and clipboard-write permission.
48
+ * Falls back to opening the image in a new tab if clipboard is unavailable.
49
+ *
50
+ * @param {number} slideIndex
51
+ * @param {object} renderer
52
+ * @param {object|number} [opts] options or legacy pixel width
53
+ * @param {number} [opts.width] pixel width (overrides dpi)
54
+ * @param {number} [opts.dpi=150] dots per inch
55
+ * @returns {Promise<{success: boolean, method: string}>}
56
+ */
57
+ export async function copySlideToClipboard(slideIndex, renderer, opts = {}) {
58
+ // Legacy: allow passing a raw number as width
59
+ if (typeof opts === 'number') opts = { width: opts };
60
+ const { width = null, dpi = 150 } = opts;
61
+ const resolvedWidth = width ?? dpiToWidth(renderer, dpi);
62
+ // Render to an offscreen canvas
63
+ const canvas = await _renderToCanvas(slideIndex, renderer, resolvedWidth);
64
+ const dataUrl = canvas.toDataURL('image/png');
65
+
66
+ // Method 1: Clipboard API (modern browsers, requires permission)
67
+ if (navigator.clipboard && window.ClipboardItem) {
68
+ try {
69
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
70
+ await navigator.clipboard.write([
71
+ new ClipboardItem({ 'image/png': blob }),
72
+ ]);
73
+ return { success: true, method: 'clipboard-api', dataUrl };
74
+ } catch (err) {
75
+ if (err.name !== 'NotAllowedError') throw err;
76
+ // Permission denied — fall through to fallback
77
+ }
78
+ }
79
+
80
+ // Method 2: execCommand fallback (deprecated but still works in some contexts)
81
+ if (document.execCommand) {
82
+ try {
83
+ const img = new Image();
84
+ img.src = dataUrl;
85
+ await new Promise(r => { img.onload = r; });
86
+ // Create a contenteditable div, paste the image, copy it
87
+ const div = document.createElement('div');
88
+ div.contentEditable = 'true';
89
+ div.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0;';
90
+ div.appendChild(img);
91
+ document.body.appendChild(div);
92
+ const range = document.createRange();
93
+ range.selectNodeContents(div);
94
+ const sel = window.getSelection();
95
+ sel.removeAllRanges();
96
+ sel.addRange(range);
97
+ const ok = document.execCommand('copy');
98
+ document.body.removeChild(div);
99
+ sel.removeAllRanges();
100
+ if (ok) return { success: true, method: 'execCommand', dataUrl };
101
+ } catch (_) {}
102
+ }
103
+
104
+ // Method 3: Open in new tab (always works)
105
+ const win = window.open();
106
+ if (win) {
107
+ win.document.write(`<img src="${dataUrl}" style="max-width:100%">`);
108
+ win.document.title = `Slide ${slideIndex + 1}`;
109
+ }
110
+ return { success: false, method: 'opened-tab', dataUrl };
111
+ }
112
+
113
+ /**
114
+ * Download a slide as a PNG file.
115
+ *
116
+ * @param {number} slideIndex
117
+ * @param {object} renderer
118
+ * @param {object|number} [opts] options or legacy pixel width
119
+ * @param {number} [opts.width] pixel width (overrides dpi)
120
+ * @param {number} [opts.dpi=300] dots per inch (default 300 for download)
121
+ * @param {string} [opts.filename]
122
+ */
123
+ export async function downloadSlide(slideIndex, renderer, opts = {}, filename) {
124
+ if (typeof opts === 'number') opts = { width: opts };
125
+ const { width = null, dpi = 300, filename: fn } = opts;
126
+ const resolvedWidth = width ?? dpiToWidth(renderer, dpi);
127
+ const canvas = await _renderToCanvas(slideIndex, renderer, resolvedWidth);
128
+ const dataUrl = canvas.toDataURL('image/png');
129
+ const name = filename || fn || `slide-${slideIndex + 1}.png`;
130
+ const a = document.createElement('a');
131
+ a.href = dataUrl;
132
+ a.download = name;
133
+ a.click();
134
+ }
135
+
136
+ /**
137
+ * Download all slides as individual PNG files, or as a ZIP if JSZip is available.
138
+ *
139
+ * @param {object} renderer
140
+ * @param {object|number} [opts] options or legacy pixel width
141
+ * @param {number} [opts.width] pixel width (overrides dpi)
142
+ * @param {number} [opts.dpi=300] dots per inch
143
+ * @param {function} [opts.onProgress] (completed, total) => void
144
+ */
145
+ export async function downloadAllSlides(renderer, opts = {}, onProgress) {
146
+ if (typeof opts === 'number') opts = { width: opts };
147
+ const { onProgress: progFn, ...slideOpts } = opts;
148
+ const progress = onProgress || progFn;
149
+ const n = renderer.slideCount;
150
+ for (let i = 0; i < n; i++) {
151
+ await downloadSlide(i, renderer, slideOpts);
152
+ progress?.(i + 1, n);
153
+ // Small delay to allow browser to process download
154
+ await new Promise(r => setTimeout(r, 100));
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Render a slide to an HTMLCanvasElement.
160
+ */
161
+ async function _renderToCanvas(slideIndex, renderer, width) {
162
+ const { cx, cy } = renderer.slideSize;
163
+ const aspect = cx / cy;
164
+ const h = Math.round(width / aspect);
165
+
166
+ const canvas = document.createElement('canvas');
167
+ canvas.width = width;
168
+ canvas.height = h;
169
+ await renderer.renderSlide(slideIndex, canvas, width);
170
+ return canvas;
171
+ }
172
+
173
+ // ── Progressive lazy rendering ────────────────────────────────────────────────
174
+
175
+ /**
176
+ * @typedef {object} LazyDeckOpts
177
+ * @property {number} [thumbWidth=320] px width of initial low-res render
178
+ * @property {number} [fullWidth=1280] px width of full-res render
179
+ * @property {string} [gap='24px'] CSS gap between slides
180
+ * @property {string} [background='#1a1a1a'] container background
181
+ * @property {string} [slideBackground='#fff'] per-slide background
182
+ * @property {boolean} [shadow=true] drop shadow on slides
183
+ * @property {boolean} [clickToShow=false] clicking a slide opens fullscreen
184
+ * @property {string} [maxWidth='900px'] max width of slide display
185
+ * @property {number} [rootMargin=200] px pre-load margin (IntersectionObserver)
186
+ * @property {function} [onSlideVisible] (index) => void
187
+ * @property {function} [onSlideRendered] (index) => void
188
+ */
189
+
190
+ /**
191
+ * Create a progressive lazy-rendered deck view.
192
+ *
193
+ * Slides are shown as grey placeholders and rendered on-demand as they
194
+ * scroll into the viewport (plus a small lookahead margin).
195
+ *
196
+ * Returns a controller object with:
197
+ * .destroy() — remove everything and clean up
198
+ * .scrollTo(index) — smooth scroll to a slide
199
+ * .renderAll() — force render all slides immediately
200
+ * .getCanvas(index) — get the canvas element for a slide
201
+ *
202
+ * @param {object} renderer
203
+ * @param {HTMLElement} container
204
+ * @param {LazyDeckOpts} [opts]
205
+ * @returns {LazyDeckController}
206
+ */
207
+ export function createLazyDeck(renderer, container, opts = {}) {
208
+ const {
209
+ thumbWidth = 320,
210
+ fullWidth = 1280,
211
+ gap = '24px',
212
+ background = '#1a1a1a',
213
+ slideBackground = '#fff',
214
+ shadow = true,
215
+ clickToShow = false,
216
+ maxWidth = '900px',
217
+ rootMargin = 200,
218
+ onSlideVisible,
219
+ onSlideRendered,
220
+ } = opts;
221
+
222
+ const { cx, cy } = renderer.slideSize;
223
+ const aspect = cx / cy;
224
+ const n = renderer.slideCount;
225
+
226
+ // ── Build container ────────────────────────────────────────────────────────
227
+
228
+ container.style.cssText = `
229
+ background:${background};
230
+ padding:${gap};
231
+ display:flex;
232
+ flex-direction:column;
233
+ align-items:center;
234
+ gap:${gap};
235
+ overflow-y:auto;
236
+ position:relative;
237
+ `;
238
+
239
+ // ── Slide elements ────────────────────────────────────────────────────────
240
+
241
+ const slideWrappers = [];
242
+ const canvases = [];
243
+ const renderStates = []; // 'pending' | 'thumb' | 'full'
244
+
245
+ for (let i = 0; i < n; i++) {
246
+ const wrap = document.createElement('div');
247
+ wrap.style.cssText = `
248
+ width:100%;
249
+ max-width:${maxWidth};
250
+ position:relative;
251
+ background:${slideBackground};
252
+ border-radius:4px;
253
+ ${shadow ? 'box-shadow:0 4px 24px rgba(0,0,0,0.5);' : ''}
254
+ overflow:hidden;
255
+ aspect-ratio:${cx}/${cy};
256
+ flex-shrink:0;
257
+ `;
258
+ wrap.setAttribute('data-slide', i);
259
+
260
+ // Slide number badge
261
+ const badge = document.createElement('div');
262
+ badge.style.cssText = `
263
+ position:absolute;bottom:8px;right:10px;
264
+ background:rgba(0,0,0,0.45);color:#fff;
265
+ font:11px/1.4 system-ui,sans-serif;
266
+ padding:2px 7px;border-radius:10px;
267
+ pointer-events:none;z-index:1;
268
+ `;
269
+ badge.textContent = i + 1;
270
+ wrap.appendChild(badge);
271
+
272
+ // Canvas (sized to the wrapper)
273
+ const canvas = document.createElement('canvas');
274
+ canvas.style.cssText = 'display:block;width:100%;height:100%;';
275
+ canvas.width = thumbWidth;
276
+ canvas.height = Math.round(thumbWidth / aspect);
277
+ wrap.appendChild(canvas);
278
+
279
+ // Placeholder while not yet rendered
280
+ const placeholder = _buildPlaceholder(i, cx, cy, slideBackground);
281
+ wrap.appendChild(placeholder);
282
+
283
+ // Click handler
284
+ if (clickToShow) {
285
+ wrap.style.cursor = 'pointer';
286
+ wrap.addEventListener('click', () => {
287
+ const show = new (require('./slideshow.js').SlideShow)(renderer, document.body);
288
+ show.start(i);
289
+ });
290
+ }
291
+
292
+ container.appendChild(wrap);
293
+ slideWrappers.push(wrap);
294
+ canvases.push(canvas);
295
+ renderStates.push('pending');
296
+ }
297
+
298
+ // ── IntersectionObserver ──────────────────────────────────────────────────
299
+
300
+ const renderQueue = [];
301
+ let renderBusy = false;
302
+
303
+ async function processQueue() {
304
+ if (renderBusy || renderQueue.length === 0) return;
305
+ renderBusy = true;
306
+ while (renderQueue.length > 0) {
307
+ const idx = renderQueue.shift();
308
+ if (renderStates[idx] === 'full') continue;
309
+
310
+ const canvas = canvases[idx];
311
+ const wrap = slideWrappers[idx];
312
+ const placeholder = wrap.querySelector('[data-placeholder]');
313
+
314
+ try {
315
+ // Render at full resolution
316
+ canvas.width = fullWidth;
317
+ canvas.height = Math.round(fullWidth / aspect);
318
+ await renderer.renderSlide(idx, canvas, fullWidth);
319
+ renderStates[idx] = 'full';
320
+
321
+ // Fade in
322
+ canvas.style.transition = 'opacity 0.3s';
323
+ canvas.style.opacity = '1';
324
+ if (placeholder) placeholder.style.display = 'none';
325
+
326
+ onSlideRendered?.(idx);
327
+ } catch (err) {
328
+ console.warn(`LazyDeck: failed to render slide ${idx}`, err);
329
+ }
330
+ }
331
+ renderBusy = false;
332
+ }
333
+
334
+ const observer = new IntersectionObserver((entries) => {
335
+ for (const entry of entries) {
336
+ if (!entry.isIntersecting) continue;
337
+ const idx = parseInt(entry.target.getAttribute('data-slide'), 10);
338
+ if (isNaN(idx) || renderStates[idx] === 'full') continue;
339
+
340
+ onSlideVisible?.(idx);
341
+
342
+ // Enqueue: current slide first, then neighbours
343
+ for (const neighbor of [idx, idx - 1, idx + 1, idx + 2].filter(j => j >= 0 && j < n)) {
344
+ if (renderStates[neighbor] !== 'full' && !renderQueue.includes(neighbor)) {
345
+ renderQueue.push(neighbor);
346
+ }
347
+ }
348
+ processQueue();
349
+ }
350
+ }, {
351
+ root: container,
352
+ rootMargin: `${rootMargin}px`,
353
+ threshold: 0,
354
+ });
355
+
356
+ slideWrappers.forEach(w => observer.observe(w));
357
+
358
+ // ── Controller ─────────────────────────────────────────────────────────────
359
+
360
+ return {
361
+ destroy() {
362
+ observer.disconnect();
363
+ container.innerHTML = '';
364
+ },
365
+
366
+ scrollTo(index, behavior = 'smooth') {
367
+ const wrap = slideWrappers[index];
368
+ if (wrap) wrap.scrollIntoView({ behavior, block: 'start' });
369
+ },
370
+
371
+ async renderAll(onProgress) {
372
+ for (let i = 0; i < n; i++) {
373
+ if (renderStates[i] !== 'full') {
374
+ renderQueue.push(i);
375
+ }
376
+ }
377
+ await processQueue();
378
+ },
379
+
380
+ getCanvas(index) {
381
+ return canvases[index] || null;
382
+ },
383
+
384
+ get slideCount() { return n; },
385
+ };
386
+ }
387
+
388
+ function _buildPlaceholder(index, cx, cy, bg) {
389
+ const el = document.createElement('div');
390
+ el.setAttribute('data-placeholder', '1');
391
+ el.style.cssText = `
392
+ position:absolute;inset:0;
393
+ display:flex;align-items:center;justify-content:center;
394
+ background:${bg};
395
+ flex-direction:column;gap:12px;
396
+ `;
397
+
398
+ // Animated skeleton lines
399
+ const linesHtml = `
400
+ <div style="width:55%;height:18px;background:#e0e0e0;border-radius:3px;animation:pulse 1.4s ease-in-out infinite;"></div>
401
+ <div style="width:72%;height:10px;background:#ebebeb;border-radius:3px;animation:pulse 1.4s ease-in-out infinite 0.1s;"></div>
402
+ <div style="width:62%;height:10px;background:#ebebeb;border-radius:3px;animation:pulse 1.4s ease-in-out infinite 0.2s;"></div>
403
+ <div style="width:45%;height:10px;background:#ebebeb;border-radius:3px;animation:pulse 1.4s ease-in-out infinite 0.3s;"></div>
404
+ `;
405
+
406
+ // Inject keyframes once
407
+ if (!document.getElementById('_pptx_lazy_css')) {
408
+ const style = document.createElement('style');
409
+ style.id = '_pptx_lazy_css';
410
+ style.textContent = `@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}`;
411
+ document.head.appendChild(style);
412
+ }
413
+
414
+ el.innerHTML = linesHtml;
415
+ return el;
416
+ }