@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/LICENSE +99 -173
- package/NOTICE +19 -19
- package/README.md +333 -113
- package/frame-cache.mjs +731 -0
- package/index.d.ts +116 -37
- package/index.mjs +633 -98
- package/manifest.json +25 -11
- package/package.json +7 -2
- package/ppt-font-cjk.otf +0 -0
- package/ppt-native.wasm +0 -0
- package/worker.mjs +920 -0
package/worker.mjs
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
import { createFrameCache } from './frame-cache.mjs';
|
|
2
|
+
|
|
3
|
+
const WASM_EXPORTS = Object.freeze([
|
|
4
|
+
'memory',
|
|
5
|
+
'ppt_input_alloc',
|
|
6
|
+
'ppt_input_free',
|
|
7
|
+
'ppt_font_pack_install',
|
|
8
|
+
'ppt_font_pack_is_installed',
|
|
9
|
+
'ppt_document_open',
|
|
10
|
+
'ppt_document_close',
|
|
11
|
+
'ppt_slide_count',
|
|
12
|
+
'ppt_slide_width',
|
|
13
|
+
'ppt_slide_height',
|
|
14
|
+
'ppt_render_slide_rgba',
|
|
15
|
+
'ppt_frame_ptr',
|
|
16
|
+
'ppt_frame_len',
|
|
17
|
+
'ppt_frame_width',
|
|
18
|
+
'ppt_frame_height',
|
|
19
|
+
'ppt_frame_stride',
|
|
20
|
+
'ppt_frame_free',
|
|
21
|
+
'ppt_last_error_code'
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const CFB_SIGNATURE = Object.freeze([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
|
25
|
+
const MAX_ASSET_BYTES = 64 * 1024 * 1024;
|
|
26
|
+
const MAX_DOCUMENT_BYTES = 256 * 1024 * 1024;
|
|
27
|
+
const CACHE_VARIANT_VERSION = 'flyfish-ppt-final-watermarked-png-v1';
|
|
28
|
+
|
|
29
|
+
let runtime = null;
|
|
30
|
+
let requestQueue = Promise.resolve();
|
|
31
|
+
let sessionEpoch = 0;
|
|
32
|
+
const activationIntents = new Map();
|
|
33
|
+
const pendingRequestIds = new Set();
|
|
34
|
+
const activeAbortControllers = new Set();
|
|
35
|
+
|
|
36
|
+
const counters = {
|
|
37
|
+
cacheHits: 0,
|
|
38
|
+
cacheMisses: 0,
|
|
39
|
+
cacheWrites: 0,
|
|
40
|
+
nativeRenders: 0,
|
|
41
|
+
cancellations: 0
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const workerScope = globalThis;
|
|
45
|
+
if (typeof workerScope.addEventListener === 'function' && typeof workerScope.postMessage === 'function') {
|
|
46
|
+
workerScope.addEventListener('message', (event) => enqueueRequest(event.data));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function enqueueRequest(message) {
|
|
50
|
+
const fallbackId = isRecord(message) && isRequestId(message.id) ? message.id : null;
|
|
51
|
+
let request;
|
|
52
|
+
try {
|
|
53
|
+
request = prepareRequest(message);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
postResponse(fallbackId, false, serializeError(error));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const requestKey = idKey(request.id);
|
|
60
|
+
if (pendingRequestIds.has(requestKey)) {
|
|
61
|
+
postResponse(request.id, false, serializeError(workerError(
|
|
62
|
+
'duplicate-request-id',
|
|
63
|
+
'Worker request ids must remain unique until their responses are delivered.'
|
|
64
|
+
)));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
pendingRequestIds.add(requestKey);
|
|
68
|
+
|
|
69
|
+
requestQueue = requestQueue.then(async () => {
|
|
70
|
+
try {
|
|
71
|
+
const result = await dispatchRequest(request);
|
|
72
|
+
postResponse(request.id, true, result);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof CancelledOperation) {
|
|
75
|
+
counters.cancellations += 1;
|
|
76
|
+
postResponse(request.id, true, Object.freeze({ cancelled: true }));
|
|
77
|
+
} else {
|
|
78
|
+
postResponse(request.id, false, serializeError(error));
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
pendingRequestIds.delete(requestKey);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function prepareRequest(message) {
|
|
87
|
+
if (!isRecord(message)) throw workerError('request-invalid', 'Worker requests must be objects.');
|
|
88
|
+
if (!isRequestId(message.id)) {
|
|
89
|
+
throw workerError('request-id-invalid', 'Worker requests require a finite number or non-empty string id.');
|
|
90
|
+
}
|
|
91
|
+
if (typeof message.type !== 'string' || message.type.length === 0) {
|
|
92
|
+
throw workerError('request-type-invalid', 'Worker requests require a non-empty type.');
|
|
93
|
+
}
|
|
94
|
+
if (!['init', 'open', 'attach', 'activate', 'deactivate', 'cacheStats', 'close'].includes(message.type)) {
|
|
95
|
+
throw workerError('request-type-unsupported', `Unsupported worker request type: ${message.type}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (message.type === 'init' || message.type === 'close') {
|
|
99
|
+
sessionEpoch += 1;
|
|
100
|
+
activationIntents.clear();
|
|
101
|
+
abortActiveOperations();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let intentRevision = null;
|
|
105
|
+
if (message.type === 'activate' || message.type === 'deactivate') {
|
|
106
|
+
const slideIndex = requireSlideIndex(message.slideIndex, false);
|
|
107
|
+
const previous = activationIntents.get(slideIndex) || { revision: 0, active: false };
|
|
108
|
+
const next = {
|
|
109
|
+
revision: previous.revision + 1,
|
|
110
|
+
active: message.type === 'activate'
|
|
111
|
+
};
|
|
112
|
+
activationIntents.set(slideIndex, next);
|
|
113
|
+
intentRevision = next.revision;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
id: message.id,
|
|
118
|
+
type: message.type,
|
|
119
|
+
message,
|
|
120
|
+
epoch: sessionEpoch,
|
|
121
|
+
intentRevision
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function dispatchRequest(request) {
|
|
126
|
+
assertCurrent(request);
|
|
127
|
+
switch (request.type) {
|
|
128
|
+
case 'init':
|
|
129
|
+
return initialize(request);
|
|
130
|
+
case 'open':
|
|
131
|
+
return openDocument(request);
|
|
132
|
+
case 'attach':
|
|
133
|
+
return attachCanvas(request);
|
|
134
|
+
case 'activate':
|
|
135
|
+
return activateCanvas(request);
|
|
136
|
+
case 'deactivate':
|
|
137
|
+
return deactivateCanvas(request);
|
|
138
|
+
case 'cacheStats':
|
|
139
|
+
return cacheStats(request);
|
|
140
|
+
case 'close':
|
|
141
|
+
return closeRuntime(request);
|
|
142
|
+
default:
|
|
143
|
+
throw workerError('request-type-unsupported', `Unsupported worker request type: ${request.type}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function initialize(request) {
|
|
148
|
+
await releaseRuntime();
|
|
149
|
+
assertCurrent(request);
|
|
150
|
+
resetCounters();
|
|
151
|
+
|
|
152
|
+
const manifest = normalizeManifest(request.message.manifest);
|
|
153
|
+
const wasmUrl = normalizeAssetUrl(request.message.wasmUrl, 'wasmUrl');
|
|
154
|
+
const fontUrl = normalizeAssetUrl(request.message.fontUrl, 'fontUrl');
|
|
155
|
+
const cacheOptions = normalizeCacheOptions(request.message.cacheOptions);
|
|
156
|
+
requireWebCrypto();
|
|
157
|
+
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
activeAbortControllers.add(controller);
|
|
160
|
+
let cache = null;
|
|
161
|
+
try {
|
|
162
|
+
const fontPromise = downloadVerifiedAsset(
|
|
163
|
+
fontUrl,
|
|
164
|
+
manifest.fontPack.bytes,
|
|
165
|
+
manifest.fontPack.sha256,
|
|
166
|
+
'PPT native font pack',
|
|
167
|
+
controller.signal
|
|
168
|
+
);
|
|
169
|
+
void fontPromise.catch(() => {});
|
|
170
|
+
const wasmBytes = await downloadVerifiedAsset(
|
|
171
|
+
wasmUrl,
|
|
172
|
+
manifest.wasmBytes,
|
|
173
|
+
manifest.wasmSha256,
|
|
174
|
+
'PPT native WASM',
|
|
175
|
+
controller.signal
|
|
176
|
+
);
|
|
177
|
+
assertCurrent(request);
|
|
178
|
+
|
|
179
|
+
// Security invariant: no unverified bytes are presented to the WebAssembly
|
|
180
|
+
// compiler. Streaming compilation and compile-before-hash are deliberately
|
|
181
|
+
// not used by this worker.
|
|
182
|
+
const module = await WebAssembly.compile(wasmBytes);
|
|
183
|
+
assertCurrent(request);
|
|
184
|
+
validateModule(module);
|
|
185
|
+
const instance = await WebAssembly.instantiate(module, {});
|
|
186
|
+
assertCurrent(request);
|
|
187
|
+
validateEngine(instance.exports);
|
|
188
|
+
const fontBytes = await fontPromise;
|
|
189
|
+
assertCurrent(request);
|
|
190
|
+
installFontPack(instance.exports, fontBytes);
|
|
191
|
+
|
|
192
|
+
cache = createFrameCache(cacheOptions);
|
|
193
|
+
runtime = {
|
|
194
|
+
manifest,
|
|
195
|
+
instance,
|
|
196
|
+
api: instance.exports,
|
|
197
|
+
cache,
|
|
198
|
+
document: null,
|
|
199
|
+
attachments: new Map()
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const cacheSnapshot = await safeCacheStats(cache);
|
|
203
|
+
assertCurrent(request);
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
initialized: true,
|
|
206
|
+
wasmSha256: manifest.wasmSha256,
|
|
207
|
+
wasmBytes: manifest.wasmBytes,
|
|
208
|
+
fontPackSha256: manifest.fontPack.sha256,
|
|
209
|
+
fontPackBytes: manifest.fontPack.bytes,
|
|
210
|
+
fontPackInstalled: true,
|
|
211
|
+
cache: cacheSnapshot
|
|
212
|
+
});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
controller.abort();
|
|
215
|
+
if (cache) cache.close();
|
|
216
|
+
runtime = null;
|
|
217
|
+
if (!isCurrent(request) || error?.name === 'AbortError') throw new CancelledOperation();
|
|
218
|
+
throw error;
|
|
219
|
+
} finally {
|
|
220
|
+
activeAbortControllers.delete(controller);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function openDocument(request) {
|
|
225
|
+
const current = requireRuntime();
|
|
226
|
+
if (current.document) {
|
|
227
|
+
throw workerError('document-already-open', 'Close the current PPT document before opening another one.');
|
|
228
|
+
}
|
|
229
|
+
const input = request.message.input;
|
|
230
|
+
if (!(input instanceof ArrayBuffer) || typeof SharedArrayBuffer !== 'undefined' && input instanceof SharedArrayBuffer) {
|
|
231
|
+
throw workerError('document-input-invalid', 'open requires a transferable ArrayBuffer in the input field.');
|
|
232
|
+
}
|
|
233
|
+
const bytes = new Uint8Array(input);
|
|
234
|
+
if (bytes.byteLength < CFB_SIGNATURE.length || bytes.byteLength > MAX_DOCUMENT_BYTES) {
|
|
235
|
+
throw workerError('document-size-invalid', 'The PPT input length is outside the supported range.');
|
|
236
|
+
}
|
|
237
|
+
if (!CFB_SIGNATURE.every((value, index) => bytes[index] === value)) {
|
|
238
|
+
throw workerError('document-signature-invalid', 'Input is not a PowerPoint 97-2003 compound document.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const documentHashPromise = sha256Hex(bytes);
|
|
242
|
+
let handle = 0;
|
|
243
|
+
try {
|
|
244
|
+
handle = copyAndOpenDocument(current.api, bytes);
|
|
245
|
+
if (!handle) throw engineError(current.api, 'The native PPT parser rejected the document.');
|
|
246
|
+
const slideCount = Number(current.api.ppt_slide_count(handle));
|
|
247
|
+
const width = Number(current.api.ppt_slide_width(handle, 1000));
|
|
248
|
+
const height = Number(current.api.ppt_slide_height(handle, 1000));
|
|
249
|
+
if (!Number.isInteger(slideCount) || slideCount < 1 || slideCount > 10_000
|
|
250
|
+
|| !Number.isInteger(width) || width < 1
|
|
251
|
+
|| !Number.isInteger(height) || height < 1) {
|
|
252
|
+
throw engineError(current.api, 'The native PPT parser returned invalid document metadata.');
|
|
253
|
+
}
|
|
254
|
+
const sha256 = await documentHashPromise;
|
|
255
|
+
assertCurrent(request);
|
|
256
|
+
current.document = Object.freeze({ handle, sha256, slideCount, width, height });
|
|
257
|
+
handle = 0;
|
|
258
|
+
return Object.freeze({
|
|
259
|
+
documentId: sha256,
|
|
260
|
+
sha256,
|
|
261
|
+
slideCount,
|
|
262
|
+
width,
|
|
263
|
+
height
|
|
264
|
+
});
|
|
265
|
+
} catch (error) {
|
|
266
|
+
await documentHashPromise.catch(() => {});
|
|
267
|
+
if (handle) current.api.ppt_document_close(handle);
|
|
268
|
+
if (!isCurrent(request)) throw new CancelledOperation();
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function attachCanvas(request) {
|
|
274
|
+
const current = requireOpenDocument();
|
|
275
|
+
const slideIndex = requireSlideIndex(request.message.slideIndex, true, current.document.slideCount);
|
|
276
|
+
const canvas = request.message.canvas;
|
|
277
|
+
if (typeof OffscreenCanvas !== 'function' || !(canvas instanceof OffscreenCanvas)) {
|
|
278
|
+
throw workerError('canvas-invalid', 'attach requires a transferred OffscreenCanvas in the canvas field.');
|
|
279
|
+
}
|
|
280
|
+
const renderOptions = normalizeRenderOptions(request.message.scale, request.message.pixelRatio);
|
|
281
|
+
const context = canvas.getContext('2d', { alpha: false, desynchronized: false });
|
|
282
|
+
if (!context || typeof context.putImageData !== 'function' || typeof context.drawImage !== 'function') {
|
|
283
|
+
throw workerError('canvas-context-unavailable', 'A Worker-compatible Canvas 2D context is required.');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const existing = current.attachments.get(slideIndex);
|
|
287
|
+
if (existing) releaseCanvasBacking(existing.canvas);
|
|
288
|
+
releaseCanvasBacking(canvas);
|
|
289
|
+
const attachment = {
|
|
290
|
+
slideIndex,
|
|
291
|
+
canvas,
|
|
292
|
+
context,
|
|
293
|
+
scale: renderOptions.scale,
|
|
294
|
+
pixelRatio: renderOptions.pixelRatio,
|
|
295
|
+
active: false,
|
|
296
|
+
rendered: null
|
|
297
|
+
};
|
|
298
|
+
current.attachments.set(slideIndex, attachment);
|
|
299
|
+
return Object.freeze({
|
|
300
|
+
attached: true,
|
|
301
|
+
slideIndex,
|
|
302
|
+
scale: attachment.scale,
|
|
303
|
+
pixelRatio: attachment.pixelRatio
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function activateCanvas(request) {
|
|
308
|
+
const current = requireOpenDocument();
|
|
309
|
+
const slideIndex = requireSlideIndex(request.message.slideIndex, true, current.document.slideCount);
|
|
310
|
+
const attachment = current.attachments.get(slideIndex);
|
|
311
|
+
if (!attachment) throw workerError('canvas-not-attached', `Slide ${slideIndex} has no attached OffscreenCanvas.`);
|
|
312
|
+
assertActivationCurrent(request, slideIndex, true);
|
|
313
|
+
|
|
314
|
+
const renderOptions = normalizeRenderOptions(request.message.scale, request.message.pixelRatio);
|
|
315
|
+
attachment.scale = renderOptions.scale;
|
|
316
|
+
attachment.pixelRatio = renderOptions.pixelRatio;
|
|
317
|
+
const dimensions = slideDimensions(current.api, current.document.handle, renderOptions.scaleMilli);
|
|
318
|
+
const cacheKey = frameCacheKey(current, slideIndex, renderOptions.scaleMilli, dimensions);
|
|
319
|
+
|
|
320
|
+
const cached = await current.cache.get(cacheKey);
|
|
321
|
+
assertCurrent(request);
|
|
322
|
+
assertActivationCurrent(request, slideIndex, true);
|
|
323
|
+
if (validCachedFrame(cached, dimensions)) {
|
|
324
|
+
const drawn = await drawCachedFrame(attachment, cached.blob, dimensions, request);
|
|
325
|
+
if (drawn) {
|
|
326
|
+
counters.cacheHits += 1;
|
|
327
|
+
attachment.active = true;
|
|
328
|
+
attachment.rendered = renderMetadata(
|
|
329
|
+
slideIndex,
|
|
330
|
+
renderOptions,
|
|
331
|
+
dimensions,
|
|
332
|
+
cacheKey,
|
|
333
|
+
'indexeddb'
|
|
334
|
+
);
|
|
335
|
+
return Object.freeze({ ...attachment.rendered, cancelled: false });
|
|
336
|
+
}
|
|
337
|
+
await current.cache.delete(cacheKey);
|
|
338
|
+
} else if (cached) {
|
|
339
|
+
await current.cache.delete(cacheKey);
|
|
340
|
+
}
|
|
341
|
+
counters.cacheMisses += 1;
|
|
342
|
+
|
|
343
|
+
assertCurrent(request);
|
|
344
|
+
assertActivationCurrent(request, slideIndex, true);
|
|
345
|
+
drawNativeFrame(current.api, current.document.handle, slideIndex, renderOptions.scaleMilli, dimensions, attachment);
|
|
346
|
+
counters.nativeRenders += 1;
|
|
347
|
+
attachment.active = true;
|
|
348
|
+
attachment.rendered = renderMetadata(
|
|
349
|
+
slideIndex,
|
|
350
|
+
renderOptions,
|
|
351
|
+
dimensions,
|
|
352
|
+
cacheKey,
|
|
353
|
+
'native'
|
|
354
|
+
);
|
|
355
|
+
return Object.freeze({ ...attachment.rendered, cancelled: false });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function deactivateCanvas(request) {
|
|
359
|
+
const current = requireOpenDocument();
|
|
360
|
+
const slideIndex = requireSlideIndex(request.message.slideIndex, true, current.document.slideCount);
|
|
361
|
+
const attachment = current.attachments.get(slideIndex);
|
|
362
|
+
if (!attachment) throw workerError('canvas-not-attached', `Slide ${slideIndex} has no attached OffscreenCanvas.`);
|
|
363
|
+
assertActivationCurrent(request, slideIndex, false);
|
|
364
|
+
|
|
365
|
+
let stored = false;
|
|
366
|
+
if (attachment.rendered
|
|
367
|
+
&& attachment.rendered.source !== 'indexeddb'
|
|
368
|
+
&& attachment.canvas.width > 1
|
|
369
|
+
&& attachment.canvas.height > 1) {
|
|
370
|
+
if (typeof attachment.canvas.convertToBlob !== 'function') {
|
|
371
|
+
throw workerError('canvas-blob-unavailable', 'OffscreenCanvas PNG encoding is unavailable in this Worker.');
|
|
372
|
+
}
|
|
373
|
+
const blob = await attachment.canvas.convertToBlob({ type: 'image/png' });
|
|
374
|
+
assertCurrent(request);
|
|
375
|
+
assertActivationCurrent(request, slideIndex, false);
|
|
376
|
+
if (!(blob instanceof Blob) || blob.type !== 'image/png' || blob.size === 0) {
|
|
377
|
+
throw workerError('canvas-png-invalid', 'OffscreenCanvas did not produce a valid final-frame PNG.');
|
|
378
|
+
}
|
|
379
|
+
stored = await current.cache.put(attachment.rendered.cacheKey, {
|
|
380
|
+
blob,
|
|
381
|
+
width: attachment.rendered.width,
|
|
382
|
+
height: attachment.rendered.height
|
|
383
|
+
});
|
|
384
|
+
if (stored) counters.cacheWrites += 1;
|
|
385
|
+
assertCurrent(request);
|
|
386
|
+
assertActivationCurrent(request, slideIndex, false);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
attachment.active = false;
|
|
390
|
+
releaseCanvasBacking(attachment.canvas);
|
|
391
|
+
return Object.freeze({
|
|
392
|
+
deactivated: true,
|
|
393
|
+
slideIndex,
|
|
394
|
+
cached: stored,
|
|
395
|
+
width: 1,
|
|
396
|
+
height: 1
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function cacheStats(request) {
|
|
401
|
+
assertCurrent(request);
|
|
402
|
+
const current = requireRuntime();
|
|
403
|
+
const persisted = await safeCacheStats(current.cache);
|
|
404
|
+
assertCurrent(request);
|
|
405
|
+
return Object.freeze({
|
|
406
|
+
enabled: persisted.disabledReason !== 'disabled',
|
|
407
|
+
available: persisted.available === true,
|
|
408
|
+
disabledReason: persisted.disabledReason ?? null,
|
|
409
|
+
entries: Number(persisted.entries) || 0,
|
|
410
|
+
bytes: Number(persisted.totalBytes) || 0,
|
|
411
|
+
maxBytes: Number(persisted.maxBytes) || 0,
|
|
412
|
+
maxEntries: Number(persisted.maxEntries) || 0,
|
|
413
|
+
maxEntryBytes: Number(persisted.maxItemBytes) || 0,
|
|
414
|
+
hits: counters.cacheHits,
|
|
415
|
+
misses: counters.cacheMisses,
|
|
416
|
+
writes: counters.cacheWrites,
|
|
417
|
+
nativeRenders: counters.nativeRenders,
|
|
418
|
+
cancellations: counters.cancellations,
|
|
419
|
+
attachedCanvases: current.attachments.size,
|
|
420
|
+
activeCanvases: Array.from(current.attachments.values()).filter((item) => item.active).length,
|
|
421
|
+
wasmMemoryBytes: current.api.memory.buffer.byteLength
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function closeRuntime(request) {
|
|
426
|
+
releaseDocument(requireRuntime());
|
|
427
|
+
assertCurrent(request);
|
|
428
|
+
return Object.freeze({ closed: true });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function releaseRuntime() {
|
|
432
|
+
const current = runtime;
|
|
433
|
+
runtime = null;
|
|
434
|
+
if (!current) return;
|
|
435
|
+
|
|
436
|
+
let closeError = null;
|
|
437
|
+
try {
|
|
438
|
+
releaseDocument(current);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
closeError = error;
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
current.cache.close();
|
|
444
|
+
} catch (error) {
|
|
445
|
+
closeError ||= error;
|
|
446
|
+
}
|
|
447
|
+
if (closeError) throw closeError;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function releaseDocument(current) {
|
|
451
|
+
let closeError = null;
|
|
452
|
+
if (current.document) {
|
|
453
|
+
try {
|
|
454
|
+
if (Number(current.api.ppt_document_close(current.document.handle)) !== 1) {
|
|
455
|
+
closeError = engineError(current.api, 'Unable to close the native PPT document.');
|
|
456
|
+
}
|
|
457
|
+
} catch (error) {
|
|
458
|
+
closeError = error;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
current.document = null;
|
|
462
|
+
for (const attachment of current.attachments.values()) releaseCanvasBacking(attachment.canvas);
|
|
463
|
+
current.attachments.clear();
|
|
464
|
+
activationIntents.clear();
|
|
465
|
+
if (closeError) throw closeError;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function downloadVerifiedAsset(url, expectedBytes, expectedSha256, label, signal) {
|
|
469
|
+
const response = await fetch(url, { signal, cache: 'default', credentials: 'same-origin' });
|
|
470
|
+
if (!response.ok) {
|
|
471
|
+
throw workerError('asset-request-failed', `${label} request failed with HTTP ${response.status}.`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const encoding = response.headers.get('content-encoding');
|
|
475
|
+
const contentLength = response.headers.get('content-length');
|
|
476
|
+
if (!encoding && contentLength && Number(contentLength) !== expectedBytes) {
|
|
477
|
+
throw workerError('asset-length-invalid', `${label} Content-Length did not match its manifest.`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const bytes = await readExactResponseBytes(response, expectedBytes, label, signal);
|
|
481
|
+
const actualSha256 = await sha256Hex(bytes);
|
|
482
|
+
if (actualSha256 !== expectedSha256) {
|
|
483
|
+
throw workerError('asset-integrity-invalid', `${label} failed its SHA-256 integrity check.`);
|
|
484
|
+
}
|
|
485
|
+
return bytes;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function readExactResponseBytes(response, expectedBytes, label, signal) {
|
|
489
|
+
if (!response.body || typeof response.body.getReader !== 'function') {
|
|
490
|
+
const buffer = await response.arrayBuffer();
|
|
491
|
+
if (buffer.byteLength !== expectedBytes) {
|
|
492
|
+
throw workerError('asset-length-invalid', `${label} byte length did not match its manifest.`);
|
|
493
|
+
}
|
|
494
|
+
return new Uint8Array(buffer);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const output = new Uint8Array(expectedBytes);
|
|
498
|
+
const reader = response.body.getReader();
|
|
499
|
+
let offset = 0;
|
|
500
|
+
try {
|
|
501
|
+
while (true) {
|
|
502
|
+
if (signal.aborted) throw new DOMException('The operation was aborted.', 'AbortError');
|
|
503
|
+
const { done, value } = await reader.read();
|
|
504
|
+
if (done) break;
|
|
505
|
+
if (!(value instanceof Uint8Array) || offset + value.byteLength > expectedBytes) {
|
|
506
|
+
await reader.cancel();
|
|
507
|
+
throw workerError('asset-length-invalid', `${label} exceeded its manifest byte length.`);
|
|
508
|
+
}
|
|
509
|
+
output.set(value, offset);
|
|
510
|
+
offset += value.byteLength;
|
|
511
|
+
}
|
|
512
|
+
} finally {
|
|
513
|
+
reader.releaseLock();
|
|
514
|
+
}
|
|
515
|
+
if (offset !== expectedBytes) {
|
|
516
|
+
throw workerError('asset-length-invalid', `${label} byte length did not match its manifest.`);
|
|
517
|
+
}
|
|
518
|
+
return output;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function validateModule(module) {
|
|
522
|
+
const imports = WebAssembly.Module.imports(module);
|
|
523
|
+
if (imports.length !== 0) {
|
|
524
|
+
throw workerError('wasm-imports-rejected', 'The PPT rendering engine must not import browser or host functions.');
|
|
525
|
+
}
|
|
526
|
+
const actual = WebAssembly.Module.exports(module)
|
|
527
|
+
.map(({ name, kind }) => `${kind}:${name}`)
|
|
528
|
+
.sort();
|
|
529
|
+
const expected = WASM_EXPORTS
|
|
530
|
+
.map((name) => `${name === 'memory' ? 'memory' : 'function'}:${name}`)
|
|
531
|
+
.sort();
|
|
532
|
+
if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) {
|
|
533
|
+
throw workerError('wasm-abi-invalid', 'The PPT rendering engine has an unexpected public ABI.');
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function validateEngine(api) {
|
|
538
|
+
const actual = Object.keys(api).sort();
|
|
539
|
+
const expected = [...WASM_EXPORTS].sort();
|
|
540
|
+
if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
|
|
541
|
+
throw workerError('wasm-abi-invalid', 'The PPT rendering engine instance has an unexpected public ABI.');
|
|
542
|
+
}
|
|
543
|
+
if (!(api.memory instanceof WebAssembly.Memory)) {
|
|
544
|
+
throw workerError('wasm-memory-invalid', 'The PPT rendering engine did not export its bounded memory.');
|
|
545
|
+
}
|
|
546
|
+
for (const name of WASM_EXPORTS) {
|
|
547
|
+
if (name !== 'memory' && typeof api[name] !== 'function') {
|
|
548
|
+
throw workerError('wasm-abi-invalid', `The PPT rendering engine is missing ${name}.`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function installFontPack(api, bytes) {
|
|
554
|
+
if (Number(api.ppt_font_pack_is_installed()) !== 0) {
|
|
555
|
+
throw workerError('font-pack-state-invalid', 'The native font pack was unexpectedly installed before initialization.');
|
|
556
|
+
}
|
|
557
|
+
const pointer = Number(api.ppt_input_alloc(bytes.byteLength));
|
|
558
|
+
if (!pointer) throw engineError(api, 'Unable to allocate native font-pack memory.');
|
|
559
|
+
let consumed = false;
|
|
560
|
+
try {
|
|
561
|
+
new Uint8Array(api.memory.buffer, pointer, bytes.byteLength).set(bytes);
|
|
562
|
+
const installed = Number(api.ppt_font_pack_install(pointer, bytes.byteLength));
|
|
563
|
+
if (installed !== 1) throw engineError(api, 'The native engine rejected the verified font pack.');
|
|
564
|
+
// A successful install transfers allocation ownership to native code. It
|
|
565
|
+
// must not be passed to ppt_input_free afterwards.
|
|
566
|
+
consumed = true;
|
|
567
|
+
if (Number(api.ppt_font_pack_is_installed()) !== 1) {
|
|
568
|
+
throw workerError('font-pack-state-invalid', 'The native font pack did not enter the installed state.');
|
|
569
|
+
}
|
|
570
|
+
} finally {
|
|
571
|
+
if (!consumed && Number(api.ppt_input_free(pointer, bytes.byteLength)) !== 1) {
|
|
572
|
+
throw engineError(api, 'Unable to release rejected native font-pack memory.');
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function copyAndOpenDocument(api, bytes) {
|
|
578
|
+
const pointer = Number(api.ppt_input_alloc(bytes.byteLength));
|
|
579
|
+
if (!pointer) throw engineError(api, 'Unable to allocate native PPT input memory.');
|
|
580
|
+
let handle = 0;
|
|
581
|
+
let openErrorCode = 0;
|
|
582
|
+
let primaryError = null;
|
|
583
|
+
try {
|
|
584
|
+
new Uint8Array(api.memory.buffer, pointer, bytes.byteLength).set(bytes);
|
|
585
|
+
handle = Number(api.ppt_document_open(pointer, bytes.byteLength));
|
|
586
|
+
if (!handle) openErrorCode = Number(api.ppt_last_error_code()) || 0;
|
|
587
|
+
} catch (error) {
|
|
588
|
+
primaryError = error;
|
|
589
|
+
}
|
|
590
|
+
const released = Number(api.ppt_input_free(pointer, bytes.byteLength));
|
|
591
|
+
if (primaryError) throw primaryError;
|
|
592
|
+
if (released !== 1) {
|
|
593
|
+
const releaseError = engineError(api, 'Unable to release native PPT input memory.');
|
|
594
|
+
if (handle) api.ppt_document_close(handle);
|
|
595
|
+
throw releaseError;
|
|
596
|
+
}
|
|
597
|
+
if (!handle) {
|
|
598
|
+
throw workerError(
|
|
599
|
+
`native-${openErrorCode || 'unknown'}`,
|
|
600
|
+
`The native PPT parser rejected the document. (native error ${openErrorCode || 'unknown'})`
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
return handle;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function slideDimensions(api, documentHandle, scaleMilli) {
|
|
607
|
+
const width = Number(api.ppt_slide_width(documentHandle, scaleMilli));
|
|
608
|
+
const height = Number(api.ppt_slide_height(documentHandle, scaleMilli));
|
|
609
|
+
if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
|
|
610
|
+
throw engineError(api, 'The native engine returned invalid slide dimensions.');
|
|
611
|
+
}
|
|
612
|
+
return Object.freeze({ width, height });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function drawNativeFrame(api, documentHandle, slideIndex, scaleMilli, dimensions, attachment) {
|
|
616
|
+
const frame = Number(api.ppt_render_slide_rgba(documentHandle, slideIndex, scaleMilli));
|
|
617
|
+
if (!frame) throw engineError(api, `Unable to render slide ${slideIndex + 1}.`);
|
|
618
|
+
let primaryError = null;
|
|
619
|
+
try {
|
|
620
|
+
const width = Number(api.ppt_frame_width(frame));
|
|
621
|
+
const height = Number(api.ppt_frame_height(frame));
|
|
622
|
+
const stride = Number(api.ppt_frame_stride(frame));
|
|
623
|
+
const length = Number(api.ppt_frame_len(frame));
|
|
624
|
+
const pointer = Number(api.ppt_frame_ptr(frame));
|
|
625
|
+
const expectedLength = dimensions.width * dimensions.height * 4;
|
|
626
|
+
if (width !== dimensions.width || height !== dimensions.height || stride !== width * 4
|
|
627
|
+
|| length !== expectedLength || !pointer || pointer + length > api.memory.buffer.byteLength) {
|
|
628
|
+
throw workerError('wasm-frame-invalid', 'The native engine returned an invalid final RGBA frame.');
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
attachment.canvas.width = width;
|
|
632
|
+
attachment.canvas.height = height;
|
|
633
|
+
const pixels = new Uint8ClampedArray(api.memory.buffer, pointer, length);
|
|
634
|
+
let imageData;
|
|
635
|
+
if (typeof ImageData === 'function') {
|
|
636
|
+
imageData = new ImageData(pixels, width, height);
|
|
637
|
+
} else {
|
|
638
|
+
imageData = attachment.context.createImageData(width, height);
|
|
639
|
+
imageData.data.set(pixels);
|
|
640
|
+
}
|
|
641
|
+
attachment.context.putImageData(imageData, 0, 0);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
primaryError = error;
|
|
644
|
+
}
|
|
645
|
+
const released = Number(api.ppt_frame_free(frame));
|
|
646
|
+
if (primaryError) throw primaryError;
|
|
647
|
+
if (released !== 1) throw engineError(api, 'Unable to release the native final RGBA frame.');
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function drawCachedFrame(attachment, blob, dimensions, request) {
|
|
651
|
+
if (typeof createImageBitmap !== 'function') return false;
|
|
652
|
+
let bitmap;
|
|
653
|
+
try {
|
|
654
|
+
bitmap = await createImageBitmap(blob);
|
|
655
|
+
assertCurrent(request);
|
|
656
|
+
assertActivationCurrent(request, attachment.slideIndex, true);
|
|
657
|
+
if (bitmap.width !== dimensions.width || bitmap.height !== dimensions.height) return false;
|
|
658
|
+
attachment.canvas.width = dimensions.width;
|
|
659
|
+
attachment.canvas.height = dimensions.height;
|
|
660
|
+
attachment.context.drawImage(bitmap, 0, 0, dimensions.width, dimensions.height);
|
|
661
|
+
return true;
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (error instanceof CancelledOperation) throw error;
|
|
664
|
+
return false;
|
|
665
|
+
} finally {
|
|
666
|
+
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function frameCacheKey(current, slideIndex, scaleMilli, dimensions) {
|
|
671
|
+
return Object.freeze({
|
|
672
|
+
documentId: current.document.sha256,
|
|
673
|
+
pageIndex: slideIndex,
|
|
674
|
+
variant: [
|
|
675
|
+
CACHE_VARIANT_VERSION,
|
|
676
|
+
current.manifest.wasmSha256,
|
|
677
|
+
current.manifest.fontPack.sha256,
|
|
678
|
+
`s${scaleMilli}`,
|
|
679
|
+
`w${dimensions.width}`,
|
|
680
|
+
`h${dimensions.height}`
|
|
681
|
+
].join(':')
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function validCachedFrame(entry, dimensions) {
|
|
686
|
+
return isRecord(entry)
|
|
687
|
+
&& entry.blob instanceof Blob
|
|
688
|
+
&& entry.blob.type === 'image/png'
|
|
689
|
+
&& entry.blob.size > 0
|
|
690
|
+
&& entry.width === dimensions.width
|
|
691
|
+
&& entry.height === dimensions.height;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function renderMetadata(slideIndex, options, dimensions, cacheKey, source) {
|
|
695
|
+
return Object.freeze({
|
|
696
|
+
slideIndex,
|
|
697
|
+
width: dimensions.width,
|
|
698
|
+
height: dimensions.height,
|
|
699
|
+
scale: options.scale,
|
|
700
|
+
pixelRatio: options.pixelRatio,
|
|
701
|
+
source,
|
|
702
|
+
cacheKey
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function normalizeManifest(value) {
|
|
707
|
+
if (!isRecord(value)) throw workerError('manifest-invalid', 'init requires a manifest object.');
|
|
708
|
+
if (value.watermarkRequired !== true || value.watermarkEnforcement !== 'native-final-frame') {
|
|
709
|
+
throw workerError('manifest-policy-invalid', 'The Worker only accepts mandatory native-final-frame watermark builds.');
|
|
710
|
+
}
|
|
711
|
+
if (value.edition !== 'public-watermarked'
|
|
712
|
+
|| value.renderer !== 'native-wasm-worker-offscreen-canvas') {
|
|
713
|
+
throw workerError('manifest-policy-invalid', 'The Worker only accepts the public native OffscreenCanvas edition.');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const wasmBytes = requireAssetLength(value.wasmBytes, 'manifest.wasmBytes');
|
|
717
|
+
const wasmSha256 = requireSha256(value.wasmSha256, 'manifest.wasmSha256');
|
|
718
|
+
const fontPack = isRecord(value.fontPack) ? value.fontPack : {
|
|
719
|
+
file: value.fontPackFile,
|
|
720
|
+
bytes: value.fontPackBytes,
|
|
721
|
+
sha256: value.fontPackSha256
|
|
722
|
+
};
|
|
723
|
+
const fontBytes = requireAssetLength(fontPack.bytes, 'manifest.fontPack.bytes');
|
|
724
|
+
const fontSha256 = requireSha256(fontPack.sha256, 'manifest.fontPack.sha256');
|
|
725
|
+
return Object.freeze({
|
|
726
|
+
...value,
|
|
727
|
+
wasmBytes,
|
|
728
|
+
wasmSha256,
|
|
729
|
+
fontPack: Object.freeze({
|
|
730
|
+
file: typeof fontPack.file === 'string' ? fontPack.file : '',
|
|
731
|
+
bytes: fontBytes,
|
|
732
|
+
sha256: fontSha256
|
|
733
|
+
})
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function normalizeAssetUrl(value, label) {
|
|
738
|
+
if (!(typeof value === 'string' && value.length > 0) && !(value instanceof URL)) {
|
|
739
|
+
throw workerError('asset-url-invalid', `init requires a valid ${label}.`);
|
|
740
|
+
}
|
|
741
|
+
const url = new URL(value instanceof URL ? value.href : value, import.meta.url);
|
|
742
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
743
|
+
throw workerError('asset-url-invalid', `${label} must use HTTP or HTTPS; blob/data assets are not accepted.`);
|
|
744
|
+
}
|
|
745
|
+
return url;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function normalizeCacheOptions(value) {
|
|
749
|
+
if (value === undefined) return Object.freeze({});
|
|
750
|
+
if (!isRecord(value)) throw workerError('cache-options-invalid', 'cacheOptions must be an object when provided.');
|
|
751
|
+
const normalized = { ...value };
|
|
752
|
+
if (value.maxEntryBytes !== undefined) normalized.maxItemBytes = value.maxEntryBytes;
|
|
753
|
+
delete normalized.maxEntryBytes;
|
|
754
|
+
// `enabled: false` is intentionally preserved. createFrameCache treats it as
|
|
755
|
+
// a hard disable and never opens, reads, or writes IndexedDB.
|
|
756
|
+
return Object.freeze(normalized);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function normalizeRenderOptions(scaleValue, pixelRatioValue) {
|
|
760
|
+
const scale = finitePositive(scaleValue, 'scale');
|
|
761
|
+
const pixelRatio = finitePositive(pixelRatioValue, 'pixelRatio');
|
|
762
|
+
const nativeScale = scale * pixelRatio;
|
|
763
|
+
if (nativeScale < 0.1 || nativeScale > 8) {
|
|
764
|
+
throw workerError('render-scale-invalid', 'scale * pixelRatio must be between 0.1 and 8.');
|
|
765
|
+
}
|
|
766
|
+
return Object.freeze({ scale, pixelRatio, scaleMilli: Math.round(nativeScale * 1000) });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function finitePositive(value, label) {
|
|
770
|
+
const number = Number(value);
|
|
771
|
+
if (!Number.isFinite(number) || number <= 0) {
|
|
772
|
+
throw workerError('render-scale-invalid', `${label} must be a finite positive number.`);
|
|
773
|
+
}
|
|
774
|
+
return number;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function requireSlideIndex(value, validateRange, slideCount = 0) {
|
|
778
|
+
if (!Number.isInteger(value) || value < 0 || value > 9_999) {
|
|
779
|
+
throw workerError('slide-index-invalid', 'slideIndex must be an integer between 0 and 9999.');
|
|
780
|
+
}
|
|
781
|
+
if (validateRange && value >= slideCount) {
|
|
782
|
+
throw workerError('slide-index-invalid', `slideIndex must be between 0 and ${slideCount - 1}.`);
|
|
783
|
+
}
|
|
784
|
+
return value;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function requireAssetLength(value, label) {
|
|
788
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_ASSET_BYTES) {
|
|
789
|
+
throw workerError('manifest-length-invalid', `${label} must be an exact positive byte length no larger than ${MAX_ASSET_BYTES}.`);
|
|
790
|
+
}
|
|
791
|
+
return value;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function requireSha256(value, label) {
|
|
795
|
+
if (typeof value !== 'string' || !/^[0-9a-f]{64}$/i.test(value)) {
|
|
796
|
+
throw workerError('manifest-sha256-invalid', `${label} must be a 64-character hexadecimal SHA-256 digest.`);
|
|
797
|
+
}
|
|
798
|
+
return value.toLowerCase();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function requireRuntime() {
|
|
802
|
+
if (!runtime) throw workerError('worker-not-initialized', 'Initialize the PPT Worker before using it.');
|
|
803
|
+
return runtime;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function requireOpenDocument() {
|
|
807
|
+
const current = requireRuntime();
|
|
808
|
+
if (!current.document) throw workerError('document-not-open', 'Open a PPT document before attaching or rendering slides.');
|
|
809
|
+
return current;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function assertActivationCurrent(request, slideIndex, expectedActive) {
|
|
813
|
+
assertCurrent(request);
|
|
814
|
+
const intent = activationIntents.get(slideIndex);
|
|
815
|
+
if (!intent || intent.revision !== request.intentRevision || intent.active !== expectedActive) {
|
|
816
|
+
throw new CancelledOperation();
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function assertCurrent(request) {
|
|
821
|
+
if (!isCurrent(request)) throw new CancelledOperation();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function isCurrent(request) {
|
|
825
|
+
return request.epoch === sessionEpoch;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function abortActiveOperations() {
|
|
829
|
+
for (const controller of activeAbortControllers) controller.abort();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function resetCounters() {
|
|
833
|
+
counters.cacheHits = 0;
|
|
834
|
+
counters.cacheMisses = 0;
|
|
835
|
+
counters.cacheWrites = 0;
|
|
836
|
+
counters.nativeRenders = 0;
|
|
837
|
+
counters.cancellations = 0;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function releaseCanvasBacking(canvas) {
|
|
841
|
+
try {
|
|
842
|
+
canvas.width = 1;
|
|
843
|
+
canvas.height = 1;
|
|
844
|
+
} catch {
|
|
845
|
+
// A detached or already-disposed OffscreenCanvas has no remaining backing
|
|
846
|
+
// store to release.
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function safeCacheStats(cache) {
|
|
851
|
+
try {
|
|
852
|
+
return Object.freeze({ ...(await cache.stats()) });
|
|
853
|
+
} catch {
|
|
854
|
+
return Object.freeze({ available: false, entries: 0, totalBytes: 0 });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function sha256Hex(bytes) {
|
|
859
|
+
const subtle = requireWebCrypto();
|
|
860
|
+
const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
|
|
861
|
+
return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function requireWebCrypto() {
|
|
865
|
+
const subtle = globalThis.crypto?.subtle;
|
|
866
|
+
if (!subtle || typeof subtle.digest !== 'function') {
|
|
867
|
+
throw workerError('web-crypto-unavailable', 'Web Crypto SHA-256 support is required in the PPT Worker.');
|
|
868
|
+
}
|
|
869
|
+
return subtle;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function engineError(api, fallback) {
|
|
873
|
+
const nativeCode = Number(api.ppt_last_error_code()) || 0;
|
|
874
|
+
return workerError(`native-${nativeCode || 'unknown'}`, `${fallback} (native error ${nativeCode || 'unknown'})`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function workerError(code, message) {
|
|
878
|
+
const error = new Error(message);
|
|
879
|
+
error.name = 'FlyfishPptWorkerError';
|
|
880
|
+
Object.defineProperty(error, 'code', { value: code, enumerable: true });
|
|
881
|
+
return error;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function serializeError(error) {
|
|
885
|
+
return Object.freeze({
|
|
886
|
+
name: typeof error?.name === 'string' ? error.name : 'Error',
|
|
887
|
+
code: typeof error?.code === 'string' ? error.code : 'worker-failure',
|
|
888
|
+
message: error instanceof Error ? error.message : String(error)
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function postResponse(id, ok, payload) {
|
|
893
|
+
try {
|
|
894
|
+
workerScope.postMessage(ok
|
|
895
|
+
? { id, ok: true, result: payload }
|
|
896
|
+
: { id, ok: false, error: payload });
|
|
897
|
+
} catch {
|
|
898
|
+
// There is no secondary channel on which a failed structured-clone response
|
|
899
|
+
// can be reported. All normal results intentionally contain cloneable data.
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function isRecord(value) {
|
|
904
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function isRequestId(value) {
|
|
908
|
+
return typeof value === 'string' ? value.length > 0 : typeof value === 'number' && Number.isFinite(value);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function idKey(value) {
|
|
912
|
+
return `${typeof value}:${String(value)}`;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
class CancelledOperation extends Error {
|
|
916
|
+
constructor() {
|
|
917
|
+
super('The Worker operation was superseded by newer state.');
|
|
918
|
+
this.name = 'CancelledOperation';
|
|
919
|
+
}
|
|
920
|
+
}
|