@file-viewer/ppt 0.2.0 → 0.3.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/index.mjs CHANGED
@@ -1,9 +1,13 @@
1
- const MANIFEST = Object.freeze({"kind":"flyfish-ppt-public-native-wasm-v1","product":"Flyfish PPT Viewer","format":"PowerPoint 97-2003 (.ppt)","packageName":"@file-viewer/ppt","packageVersion":"0.2.0","engine":"flyfish-classic-ppt-native-engine","engineBuild":"ef5987a0a85b6103","id":"FV-PPT-PUBLIC-WATERMARKED-V2","feature":"ppt","edition":"public-watermarked","holder":"Individual Personal Non-Commercial Use","distribution":"proprietary-public-binary","commercialUse":false,"publicAnyOrigin":true,"watermarkRequired":true,"watermarkText":"Flyfish Viewer","usePolicy":"public-individual-noncommercial-watermarked","licensePolicy":"flyfish-public-watermarked-binary-license-v1","sourceCodeRights":false,"sourceDeliveryRights":false,"apacheLicenseApplies":false,"redistributionAllowed":false,"runtimeLicenseRequired":false,"renderer":"native-wasm-rgba-canvas","integrity":"native-wasm-sha256-v1","wasmFile":"ppt-native.wasm","watermarkEnforcement":"native-final-frame","wasmSha256":"ef5987a0a85b61034ea651a02a28d4582ad19a09610e9a5637dcf01136815a2e"});
1
+ const MANIFEST = Object.freeze({"kind":"flyfish-ppt-public-native-wasm-v2","product":"Flyfish PPT Viewer","format":"PowerPoint 97-2003 (.ppt)","packageName":"@file-viewer/ppt","packageVersion":"0.3.0","engine":"flyfish-classic-ppt-native-engine","engineBuild":"28e655e6a05c9b73","id":"FV-PPT-PUBLIC-WATERMARKED-V2","feature":"ppt","edition":"public-watermarked","holder":"Individual Personal Non-Commercial Use","distribution":"proprietary-public-binary","commercialUse":false,"publicAnyOrigin":true,"watermarkRequired":true,"watermarkText":"Flyfish Viewer","usePolicy":"public-individual-noncommercial-watermarked","licensePolicy":"flyfish-public-watermarked-binary-license-v1","sourceCodeRights":false,"sourceDeliveryRights":false,"apacheLicenseApplies":false,"redistributionAllowed":false,"runtimeLicenseRequired":false,"renderer":"native-wasm-worker-offscreen-canvas","integrity":"native-wasm-font-sha256-v2","wasmFile":"ppt-native.wasm","wasmBytes":1557607,"workerFile":"worker.mjs","fontPack":{"kind":"flyfish-cjk-font-pack-v1","file":"ppt-font-cjk.otf","bytes":16437364,"sha256":"2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b"},"workerDefault":true,"virtualScrollDefault":true,"frameCache":"indexeddb-final-watermarked-png-lru","watermarkEnforcement":"native-final-frame","wasmSha256":"c14dd1d5b971817b2eb133739fe928920b9ccc0a5afb25f7044d879bed7c6d23"});
2
2
  const DEFAULT_WASM_URL = new URL('./ppt-native.wasm', import.meta.url);
