@file-viewer/ppt 0.2.0 → 0.3.1

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