Open History to see the last commit
1// Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode.
2import {
3 initWasm,
4 needsElementDecode,
5 openPresentation,
6 paintSlide,
7 presentationImageBlob,
8 type PresentationHandle,
9 type SlideDisplayList,
10 type SlidePrimitive,
11} from "@betteroffice/pptx";
12import { slideFonts } from "./slide-fonts";
13import type { SlideMethods, SlidePainted, SlideSummary } from "./slide-protocol";
14import { maxImageSide, type VectorBitmap, type VectorRequest } from "./vector-pictures";
15import { deckText } from "./slide-text";
16import { officeImageBitmap } from "./image-bitmap";
17import { serveWorker, Transfer } from "./worker-rpc";
18
19// One worker holds one open PowerPoint deck: its engine, fonts, laid-out slides, decoded pictures and painting. The
20// window terminates the worker when the deck closes, which is the only way the engine's memory is given back.
21
22/** English Metric Units per CSS pixel. */
23const emuPerPixel = 9525;
24
25/** The most device pixels one slide's bitmap holds, about a 4K screen: a zoomed slide stays sharp up to it. */
26const maxBitmapPixels = 3840 * 2160;
27
28/** The longest side a canvas takes in every browser. */
29const maxBitmapSide = 16384;
30
31/**
32 * Shadow pixels a slide may blur, per pixel of its bitmap: sixteen slide-sized shadows before they are dropped. Past
33 * it a slide gives up its shadows within about half a second, rather than painting for many seconds.
34 */
35const shadowPixelsPerPixel = 16;
36
37/**
38 * How many bytes of decoded pictures stay for repaints; the least recently drawn go first past it. A deck of camera
39 * photos holds about 17 MB per picture, even scaled down to `maxImageSide`.
40 */
41const pictureBudget = 128 * 1024 * 1024;
42
43type Size = { readonly width: number; readonly height: number };
44
45/**
46 * A picture as the painter draws it: a bitmap, an SVG that the window rasterizes (a worker has no SVG decoder) with
47 * the bitmap it last returned, or nothing when the browser cannot decode it.
48 */
49type Picture =
50 | { readonly kind: "bitmap"; readonly bitmap: ImageBitmap; readonly size: Size }
51 | {
52 readonly kind: "vector";
53 readonly blob: Blob;
54 bitmap: ImageBitmap | undefined;
55 failed: boolean;
56 }
57 | { readonly kind: "missing" };
58
59type Deck = ReturnType<typeof createDeck>;
60
61/** The deck this worker opened, once it has. */
62type WorkerState = { deck: Deck | undefined };
63
64const state: WorkerState = { deck: undefined };
65
66serveWorker<SlideMethods>({
67 open: async (input) => {
68 if (state.deck) throw new Error("The worker already holds a deck");
69
70 await initWasm();
71
72 const bytes = new Uint8Array(input.bytes);
73 const handle = openPresentation(bytes);
74 const snapshot = handle.snapshot();
75 const text = deckText(bytes, snapshot);
76 const fonts = slideFonts(handle, workerFonts());
77
78 await fonts.load(text);
79
80 state.deck = createDeck(handle, fonts.layout);
81
82 const size = {
83 width: snapshot.widthEmu / emuPerPixel,
84 height: snapshot.heightEmu / emuPerPixel,
85 };
86
87 return snapshot.slides.map((slide, index): SlideSummary => ({
88 ...size,
89 hidden: text.hidden[index] ?? false,
90 notes: slide.notes?.trim() || undefined,
91 }));
92 },
93 paint: async (input, signal) => {
94 const deck = required();
95 const painted = await deck.paint(input.index, input.density, signal).finally(deck.trim);
96
97 return painted.kind === "painted" ? new Transfer(painted, [painted.bitmap]) : painted;
98 },
99 vectors: (input) => required().vectors(input),
100});
101
102function required() {
103 if (!state.deck) throw new Error("The worker holds no deck");
104
105 return state.deck;
106}
107
108/** The worker's own font set, which its canvases draw text with. */
109function workerFonts() {
110 if (!("fonts" in self)) throw new Error("This browser has no fonts in workers");
111
112 // SAFETY: a worker's global scope is a FontFaceSource, whose `fonts` is a FontFaceSet (CSS Font Loading, exposed to
113 // workers). The window's types declare it on documents only, and workers expose no FontFaceSet to check against.
114 return self.fonts as FontFaceSet;
115}
116
117function createDeck(
118 handle: PresentationHandle,
119 layout: (index: number) => Promise<SlideDisplayList>,
120) {
121 // A frame depends on the slide alone, so a new scale or pixel ratio paints the frame laid out before.
122 const frames = new Map<number, Promise<SlideDisplayList | undefined>>();
123 const pictures = new Map<string, Promise<Picture>>();
124 // The decoded bitmaps, least recently drawn first, which `trim` keeps within `pictureBudget`.
125 const drawn = new Map<string, ImageBitmap>();
126 const surface = new OffscreenCanvas(1, 1);
127
128 const frame = (index: number) => {
129 const cached = frames.get(index);
130
131 if (cached) return cached;
132
133 const laid = layout(index).catch(() => undefined);
134
135 frames.set(index, laid);
136
137 return laid;
138 };
139
140 const decode = async (asset: string): Promise<Picture> => {
141 const blob = presentationImageBlob(handle.mediaBytes(asset));
142
143 // SVG decodes through an image element, which a worker lacks.
144 if (needsElementDecode(blob)) return { kind: "vector", blob, bitmap: undefined, failed: false };
145
146 const source = await officeImageBitmap(blob).catch(() => undefined);
147
148 if (!source) return { kind: "missing" };
149 const size = { width: source.width, height: source.height };
150 return { kind: "bitmap", bitmap: await bounded(source), size };
151 };
152
153 const picture = (asset: string) => {
154 const cached = pictures.get(asset);
155
156 if (cached) return cached;
157
158 const decoded = decode(asset).catch((): Picture => ({ kind: "missing" }));
159
160 pictures.set(asset, decoded);
161
162 return decoded;
163 };
164
165 const tileScale = async (primitive: SlidePrimitive): Promise<SlidePrimitive> => {
166 if (primitive.kind === "table" || primitive.kind === "chart") {
167 const primitives: SlidePrimitive[] = [];
168 for (const child of primitive.primitives) primitives.push(await tileScale(child));
169 return { ...primitive, primitives };
170 }
171 if (primitive.kind !== "image" || !primitive.tile || !primitive.assetId) return primitive;
172 const found = await picture(primitive.assetId);
173 if (found.kind !== "bitmap") return primitive;
174 return {
175 ...primitive,
176 tile: {
177 ...primitive.tile,
178 scaleX: primitive.tile.scaleX * (found.size.width / found.bitmap.width),
179 scaleY: primitive.tile.scaleY * (found.size.height / found.bitmap.height),
180 },
181 };
182 };
183
184 return {
185 paint: async (index: number, density: number, signal: AbortSignal): Promise<SlidePainted> => {
186 const laid = await frame(index);
187
188 if (!laid) return { kind: "failed" };
189
190 if (signal.aborted) return { kind: "cancelled" };
191
192 const scale = Math.min(
193 density,
194 Math.sqrt(maxBitmapPixels / (laid.width * laid.height)),
195 maxBitmapSide / Math.max(laid.width, laid.height),
196 );
197
198 // One canvas paints every slide: a paint that was withdrawn leaves its pixels for the next to clear, rather than a
199 // slide-sized canvas each until the worker collects garbage. A reset context starts as a new canvas's does.
200 const canvas = surface;
201 const width = Math.max(1, Math.round(laid.width * scale));
202 const height = Math.max(1, Math.round(laid.height * scale));
203
204 if (canvas.width !== width) canvas.width = width;
205
206 if (canvas.height !== height) canvas.height = height;
207
208 const context = canvas.getContext("2d");
209
210 if (!context) return { kind: "failed" };
211
212 context.reset();
213
214 const needs = vectorSizes(laid.primitives, scale);
215 const wanted = new Map<string, VectorRequest>();
216
217 const resolveImage = async (asset: string) => {
218 // A paint that was withdrawn finishes without pictures, and its reply is dropped.
219 if (signal.aborted) return null;
220
221 const found = await picture(asset);
222
223 if (found.kind === "bitmap") {
224 drawn.delete(asset);
225 drawn.set(asset, found.bitmap);
226
227 return found.bitmap;
228 }
229
230 if (found.kind === "missing" || found.failed) return null;
231
232 const need = needs.get(asset);
233
234 if (!covers(found.bitmap, need)) wanted.set(asset, { asset, blob: found.blob, size: need });
235
236 if (found.bitmap) {
237 drawn.delete(asset);
238 drawn.set(asset, found.bitmap);
239 }
240
241 return found.bitmap ?? null;
242 };
243
244 const options = {
245 resolveImage,
246 maxShadowPixels: canvas.width * canvas.height * shadowPixelsPerPixel,
247 };
248
249 const attempt = (list: SlideDisplayList) =>
250 // SAFETY: the painter draws through the members a worker's 2D context shares with the window's, and makes its
251 // scratch canvases with OffscreenCanvas wherever it exists, as it does in a worker. Only the window's context
252 // type is declared, and it has two focus and attribute members the worker's lacks, hence the double cast.
253 paintSlide(context as unknown as CanvasRenderingContext2D, list, scale, 1, options).then(
254 () => true,
255 () => false,
256 );
257
258 const primitives: SlidePrimitive[] = [];
259 for (const primitive of laid.primitives) primitives.push(await tileScale(primitive));
260 const scaled = { ...laid, primitives };
261 const shadowed = await attempt(scaled);
262
263 // Past its shadow budget a slide throws, and paints again without shadows rather than not at all. It first lets
264 // through a cancellation that arrived meanwhile, such as a scroll that moved the slide away.
265 if (!shadowed) await new Promise((resolve) => setTimeout(resolve));
266
267 if (signal.aborted) return { kind: "cancelled" };
268
269 const done = shadowed || (await attempt(withoutShadows(scaled)));
270
271 if (!done) return { kind: "failed" };
272
273 if (signal.aborted) return { kind: "cancelled" };
274
275 return {
276 kind: "painted",
277 bitmap: canvas.transferToImageBitmap(),
278 vectors: [...wanted.values()],
279 };
280 },
281
282 /** Closes the least recently drawn pictures past the budget; they decode again when drawn. Call it between paints. */
283 trim: () => {
284 [...drawn].reduce(
285 (total, [asset, bitmap]) => {
286 if (total <= pictureBudget) return total;
287
288 const freed = pixelBytes(bitmap);
289
290 bitmap.close();
291 drawn.delete(asset);
292 pictures.delete(asset);
293
294 return total - freed;
295 },
296 [...drawn.values()].reduce((total, bitmap) => total + pixelBytes(bitmap), 0),
297 );
298 },
299
300 /**
301 * Keeps the window's rasterized SVG pictures in place of coarser ones. A finer one that a paint at a larger scale
302 * returned stays, and so does any one when the window could not rasterize the picture again.
303 */
304 vectors: (bitmaps: readonly VectorBitmap[]) =>
305 Promise.all(
306 bitmaps.map(async (entry) => {
307 const found = await picture(entry.asset);
308
309 if (found.kind !== "vector") return entry.bitmap?.close();
310
311 if (found.bitmap && (!entry.bitmap || covers(found.bitmap, entry.bitmap)))
312 return entry.bitmap?.close();
313
314 found.bitmap?.close();
315 drawn.delete(entry.asset);
316 found.bitmap = entry.bitmap;
317 found.failed = !entry.bitmap;
318 if (entry.bitmap) drawn.set(entry.asset, entry.bitmap);
319 }),
320 ).then(() => undefined),
321 };
322}
323
324/**
325 * The bitmap size each SVG picture on a slide needs at `scale`: its drawn size in device pixels, before cropping and
326 * up to `maxImageSide`. A tiled picture repeats at its own size, so it needs exactly that (undefined).
327 */
328function vectorSizes(primitives: readonly SlidePrimitive[], scale: number) {
329 const sizes = new Map<string, Size | undefined>();
330
331 const visit = (primitive: SlidePrimitive): void => {
332 if (primitive.kind === "table" || primitive.kind === "chart")
333 return primitive.primitives.forEach(visit);
334
335 if (primitive.kind !== "image" || !primitive.assetId) return;
336
337 const asset = primitive.assetId;
338 const previous = sizes.get(asset);
339
340 if (primitive.tile || (sizes.has(asset) && !previous)) return void sizes.set(asset, undefined);
341
342 const visibleX = 1 - fraction(primitive.crop?.left) - fraction(primitive.crop?.right);
343 const visibleY = 1 - fraction(primitive.crop?.top) - fraction(primitive.crop?.bottom);
344
345 if (visibleX <= 0 || visibleY <= 0) return;
346
347 const width = Math.max(previous?.width ?? 0, (primitive.w * scale) / visibleX);
348 const height = Math.max(previous?.height ?? 0, (primitive.h * scale) / visibleY);
349 const shrink = Math.min(1, maxImageSide / Math.max(width, height));
350
351 sizes.set(asset, {
352 width: Math.max(1, Math.ceil(width * shrink)),
353 height: Math.max(1, Math.ceil(height * shrink)),
354 });
355 };
356
357 primitives.forEach(visit);
358
359 return sizes;
360}
361
362function pixelBytes(bitmap: ImageBitmap) {
363 return bitmap.width * bitmap.height * 4;
364}
365
366/** Whether a rasterized SVG picture is as fine as `need`; undefined needs the picture's own size, rasterized once. */
367function covers(bitmap: ImageBitmap | undefined, need: Size | undefined) {
368 if (!bitmap) return false;
369
370 if (!need) return true;
371
372 return bitmap.width >= need.width && bitmap.height >= need.height;
373}
374
375/** A crop fraction as the painter reads it. */
376function fraction(value: number | undefined) {
377 return value === undefined || !Number.isFinite(value) ? 0 : Math.min(Math.max(value, 0), 1);
378}
379
380function withoutShadows(frame: SlideDisplayList): SlideDisplayList {
381 const strip = (primitive: SlidePrimitive): SlidePrimitive => {
382 if (primitive.kind === "shape" || primitive.kind === "image") {
383 const { shadow: _shadow, ...rest } = primitive;
384 return rest;
385 }
386
387 if (primitive.kind === "textBox") {
388 const { textShadow: _shadow, ...rest } = primitive;
389 return rest;
390 }
391
392 if (primitive.kind === "table" || primitive.kind === "chart")
393 return { ...primitive, primitives: primitive.primitives.map(strip) };
394
395 return primitive;
396 };
397
398 return { ...frame, primitives: frame.primitives.map(strip) };
399}
400
401/**
402 * The picture scaled down to `maxImageSide` when it is larger. Decks of camera photos would otherwise hold about 100 MB
403 * per decoded picture for as long as the deck is open.
404 */
405async function bounded(source: ImageBitmap) {
406 const shrink = maxImageSide / Math.max(source.width, source.height);
407
408 if (shrink >= 1) return source;
409
410 return createImageBitmap(source, {
411 resizeWidth: Math.max(1, Math.round(source.width * shrink)),
412 resizeHeight: Math.max(1, Math.round(source.height * shrink)),
413 resizeQuality: "high",
414 }).then(
415 (resized) => {
416 source.close();
417
418 return resized;
419 },
420 () => source,
421 );
422}