3
+ const DEFAULT_FONT_URL = new URL(`./${MANIFEST.fontPack.file}`, import.meta.url);
4
+ const DEFAULT_WORKER_URL = new URL(`./${MANIFEST.workerFile}`, import.meta.url);
3
5
  const WASM_EXPORTS = Object.freeze([
4
6
  'memory',
5
7
  'ppt_input_alloc',
6
8
  'ppt_input_free',
9
+ 'ppt_font_pack_install',
10
+ 'ppt_font_pack_is_installed',
7
11
  'ppt_document_open',
8
12
  'ppt_document_close',
9
13
  'ppt_slide_count',
@@ -32,68 +36,355 @@ const LICENSE = Object.freeze({
32
36
  });
33
37
 
34
38
  const WATERMARK = Object.freeze({ required: true, text: 'Flyfish Viewer' });
35
- let enginePromise;
39
+ const directEnginePromises = new Map();
36
40
 
37
41
  export function getPptPackageManifest() {
38
- return { ...MANIFEST };
42
+ return { ...MANIFEST, fontPack: { ...MANIFEST.fontPack } };
39
43
  }
40
44
 
41
45
  export async function loadPptViewer(options = {}) {
42
- const engine = await loadEngine(options.wasmUrl);
43
- return createRuntime(engine);
46
+ const mode = selectRuntimeMode(options);
47
+ if (mode === 'worker') {
48
+ try {
49
+ return await createWorkerRuntime(options);
50
+ } catch (error) {
51
+ if (options.worker === true) throw error;
52
+ }
53
+ }
54
+ const engine = await loadDirectEngine(options.wasmUrl, options.fontUrl);
55
+ return createDirectRuntime(engine);
44
56
  }
45
57
 
46
58
  export const createPptViewer = loadPptViewer;
47
59
 
48
- async function loadEngine(wasmSource) {
49
- if (wasmSource !== undefined) return instantiateEngine(wasmSource);
50
- if (!enginePromise) {
51
- enginePromise = instantiateEngine(DEFAULT_WASM_URL).catch((error) => {
52
- enginePromise = undefined;
53
- throw error;
60
+ function selectRuntimeMode(options) {
61
+ const requested = options.worker ?? 'auto';
62
+ if (requested !== true && requested !== false && requested !== 'auto') {
63
+ throw new TypeError('worker must be true, false, or "auto".');
64
+ }
65
+ const workerCapable = typeof Worker === 'function'
66
+ && typeof OffscreenCanvas === 'function'
67
+ && (typeof HTMLCanvasElement === 'undefined'
68
+ || typeof HTMLCanvasElement.prototype?.transferControlToOffscreen === 'function');
69
+ const sourcesAreUrls = isUrlSource(options.wasmUrl) && isUrlSource(options.fontUrl) && isUrlSource(options.workerUrl);
70
+ if (requested === true && !workerCapable) {
71
+ throw packageError('worker-unavailable', 'Worker and OffscreenCanvas support are required for worker rendering.');
72
+ }
73
+ if (requested === true && !sourcesAreUrls) {
74
+ throw packageError('worker-source-invalid', 'Worker rendering requires URL-based WASM, font, and worker assets.');
75
+ }
76
+ return requested !== false && workerCapable && sourcesAreUrls ? 'worker' : 'direct';
77
+ }
78
+
79
+ function isUrlSource(source) {
80
+ return source === undefined || source instanceof URL || typeof source === 'string';
81
+ }
82
+
83
+ async function createWorkerRuntime(options) {
84
+ const bridge = new WorkerBridge(resolveUrl(options.workerUrl, DEFAULT_WORKER_URL));
85
+ const transferInputOwnership = options.transferInputOwnership === true;
86
+ try {
87
+ await bridge.request('init', {
88
+ manifest: getPptPackageManifest(),
89
+ wasmUrl: resolveUrl(options.wasmUrl, DEFAULT_WASM_URL).href,
90
+ fontUrl: resolveUrl(options.fontUrl, DEFAULT_FONT_URL).href,
91
+ cacheOptions: normalizeCacheOptions(options.cache)
54
92
  });
93
+ } catch (error) {
94
+ bridge.terminate();
95
+ throw error;
55
96
  }
56
- return enginePromise;
97
+
98
+ let activeDocument = null;
99
+ const runtime = {
100
+ mode: 'worker',
101
+ license: LICENSE,
102
+ watermark: WATERMARK,
103
+ async open(input) {
104
+ if (activeDocument && !activeDocument.closed) {
105
+ throw packageError('document-active', 'Close the current worker document before opening another presentation.');
106
+ }
107
+ const bytes = normalizeInput(input);
108
+ if (bytes.byteLength < 8 || !hasCfbSignature(bytes)) {
109
+ throw packageError('ppt-signature-invalid', 'Input is not a PowerPoint 97-2003 (.ppt) compound document.');
110
+ }
111
+ const transferred = transferableInputBuffer(input, bytes, transferInputOwnership);
112
+ const metadata = await bridge.request('open', { input: transferred }, [transferred]);
113
+ activeDocument = createWorkerDocument(bridge, metadata);
114
+ return activeDocument;
115
+ },
116
+ async mount(target, input, mountOptions = {}) {
117
+ return mountDocument(runtime, target, input, mountOptions);
118
+ },
119
+ async cacheStats() {
120
+ return bridge.request('cacheStats');
121
+ },
122
+ async close() {
123
+ try {
124
+ if (activeDocument && !activeDocument.closed) await activeDocument.close();
125
+ } finally {
126
+ bridge.terminate();
127
+ }
128
+ }
129
+ };
130
+ return Object.freeze(runtime);
57
131
  }
58
132
 
59
- async function instantiateEngine(source) {
60
- const bytes = await loadWasmBytes(source);
61
- await verifyWasmIntegrity(bytes);
62
- const module = await WebAssembly.compile(bytes);
133
+ function createWorkerDocument(bridge, metadata) {
134
+ const slideCount = positiveInteger(metadata.slideCount, 'slide count');
135
+ const width = positiveNumber(metadata.width, 'slide width');
136
+ const height = positiveNumber(metadata.height, 'slide height');
137
+ const documentId = String(metadata.documentId || '');
138
+ const attached = new Map();
139
+ let closed = false;
140
+
141
+ const document = {
142
+ mode: 'worker',
143
+ documentId,
144
+ get slideCount() { return slideCount; },
145
+ get width() { return width; },
146
+ get height() { return height; },
147
+ get closed() { return closed; },
148
+ async renderSlide(slideIndex, canvas, options = {}) {
149
+ ensureOpen(closed);
150
+ validateSlideIndex(slideIndex, slideCount);
151
+ const renderOptions = normalizeRenderOptions(options);
152
+ let state = attached.get(slideIndex);
153
+ if (!state) {
154
+ const transferable = transferCanvas(canvas, width, height, renderOptions.scale);
155
+ await bridge.request('attach', {
156
+ slideIndex,
157
+ canvas: transferable,
158
+ scale: renderOptions.scale,
159
+ pixelRatio: renderOptions.pixelRatio
160
+ }, [transferable]);
161
+ state = { canvas, scale: renderOptions.scale, pixelRatio: renderOptions.pixelRatio };
162
+ attached.set(slideIndex, state);
163
+ } else if (state.canvas !== canvas) {
164
+ throw packageError('canvas-already-attached', `Slide ${slideIndex + 1} is already attached to another Canvas.`);
165
+ }
166
+ const result = await bridge.request('activate', {
167
+ slideIndex,
168
+ scale: renderOptions.scale,
169
+ pixelRatio: renderOptions.pixelRatio
170
+ });
171
+ if (result?.cancelled) {
172
+ return Object.freeze({
173
+ cancelled: true,
174
+ slideIndex,
175
+ width: Math.round(width * renderOptions.nativeScale),
176
+ height: Math.round(height * renderOptions.nativeScale),
177
+ logicalWidth: width,
178
+ logicalHeight: height,
179
+ scale: renderOptions.scale,
180
+ pixelRatio: renderOptions.pixelRatio
181
+ });
182
+ }
183
+ return Object.freeze({ ...result, logicalWidth: width, logicalHeight: height });
184
+ },
185
+ async releaseSlide(slideIndex) {
186
+ ensureOpen(closed);
187
+ validateSlideIndex(slideIndex, slideCount);
188
+ if (!attached.has(slideIndex)) return false;
189
+ await bridge.request('deactivate', { slideIndex });
190
+ return true;
191
+ },
192
+ async cacheStats() {
193
+ ensureOpen(closed);
194
+ return bridge.request('cacheStats');
195
+ },
196
+ async close() {
197
+ if (closed) return;
198
+ closed = true;
199
+ try {
200
+ await bridge.request('close');
201
+ } finally {
202
+ attached.clear();
203
+ }
204
+ }
205
+ };
206
+ return Object.freeze(document);
207
+ }
208
+
209
+ function transferCanvas(canvas, logicalWidth, logicalHeight, scale) {
210
+ if (typeof HTMLCanvasElement !== 'undefined' && canvas instanceof HTMLCanvasElement) {
211
+ if (typeof canvas.transferControlToOffscreen !== 'function') {
212
+ throw packageError('offscreen-canvas-unavailable', 'This Canvas cannot be transferred to the rendering worker.');
213
+ }
214
+ prepareCanvasElement(canvas, logicalWidth, logicalHeight, scale);
215
+ canvas.width = 1;
216
+ canvas.height = 1;
217
+ return canvas.transferControlToOffscreen();
218
+ }
219
+ if (typeof OffscreenCanvas !== 'undefined' && canvas instanceof OffscreenCanvas) return canvas;
220
+ throw new TypeError('renderSlide requires an HTMLCanvasElement or OffscreenCanvas.');
221
+ }
222
+
223
+ class WorkerBridge {
224
+ constructor(url) {
225
+ this.worker = new Worker(url, { type: 'module', name: 'flyfish-ppt-renderer' });
226
+ this.nextId = 1;
227
+ this.pending = new Map();
228
+ this.terminated = false;
229
+ this.worker.addEventListener('message', (event) => this.receive(event.data));
230
+ this.worker.addEventListener('error', (event) => {
231
+ this.failAll(packageError('worker-failed', event.message || 'The PPT rendering worker failed.'));
232
+ });
233
+ this.worker.addEventListener('messageerror', () => {
234
+ this.failAll(packageError('worker-message-invalid', 'The PPT rendering worker returned an invalid message.'));
235
+ });
236
+ }
237
+
238
+ request(type, payload = {}, transfer = []) {
239
+ if (this.terminated) return Promise.reject(packageError('worker-closed', 'The PPT rendering worker is closed.'));
240
+ const id = this.nextId++;
241
+ return new Promise((resolve, reject) => {
242
+ this.pending.set(id, { resolve, reject });
243
+ try {
244
+ this.worker.postMessage({ id, type, ...payload }, transfer);
245
+ } catch (error) {
246
+ this.pending.delete(id);
247
+ reject(error);
248
+ }
249
+ });
250
+ }
251
+
252
+ receive(message) {
253
+ if (!message || !Number.isInteger(message.id)) return;
254
+ const pending = this.pending.get(message.id);
255
+ if (!pending) return;
256
+ this.pending.delete(message.id);
257
+ if (message.ok) pending.resolve(message.result);
258
+ else pending.reject(workerError(message.error));
259
+ }
260
+
261
+ failAll(error) {
262
+ for (const pending of this.pending.values()) pending.reject(error);
263
+ this.pending.clear();
264
+ }
265
+
266
+ terminate() {
267
+ if (this.terminated) return;
268
+ this.terminated = true;
269
+ this.worker.terminate();
270
+ this.failAll(packageError('worker-closed', 'The PPT rendering worker was closed.'));
271
+ }
272
+ }
273
+
274
+ function workerError(value) {
275
+ const error = packageError(value?.code || 'worker-operation-failed', value?.message || 'The PPT rendering worker rejected the operation.');
276
+ if (value?.name) error.name = value.name;
277
+ return error;
278
+ }
279
+
280
+ async function loadDirectEngine(wasmSource, fontSource) {
281
+ const key = directEngineKey(wasmSource, fontSource);
282
+ if (!key) return instantiateEngine(wasmSource ?? DEFAULT_WASM_URL, fontSource ?? DEFAULT_FONT_URL);
283
+ if (!directEnginePromises.has(key)) {
284
+ directEnginePromises.set(key, instantiateEngine(wasmSource ?? DEFAULT_WASM_URL, fontSource ?? DEFAULT_FONT_URL).catch((error) => {
285
+ directEnginePromises.delete(key);
286
+ throw error;
287
+ }));
288
+ }
289
+ return directEnginePromises.get(key);
290
+ }
291
+
292
+ function directEngineKey(wasmSource, fontSource) {
293
+ if (!isUrlSource(wasmSource) || !isUrlSource(fontSource)) return '';
294
+ return `${resolveUrl(wasmSource, DEFAULT_WASM_URL).href}\n${resolveUrl(fontSource, DEFAULT_FONT_URL).href}`;
295
+ }
296
+
297
+ async function instantiateEngine(wasmSource, fontSource) {
298
+ const fontPromise = loadAssetBytes(fontSource, MANIFEST.fontPack.bytes, 'CJK font pack');
299
+ void fontPromise.catch(() => {});
300
+ const wasm = await loadAssetBytes(wasmSource, MANIFEST.wasmBytes, 'PPT rendering engine');
301
+ await verifySha256(wasm, MANIFEST.wasmSha256, 'wasm-integrity-invalid', 'The PPT rendering engine failed its SHA-256 integrity check.');
302
+ const module = await WebAssembly.compile(wasm);
63
303
  const imports = WebAssembly.Module.imports(module);
64
304
  if (imports.length !== 0) {
65
305
  throw packageError('wasm-imports-rejected', 'The PPT rendering engine must not import browser or host functions.');
66
306
  }
67
307
  const instance = await WebAssembly.instantiate(module, {});
68
308
  validateEngine(instance.exports);
309
+ const font = await fontPromise;
310
+ await verifySha256(font, MANIFEST.fontPack.sha256, 'font-integrity-invalid', 'The CJK font pack failed its SHA-256 integrity check.');
311
+ installFontPack(instance.exports, font);
69
312
  return Object.freeze({ module, exports: instance.exports });
70
313
  }
71
314
 
72
- async function verifyWasmIntegrity(bytes) {
73
- const subtle = globalThis.crypto?.subtle;
74
- if (!subtle || typeof subtle.digest !== 'function') {
75
- throw packageError('wasm-integrity-unavailable', 'Web Crypto SHA-256 support is required to verify the PPT rendering engine.');
315
+ function installFontPack(api, font) {
316
+ if (Number(api.ppt_font_pack_is_installed()) === 1) return;
317
+ const pointer = Number(api.ppt_input_alloc(font.byteLength));
318
+ if (!pointer) throw engineError(api, 'Unable to allocate native font-pack memory.');
319
+ let installed = false;
320
+ try {
321
+ new Uint8Array(api.memory.buffer, pointer, font.byteLength).set(font);
322
+ installed = Number(api.ppt_font_pack_install(pointer, font.byteLength)) === 1;
323
+ if (!installed) throw engineError(api, 'The native engine rejected the CJK font pack.');
324
+ } finally {
325
+ if (!installed) api.ppt_input_free(pointer, font.byteLength);
76
326
  }
77
- const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
78
- const actual = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
79
- if (actual !== MANIFEST.wasmSha256) {
80
- throw packageError('wasm-integrity-invalid', 'The PPT rendering engine failed its SHA-256 integrity check.');
327
+ if (Number(api.ppt_font_pack_is_installed()) !== 1) {
328
+ throw packageError('font-install-invalid', 'The native engine did not retain the verified CJK font pack.');
81
329
  }
82
330
  }
83
331
 
84
- async function loadWasmBytes(source) {
85
- if (source instanceof WebAssembly.Module) {
86
- throw packageError('wasm-module-unsupported', 'Pass a URL, Response, ArrayBuffer, or Uint8Array so the engine can be verified before use.');
332
+ async function loadAssetBytes(source, expectedBytes, label) {
333
+ if (!Number.isInteger(expectedBytes) || expectedBytes < 1) {
334
+ throw packageError('asset-manifest-invalid', `The ${label} manifest length is invalid.`);
87
335
  }
88
- if (source instanceof Uint8Array) return source.slice();
89
- if (source instanceof ArrayBuffer) return new Uint8Array(source.slice(0));
336
+ if (source instanceof Uint8Array) return exactAsset(Uint8Array.from(source), expectedBytes, label);
337
+ if (source instanceof ArrayBuffer) return exactAsset(new Uint8Array(source.slice(0)), expectedBytes, label);
90
338
  if (typeof Response !== 'undefined' && source instanceof Response) {
91
- if (!source.ok) throw packageError('wasm-request-failed', `The PPT rendering engine request failed with HTTP ${source.status}.`);
92
- return new Uint8Array(await source.arrayBuffer());
339
+ if (!source.ok) throw packageError('asset-request-failed', `${label} request failed with HTTP ${source.status}.`);
340
+ return exactAsset(await readResponseBytes(source, expectedBytes), expectedBytes, label);
93
341
  }
94
342
  const response = await fetch(source instanceof URL || typeof source === 'string' ? source : DEFAULT_WASM_URL);
95
- if (!response.ok) throw packageError('wasm-request-failed', `The PPT rendering engine request failed with HTTP ${response.status}.`);
96
- return new Uint8Array(await response.arrayBuffer());
343
+ if (!response.ok) throw packageError('asset-request-failed', `${label} request failed with HTTP ${response.status}.`);
344
+ return exactAsset(await readResponseBytes(response, expectedBytes), expectedBytes, label);
345
+ }
346
+
347
+ async function readResponseBytes(response, maximumBytes) {
348
+ if (!response.body || typeof response.body.getReader !== 'function') {
349
+ const bytes = new Uint8Array(await response.arrayBuffer());
350
+ if (bytes.byteLength > maximumBytes) throw packageError('asset-too-large', 'A protected viewer asset exceeded its fixed byte limit.');
351
+ return bytes;
352
+ }
353
+ const reader = response.body.getReader();
354
+ const output = new Uint8Array(maximumBytes);
355
+ let total = 0;
356
+ try {
357
+ for (;;) {
358
+ const { done, value } = await reader.read();
359
+ if (done) break;
360
+ total += value.byteLength;
361
+ if (total > maximumBytes) {
362
+ await reader.cancel('asset byte limit exceeded');
363
+ throw packageError('asset-too-large', 'A protected viewer asset exceeded its fixed byte limit.');
364
+ }
365
+ output.set(value, total - value.byteLength);
366
+ }
367
+ } finally {
368
+ reader.releaseLock();
369
+ }
370
+ return total === maximumBytes ? output : output.subarray(0, total);
371
+ }
372
+
373
+ function exactAsset(bytes, expectedBytes, label) {
374
+ if (bytes.byteLength !== expectedBytes) {
375
+ throw packageError('asset-length-invalid', `${label} byte length is ${bytes.byteLength}; expected ${expectedBytes}.`);
376
+ }
377
+ return bytes;
378
+ }
379
+
380
+ async function verifySha256(bytes, expected, code, message) {
381
+ const subtle = globalThis.crypto?.subtle;
382
+ if (!subtle || typeof subtle.digest !== 'function') {
383
+ throw packageError('asset-integrity-unavailable', 'Web Crypto SHA-256 support is required to verify protected viewer assets.');
384
+ }
385
+ const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
386
+ const actual = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
387
+ if (actual !== expected) throw packageError(code, message);
97
388
  }
98
389
 
99
390
  function validateEngine(api) {
@@ -112,27 +403,26 @@ function validateEngine(api) {
112
403
  }
113
404
  }
114
405
 
115
- function createRuntime(engine) {
116
- const api = engine.exports;
117
- return Object.freeze({
406
+ function createDirectRuntime(engine) {
407
+ const runtime = {
408
+ mode: 'direct',
118
409
  license: LICENSE,
119
410
  watermark: WATERMARK,
120
- open(input) {
121
- return openDocument(api, input);
411
+ async open(input) {
412
+ return openDirectDocument(engine.exports, input);
122
413
  },
123
414
  async mount(target, input, options = {}) {
124
- const document = openDocument(api, input);
125
- try {
126
- return await mountDocument(document, target, options);
127
- } catch (error) {
128
- document.close();
129
- throw error;
130
- }
131
- }
132
- });
415
+ return mountDocument(runtime, target, input, options);
416
+ },
417
+ async cacheStats() {
418
+ return disabledCacheStats(engine.exports.memory.buffer.byteLength);
419
+ },
420
+ async close() {}
421
+ };
422
+ return Object.freeze(runtime);
133
423
  }
134
424
 
135
- function openDocument(api, input) {
425
+ function openDirectDocument(api, input) {
136
426
  const bytes = normalizeInput(input);
137
427
  if (bytes.byteLength < 8 || !hasCfbSignature(bytes)) {
138
428
  throw packageError('ppt-signature-invalid', 'Input is not a PowerPoint 97-2003 (.ppt) compound document.');
@@ -149,28 +439,46 @@ function openDocument(api, input) {
149
439
  if (!handle) throw engineError(api, 'The native PPT parser rejected the document.');
150
440
 
151
441
  const slideCount = Number(api.ppt_slide_count(handle));
152
- const slideWidth = Number(api.ppt_slide_width(handle, 1000));
153
- const slideHeight = Number(api.ppt_slide_height(handle, 1000));
442
+ const width = Number(api.ppt_slide_width(handle, 1000));
443
+ const height = Number(api.ppt_slide_height(handle, 1000));
154
444
  if (!Number.isInteger(slideCount) || slideCount < 1 || slideCount > 10000
155
- || !Number.isFinite(slideWidth) || slideWidth <= 0
156
- || !Number.isFinite(slideHeight) || slideHeight <= 0) {
445
+ || !Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
157
446
  api.ppt_document_close(handle);
158
447
  throw engineError(api, 'The native PPT parser returned invalid presentation dimensions.');
159
448
  }
160
449
 
450
+ const renderedCanvases = new Map();
161
451
  let closed = false;
162
452
  const document = {
453
+ mode: 'direct',
454
+ documentId: '',
163
455
  get slideCount() { return slideCount; },
164
- get width() { return slideWidth; },
165
- get height() { return slideHeight; },
456
+ get width() { return width; },
457
+ get height() { return height; },
166
458
  get closed() { return closed; },
167
- renderSlide(slideIndex, canvas, options = {}) {
459
+ async renderSlide(slideIndex, canvas, options = {}) {
168
460
  if (closed) throw packageError('document-closed', 'The PPT document is already closed.');
169
- return renderSlide(api, handle, slideCount, slideWidth, slideHeight, slideIndex, canvas, options);
461
+ const result = renderDirectSlide(api, handle, slideCount, width, height, slideIndex, canvas, options);
462
+ renderedCanvases.set(slideIndex, canvas);
463
+ return result;
170
464
  },
171
- close() {
465
+ async releaseSlide(slideIndex) {
466
+ if (closed) return false;
467
+ validateSlideIndex(slideIndex, slideCount);
468
+ const canvas = renderedCanvases.get(slideIndex);
469
+ if (!canvas) return false;
470
+ canvas.width = 1;
471
+ canvas.height = 1;
472
+ renderedCanvases.delete(slideIndex);
473
+ return true;
474
+ },
475
+ async cacheStats() {
476
+ return disabledCacheStats(api.memory.buffer.byteLength);
477
+ },
478
+ async close() {
172
479
  if (!closed) {
173
480
  closed = true;
481
+ renderedCanvases.clear();
174
482
  if (Number(api.ppt_document_close(handle)) !== 1) throw engineError(api, 'Unable to close the native PPT document.');
175
483
  }
176
484
  }
@@ -178,19 +486,12 @@ function openDocument(api, input) {
178
486
  return Object.freeze(document);
179
487
  }
180
488
 
181
- function renderSlide(api, documentHandle, slideCount, logicalWidth, logicalHeight, slideIndex, canvas, options) {
182
- if (!Number.isInteger(slideIndex) || slideIndex < 0 || slideIndex >= slideCount) {
183
- throw new RangeError(`slideIndex must be between 0 and ${slideCount - 1}.`);
184
- }
489
+ function renderDirectSlide(api, documentHandle, slideCount, logicalWidth, logicalHeight, slideIndex, canvas, options) {
490
+ validateSlideIndex(slideIndex, slideCount);
185
491
  if (!canvas || typeof canvas.getContext !== 'function') {
186
492
  throw new TypeError('renderSlide requires an HTMLCanvasElement or OffscreenCanvas.');
187
493
  }
188
- const scale = finitePositive(options.scale, 1);
189
- const pixelRatio = finitePositive(options.pixelRatio, defaultPixelRatio());
190
- const nativeScale = scale * pixelRatio;
191
- if (nativeScale < 0.1 || nativeScale > 8) {
192
- throw new RangeError('scale * pixelRatio must be between 0.1 and 8.');
193
- }
494
+ const { scale, pixelRatio, nativeScale } = normalizeRenderOptions(options);
194
495
  const scaleMilli = Math.round(nativeScale * 1000);
195
496
  const pixelWidth = Number(api.ppt_slide_width(documentHandle, scaleMilli));
196
497
  const pixelHeight = Number(api.ppt_slide_height(documentHandle, scaleMilli));
@@ -205,24 +506,20 @@ function renderSlide(api, documentHandle, slideCount, logicalWidth, logicalHeigh
205
506
  if (width !== pixelWidth || height !== pixelHeight || stride !== width * 4 || length !== stride * height || !pointer) {
206
507
  throw packageError('wasm-frame-invalid', 'The native PPT engine returned an invalid RGBA frame.');
207
508
  }
208
- const pixels = new Uint8ClampedArray(api.memory.buffer, pointer, length).slice();
209
509
  canvas.width = width;
210
510
  canvas.height = height;
211
- if ('style' in canvas && canvas.style) {
212
- canvas.style.width = `${Math.round(logicalWidth * scale)}px`;
213
- canvas.style.maxWidth = '100%';
214
- canvas.style.height = 'auto';
215
- canvas.style.aspectRatio = `${width} / ${height}`;
216
- canvas.style.display = 'block';
511
+ if (typeof HTMLCanvasElement !== 'undefined' && canvas instanceof HTMLCanvasElement) {
512
+ prepareCanvasElement(canvas, logicalWidth, logicalHeight, scale);
217
513
  }
218
514
  const context = canvas.getContext('2d', { alpha: false, desynchronized: false });
219
515
  if (!context || typeof context.putImageData !== 'function') {
220
516
  throw packageError('canvas-context-unavailable', 'A 2D Canvas context is required to display PPT slides.');
221
517
  }
518
+ const pixels = new Uint8ClampedArray(api.memory.buffer, pointer, length);
222
519
  let imageData;
223
- if (typeof ImageData === 'function') {
520
+ try {
224
521
  imageData = new ImageData(pixels, width, height);
225
- } else {
522
+ } catch {
226
523
  imageData = context.createImageData(width, height);
227
524
  imageData.data.set(pixels);
228
525
  }
@@ -233,25 +530,152 @@ function renderSlide(api, documentHandle, slideCount, logicalWidth, logicalHeigh
233
530
  }
234
531
  }
235
532
 
236
- async function mountDocument(document, target, options) {
533
+ async function mountDocument(runtime, target, input, options) {
237
534
  const container = resolveElement(target);
535
+ const document = await runtime.open(input);
238
536
  const root = documentRoot(container, options);
239
537
  const canvases = [];
240
- for (let slideIndex = 0; slideIndex < document.slideCount; slideIndex += 1) {
241
- const canvas = documentCanvas(root, slideIndex);
242
- document.renderSlide(slideIndex, canvas, options);
243
- canvases.push(canvas);
244
- if (slideIndex + 1 < document.slideCount) await yieldToBrowser();
245
- }
246
- return Object.freeze({
247
- document,
248
- root,
249
- canvases: Object.freeze(canvases),
250
- close() {
251
- document.close();
252
- root.remove();
538
+ const shells = [];
539
+ const states = Array.from({ length: document.slideCount }, () => 'idle');
540
+ const visible = Array.from({ length: document.slideCount }, () => false);
541
+ const operations = new Map();
542
+ const releaseOperations = new Map();
543
+ const releaseTimers = new Map();
544
+ let observer = null;
545
+ let closed = false;
546
+
547
+ try {
548
+ for (let slideIndex = 0; slideIndex < document.slideCount; slideIndex += 1) {
549
+ const { shell, canvas } = documentCanvas(root, slideIndex, document.width, document.height, finitePositive(options.scale, 1));
550
+ shells.push(shell);
551
+ canvases.push(canvas);
253
552
  }
254
- });
553
+
554
+ const activate = (slideIndex) => {
555
+ if (closed || states[slideIndex] === 'rendered') return operations.get(slideIndex) || Promise.resolve();
556
+ const pending = operations.get(slideIndex);
557
+ if (pending) return pending;
558
+ clearRelease(slideIndex);
559
+ states[slideIndex] = 'rendering';
560
+ canvases[slideIndex].dataset.renderState = 'rendering';
561
+ const operation = document.renderSlide(slideIndex, canvases[slideIndex], options)
562
+ .then((result) => {
563
+ if (result?.cancelled) {
564
+ states[slideIndex] = 'released';
565
+ canvases[slideIndex].dataset.renderState = 'released';
566
+ return result;
567
+ }
568
+ states[slideIndex] = 'rendered';
569
+ canvases[slideIndex].dataset.renderState = 'rendered';
570
+ return result;
571
+ })
572
+ .catch((error) => {
573
+ states[slideIndex] = 'error';
574
+ canvases[slideIndex].dataset.renderState = 'error';
575
+ throw error;
576
+ })
577
+ .finally(() => operations.delete(slideIndex));
578
+ operations.set(slideIndex, operation);
579
+ return operation;
580
+ };
581
+
582
+ const release = async (slideIndex) => {
583
+ if (closed || releaseOperations.has(slideIndex)) return releaseOperations.get(slideIndex);
584
+ const operation = (async () => {
585
+ const rendering = operations.get(slideIndex);
586
+ if (rendering) await rendering.catch(() => {});
587
+ if (closed || visible[slideIndex] || states[slideIndex] !== 'rendered') return;
588
+ try {
589
+ await document.releaseSlide(slideIndex);
590
+ states[slideIndex] = 'released';
591
+ canvases[slideIndex].dataset.renderState = 'released';
592
+ if (visible[slideIndex] && !closed) await activate(slideIndex);
593
+ } catch (error) {
594
+ canvases[slideIndex].dataset.renderState = 'error';
595
+ console.warn('Flyfish PPT slide release failed.', error);
596
+ }
597
+ })().finally(() => releaseOperations.delete(slideIndex));
598
+ releaseOperations.set(slideIndex, operation);
599
+ return operation;
600
+ };
601
+
602
+ const scheduleRelease = (slideIndex) => {
603
+ clearRelease(slideIndex);
604
+ const delay = boundedInteger(options.releaseDelayMs, 1200, 0, 30000);
605
+ const timer = setTimeout(() => {
606
+ releaseTimers.delete(slideIndex);
607
+ void release(slideIndex);
608
+ }, delay);
609
+ releaseTimers.set(slideIndex, timer);
610
+ };
611
+
612
+ function clearRelease(slideIndex) {
613
+ const timer = releaseTimers.get(slideIndex);
614
+ if (timer !== undefined) clearTimeout(timer);
615
+ releaseTimers.delete(slideIndex);
616
+ }
617
+
618
+ const virtualize = options.virtualize !== false && typeof IntersectionObserver === 'function';
619
+ if (virtualize) {
620
+ observer = new IntersectionObserver((entries) => {
621
+ for (const entry of entries) {
622
+ const slideIndex = Number(entry.target.dataset.slideIndex);
623
+ if (entry.isIntersecting) {
624
+ visible[slideIndex] = true;
625
+ clearRelease(slideIndex);
626
+ void activate(slideIndex).catch((error) => console.error(error));
627
+ } else {
628
+ visible[slideIndex] = false;
629
+ scheduleRelease(slideIndex);
630
+ }
631
+ }
632
+ }, {
633
+ root: options.scrollRoot || null,
634
+ rootMargin: typeof options.rootMargin === 'string' ? options.rootMargin : '150% 0px',
635
+ threshold: 0
636
+ });
637
+ for (const shell of shells) observer.observe(shell);
638
+ await activate(0);
639
+ } else {
640
+ for (let slideIndex = 0; slideIndex < document.slideCount; slideIndex += 1) {
641
+ await activate(slideIndex);
642
+ if (slideIndex + 1 < document.slideCount) await yieldToBrowser();
643
+ }
644
+ }
645
+
646
+ return Object.freeze({
647
+ document,
648
+ root,
649
+ canvases: Object.freeze(canvases),
650
+ virtualized: virtualize,
651
+ async renderSlide(slideIndex) {
652
+ validateSlideIndex(slideIndex, document.slideCount);
653
+ return activate(slideIndex);
654
+ },
655
+ async cacheStats() {
656
+ return document.cacheStats();
657
+ },
658
+ async close() {
659
+ if (closed) return;
660
+ closed = true;
661
+ observer?.disconnect();
662
+ for (const timer of releaseTimers.values()) clearTimeout(timer);
663
+ releaseTimers.clear();
664
+ await Promise.allSettled(operations.values());
665
+ await Promise.allSettled(releaseOperations.values());
666
+ await document.close();
667
+ root.remove();
668
+ }
669
+ });
670
+ } catch (error) {
671
+ closed = true;
672
+ observer?.disconnect();
673
+ for (const timer of releaseTimers.values()) clearTimeout(timer);
674
+ await Promise.allSettled(releaseOperations.values());
675
+ root.remove();
676
+ await document.close().catch(() => {});
677
+ throw error;
678
+ }
255
679
  }
256
680
 
257
681
  function resolveElement(target) {
@@ -267,21 +691,34 @@ function documentRoot(container, options) {
267
691
  const root = document.createElement('div');
268
692
  root.className = options.className || 'flyfish-ppt-viewer';
269
693
  root.dataset.flyfishPptEdition = 'public-watermarked';
694
+ root.dataset.flyfishPptVirtualized = String(options.virtualize !== false);
270
695
  root.style.cssText = 'display:grid;width:100%;min-width:0;justify-content:center;gap:20px;padding:20px;box-sizing:border-box;background:#eef1f5;';
271
696
  container.append(root);
272
697
  return root;
273
698
  }
274
699
 
275
- function documentCanvas(root, slideIndex) {
700
+ function documentCanvas(root, slideIndex, logicalWidth, logicalHeight, scale) {
276
701
  const shell = document.createElement('section');
277
702
  shell.dataset.slideIndex = String(slideIndex);
278
- shell.style.cssText = 'max-width:100%;min-width:0;overflow:hidden;background:#fff;box-shadow:0 8px 28px rgba(15,23,42,.14);';
703
+ shell.style.cssText = `width:${Math.round(logicalWidth * scale)}px;max-width:100%;min-width:0;aspect-ratio:${logicalWidth}/${logicalHeight};overflow:hidden;background:#fff;box-shadow:0 8px 28px rgba(15,23,42,.14);`;
279
704
  const canvas = document.createElement('canvas');
705
+ canvas.width = 1;
706
+ canvas.height = 1;
707
+ canvas.dataset.renderState = 'idle';
280
708
  canvas.setAttribute('role', 'img');
281
709
  canvas.setAttribute('aria-label', `Slide ${slideIndex + 1}`);
710
+ canvas.style.cssText = 'display:block;width:100%;height:100%;';
282
711
  shell.append(canvas);
283
712
  root.append(shell);
284
- return canvas;
713
+ return { shell, canvas };
714
+ }
715
+
716
+ function prepareCanvasElement(canvas, logicalWidth, logicalHeight, scale) {
717
+ canvas.style.width = `${Math.round(logicalWidth * scale)}px`;
718
+ canvas.style.maxWidth = '100%';
719
+ canvas.style.height = 'auto';
720
+ canvas.style.aspectRatio = `${logicalWidth} / ${logicalHeight}`;
721
+ canvas.style.display = 'block';
285
722
  }
286
723
 
287
724
  function normalizeInput(input) {
@@ -291,11 +728,88 @@ function normalizeInput(input) {
291
728
  throw new TypeError('PPT input must be an ArrayBuffer or Uint8Array.');
292
729
  }
293
730
 
731
+ function copyToArrayBuffer(bytes) {
732
+ const copy = new Uint8Array(bytes.byteLength);
733
+ copy.set(bytes);
734
+ return copy.buffer;
735
+ }
736
+
737
+ function transferableInputBuffer(input, bytes, transferOwnership) {
738
+ if (transferOwnership
739
+ && bytes.byteOffset === 0
740
+ && bytes.buffer instanceof ArrayBuffer
741
+ && bytes.byteLength === bytes.buffer.byteLength
742
+ && (input instanceof ArrayBuffer || ArrayBuffer.isView(input))) {
743
+ return bytes.buffer;
744
+ }
745
+ return copyToArrayBuffer(bytes);
746
+ }
747
+
748
+ function disabledCacheStats(wasmMemoryBytes = 0) {
749
+ return Object.freeze({
750
+ enabled: false,
751
+ available: false,
752
+ disabledReason: 'worker-disabled',
753
+ entries: 0,
754
+ bytes: 0,
755
+ maxBytes: 0,
756
+ maxEntries: 0,
757
+ maxEntryBytes: 0,
758
+ hits: 0,
759
+ misses: 0,
760
+ writes: 0,
761
+ nativeRenders: 0,
762
+ cancellations: 0,
763
+ attachedCanvases: 0,
764
+ activeCanvases: 0,
765
+ wasmMemoryBytes
766
+ });
767
+ }
768
+
294
769
  function hasCfbSignature(bytes) {
295
770
  const signature = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
296
771
  return signature.every((value, index) => bytes[index] === value);
297
772
  }
298
773
 
774
+ function normalizeRenderOptions(options = {}) {
775
+ const scale = finitePositive(options.scale, 1);
776
+ const pixelRatio = finitePositive(options.pixelRatio, defaultPixelRatio());
777
+ const nativeScale = scale * pixelRatio;
778
+ if (nativeScale < 0.1 || nativeScale > 8) {
779
+ throw new RangeError('scale * pixelRatio must be between 0.1 and 8.');
780
+ }
781
+ return { scale, pixelRatio, nativeScale };
782
+ }
783
+
784
+ function normalizeCacheOptions(value) {
785
+ if (value === false) return { enabled: false };
786
+ const options = value && typeof value === 'object' ? value : {};
787
+ return {
788
+ enabled: options.enabled !== false,
789
+ dbName: typeof options.dbName === 'string' && options.dbName.trim() ? options.dbName.trim() : 'flyfish-ppt-frame-cache-v1',
790
+ maxBytes: boundedInteger(options.maxBytes, 256 * 1024 * 1024, 16 * 1024 * 1024, 1024 * 1024 * 1024),
791
+ maxEntries: boundedInteger(options.maxEntries, 200, 8, 2000),
792
+ maxEntryBytes: boundedInteger(options.maxEntryBytes, 32 * 1024 * 1024, 1024 * 1024, 128 * 1024 * 1024)
793
+ };
794
+ }
795
+
796
+ function resolveUrl(value, fallback) {
797
+ if (value === undefined) return fallback;
798
+ if (value instanceof URL) return value;
799
+ if (typeof value === 'string') return new URL(value, globalThis.location?.href || import.meta.url);
800
+ throw new TypeError('Worker rendering asset sources must be URLs or URL strings.');
801
+ }
802
+
803
+ function validateSlideIndex(slideIndex, slideCount) {
804
+ if (!Number.isInteger(slideIndex) || slideIndex < 0 || slideIndex >= slideCount) {
805
+ throw new RangeError(`slideIndex must be between 0 and ${slideCount - 1}.`);
806
+ }
807
+ }
808
+
809
+ function ensureOpen(closed) {
810
+ if (closed) throw packageError('document-closed', 'The PPT document is already closed.');
811
+ }
812
+
299
813
  function finitePositive(value, fallback) {
300
814
  if (value === undefined) return fallback;
301
815
  const number = Number(value);
@@ -303,6 +817,24 @@ function finitePositive(value, fallback) {
303
817
  return number;
304
818
  }
305
819
 
820
+ function boundedInteger(value, fallback, minimum, maximum) {
821
+ const number = value === undefined ? fallback : Number(value);
822
+ if (!Number.isFinite(number)) return fallback;
823
+ return Math.min(maximum, Math.max(minimum, Math.round(number)));
824
+ }
825
+
826
+ function positiveInteger(value, label) {
827
+ const number = Number(value);
828
+ if (!Number.isInteger(number) || number < 1) throw packageError('worker-metadata-invalid', `Worker ${label} is invalid.`);
829
+ return number;
830
+ }
831
+
832
+ function positiveNumber(value, label) {
833
+ const number = Number(value);
834
+ if (!Number.isFinite(number) || number <= 0) throw packageError('worker-metadata-invalid', `Worker ${label} is invalid.`);
835
+ return number;
836
+ }
837
+
306
838
  function defaultPixelRatio() {
307
839
  return typeof devicePixelRatio === 'number' && Number.isFinite(devicePixelRatio) ? Math.max(1, devicePixelRatio) : 1;
308
840
  }