Skip to content
Source
Sign inSign up
Open History to see the last commit

word-document.ts

430 lines15.2 KB

1// Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode.
2import * as Schema from "effect/Schema";
3import {
4 buildResidentRegionLayoutRequest,
5 getLayoutKernelInputs,
6 workerLayoutComputation,
7} from "@betteroffice/docx/editor";
8import { createRustMeasureSource } from "@betteroffice/docx/layout";
9import {
10 applyFrameDelta,
11 decodeFrameDelta,
12 encodeDisplayListFrameExtras,
13 GlyphCache,
14 rasterizeDisplayPageToBackBuffer,
15 type DisplayPage,
16 type RetainedFrame,
17} from "@betteroffice/docx/layout/render";
18import {
19 createYrsPositionProjection,
20 createYrsSession,
21 type YrsPositionProjection,
22} from "@betteroffice/docx/yrs";
23import { loadFace, sheetFace, wordFonts } from "./fonts";
24import { maxImageSide, type VectorBitmap } from "./vector-pictures";
25import { withWholeMissingGlyphs } from "./word-glyphs";
26import { createBitmapImageResolver } from "./word-images";
27import { layerFont, type WordPageSize, type WordSpan } from "./word-protocol";
28import { pageText, positionTarget, type TextMeasure } from "./word-text";
29​
30// An open Word document: its engine session, fonts, layout, built pages and painting. The document worker holds one.
31​
32const FontRequirements = Schema.fromJsonString(
33 Schema.Array(
34 Schema.Struct({
35 key: Schema.String,
36 family: Schema.String,
37 bold: Schema.Boolean,
38 italic: Schema.Boolean,
39 scripts: Schema.optional(
40 Schema.Array(Schema.Literals(["cjk-sc", "cjk-tc", "cjk-jp", "cjk-kr", "arabic", "hebrew"])),
41 ),
42 }),
43 ),
44);
45​
46const decodeFontRequirements = Schema.decodeUnknownSync(FontRequirements);
47​
48/** A prefix layout's reply marks itself provisional when the document goes on past it. */
49const decodeLayoutReply = Schema.decodeUnknownSync(
50 Schema.fromJsonString(Schema.Struct({ provisional: Schema.optional(Schema.Boolean) })),
51);
52​
53/** Pages laid out and built before the first paint; later pages are built as they near the viewport. */
54const firstPages = 3;
55​
56/** How long one layout step may hold back a paint that waits behind it, in milliseconds. */
57const stepBudget = 24;
58​
59/**
60 * How many pages either side of the page painted last stay built. A built page holds about 5 MB in the engine and
61 * builds again in about 100 ms. This keeps every page near the viewport, which paints a viewport ahead in both
62 * directions, and lets a reader scroll back a few screens without waiting, while a long read holds at most 21 pages.
63 */
64const keptAround = 10;
65​
66/** A value built on first use. */
67type Lazy<T> = { value: T | undefined };
68​
69/**
70 * Opens the document and lays out its first pages; the rest lay out a `step` at a time. `fonts` is the font set the
71 * painting canvas draws text with, if it has one.
72 */
73export async function openWordDocument(bytes: Uint8Array, fonts: FontFaceSet | undefined) {
74 const session = await createYrsSession();
75 const document = session.openDocx(bytes, true, { mediaTokens: true }).document;
76 const settings = document.package.settings;
77​
78 const measure = createRustMeasureSource({
79 engine: {
80 registerFont: (font) => session.registerFont(font),
81 registerSubstituteFont: (id, family) => session.registerSubstituteFont(id, family),
82 clearFonts: () => session.clearFonts(),
83 },
84 bundled: wordFonts(),
85 });
86​
87 measure.setCompat(settings?.compatibilityFlags);
88​
89 const request = buildResidentRegionLayoutRequest(document, 0, {
90 themeColors: Object.fromEntries(
91 Object.entries(document.package.theme?.colorScheme ?? {}).filter(
92 (entry): entry is [string, string] => entry[1] !== undefined,
93 ),
94 ),
95 defaultTabStopTwips: settings?.defaultTabStop ?? null,
96 numericIds: {},
97 mediaTokens: true,
98 });
99​
100 const requirements = decodeFontRequirements(
101 session.layoutFontRequirementsJson(JSON.stringify(request)),
102 ).map(({ scripts, ...requirement }) => ({
103 ...requirement,
104 ...(scripts ? { scripts: [...scripts] } : {}),
105 }));
106​
107 await Promise.all([
108 measure.prepareFontRequirements(requirements),
109 fonts && addCanvasFaces(fonts, requirements),
110 ]);
111​
112 const measurement = measure.measurementConfigForRequirements(requirements);
113​
114 if (!measurement) throw new Error("The document's fonts could not be prepared");
115​
116 const input = JSON.stringify({ ...request, measurement });
117​
118 // Word shows resolved comments without the active highlight.
119 const resolvedCommentIds = (document.package.document.comments ?? [])
120 .filter(
121 (comment) =>
122 (comment.done || comment.status === "resolved") && comment.parentId === undefined,
123 )
124 .map((comment) => comment.id);
125​
126 // The session keeps the layout it last ran and builds display frames from it: only the display extras go in, and a
127 // binary frame comes back with the window's pages built and every other page a placeholder carrying its geometry.
128 const display = (previous: RetainedFrame | null, reply: string) => {
129 const layout = workerLayoutComputation(reply).layout;
130​
131 session.setDisplayWindow(0, firstPages);
132​
133 const extras = encodeDisplayListFrameExtras({
134 measured: [],
135 options: {},
136 layout,
137 fontChains: measurement.fontChains,
138 ...(getLayoutKernelInputs(layout)?.headersFooters
139 ? { headersFooters: getLayoutKernelInputs(layout)!.headersFooters! }
140 : {}),
141 resolvedCommentIds,
142 });
143​
144 return applyFrameDelta(
145 previous,
146 decodeFrameDelta(session.buildDisplayListFrame(extras, previous?.frameEpoch ?? 0)),
147 );
148 };
149​
150 // Only as much of the body as fills the first pages; a long document goes on past it.
151 const prefix = session.layoutDocumentWithRegionsPrefixRetainedJson(input, firstPages);
152 const retained = { frame: display(null, prefix) };
153​
154 if (retained.frame.displayList.pages.length === 0)
155 throw new Error("The document produced no pages");
156​
157 // The region layout that finishes the document: begun on the first step, `cost` the running average milliseconds a
158 // body block takes.
159 const layout = { loading: decodeLayoutReply(prefix).provisional ?? false, begun: false, cost: 3 };
160​
161 // Builds a page's content when it is needed and not built, the first time or again after `release`.
162 const built = (index: number) => {
163 const page = retained.frame.displayList.pages[index];
164​
165 if (!page?.unbuilt) return page;
166​
167 retained.frame = applyFrameDelta(
168 retained.frame,
169 decodeFrameDelta(session.buildDisplayPagesFrame([index], retained.frame.frameEpoch)),
170 );
171​
172 return retained.frame.displayList.pages[index];
173 };
174​
175 // Gives back the built pages far from the page about to paint, so a long read keeps only the pages around the
176 // viewport. A released page keeps its geometry and document positions, which bookmarks search, and builds again
177 // when it is needed.
178 const release = (painted: number) => {
179 // The engine releases pages of the whole document's layout only, and few pages show before it.
180 if (layout.loading) return;
181​
182 const far = retained.frame.displayList.pages.flatMap((page, index) =>
183 !page.unbuilt && Math.abs(painted - index) > keptAround ? [index] : [],
184 );
185​
186 if (far.length === 0) return;
187​
188 const frame = session.releaseDisplayPagesFrame(far, retained.frame.frameEpoch);
189​
190 // A superseded release changes nothing; the next one tries again.
191 if (frame) retained.frame = applyFrameDelta(retained.frame, decodeFrameDelta(frame));
192 };
193​
194 // The whole document's layout replaces the prefix's frame.
195 const complete = (reply: string) => {
196 const before = retained.frame.displayList.pages;
197​
198 retained.frame = display(retained.frame, reply);
199 layout.loading = false;
200​
201 const pages = retained.frame.displayList.pages;
202​
203 return {
204 done: true as const,
205 pages: pages.map(pageSize),
206 kept: pages.map((page, index) => {
207 const was = before[index];
208​
209 return was === page || (!!was?.unbuilt && !!page.unbuilt);
210 }),
211 };
212 };
213​
214 const glyphCache = new GlyphCache({
215 // The engine sets glyphs no bundled face has, such as Thai ones or emoji, as missing glyphs, which outline as empty
216 // boxes. A run whose outlines fail paints whole with the canvas's fonts instead, which the system's cover.
217 provider: (font, glyph) => {
218 if (glyph === 0) throw new Error("No bundled face has this glyph");
219​
220 return session.outlineGlyphJson(font, glyph);
221 },
222 });
223 const images = createBitmapImageResolver((token) => session.mediaSource(token));
224 const surface = new OffscreenCanvas(1, 1);
225 const fitting = new OffscreenCanvas(1, 1).getContext("2d");
226 const painted = new WeakMap<DisplayPage, DisplayPage>();
227 const textMeasure = layerMeasure();
228 const texts = new WeakMap<DisplayPage, readonly WordSpan[]>();
229 // Built on the first followed bookmark: most documents are read without one.
230 const projection: Lazy<YrsPositionProjection | null> = { value: undefined };
231​
232 return {
233 pages: () => retained.frame.displayList.pages,
234 loading: () => layout.loading,
235 built,
236 /**
237 * Lays out about `stepBudget` milliseconds more of the body. Blocks vary widely, and sizing a step by the last one
238 * alone overshoots after a run of cheap blocks.
239 */
240 step: () => {
241 if (!layout.loading) {
242 const pages = retained.frame.displayList.pages;
243​
244 return { done: true as const, pages: pages.map(pageSize), kept: pages.map(() => true) };
245 }
246​
247 if (!layout.begun) {
248 layout.begun = true;
249​
250 const begun = session.beginRegionLayout(input);
251​
252 return begun.layoutJson === undefined
253 ? { done: false as const }
254 : complete(begun.layoutJson);
255 }
256​
257 const blocks = Math.min(8192, Math.max(1, Math.round(stepBudget / layout.cost)));
258 const started = performance.now();
259 const progress = session.resumeRegionLayout(blocks);
260​
261 layout.cost = layout.cost * 0.7 + ((performance.now() - started) / blocks) * 0.3;
262​
263 return progress.layoutJson === undefined
264 ? { done: false as const }
265 : complete(progress.layoutJson);
266 },
267 paint: async (index: number, scale: number, ratio: number, signal: AbortSignal) => {
268 // A page that scrolled away before its paint began, or while it rasterized, needs no bitmap.
269 if (signal.aborted) return undefined;
270​
271 // Released first, so the page builds in the memory they free.
272 release(index);
273​
274 const page = built(index);
275​
276 if (!page) return undefined;
277​
278 const paintable =
279 painted.get(page) ?? (fitting ? withWholeMissingGlyphs(page, fitting) : page);
280​
281 painted.set(page, paintable);
282​
283 // The window drew image elements, which shrink smoothly at the default quality; a bitmap needs medium quality to
284 // look the same. Sizing the surface first keeps the setting: the painter resizes it, which resets the context,
285 // only when its size differs.
286 const context = surface.getContext("2d");
287 const width = Math.ceil(page.width * ratio * scale);
288 const height = Math.ceil(page.height * ratio * scale);
289​
290 if (surface.width !== width) surface.width = width;
291​
292 if (surface.height !== height) surface.height = height;
293​
294 if (context) context.imageSmoothingQuality = "medium";
295​
296 await rasterizeDisplayPageToBackBuffer(
297 surface,
298 paintable,
299 { glyphCache, resolveImage: images.resolve },
300 ratio,
301 scale,
302 );
303​
304 const vectors = images.vectors(pictureSizes(paintable, ratio * scale));
305​
306 images.trim();
307​
308 if (signal.aborted) return undefined;
309​
310 return { bitmap: surface.transferToImageBitmap(), vectors };
311 },
312 vectors: (bitmaps: readonly VectorBitmap[]) => images.provide(bitmaps),
313 text: (index: number) => {
314 const page = built(index);
315​
316 if (!page || !textMeasure) return [];
317​
318 const cached = texts.get(page);
319​
320 if (cached) return cached;
321​
322 const spans = pageText(page, textMeasure);
323​
324 texts.set(page, spans);
325​
326 return spans;
327 },
328 target: (name: string) => {
329 projection.value ??= createYrsPositionProjection(session, "body");
330​
331 const position = projection.value?.bookmarkPosition(name) ?? undefined;
332​
333 if (position !== undefined)
334 return positionTarget(retained.frame.displayList.pages, position, built);
335​
336 // Word's reserved bookmark for the start of the document.
337 return name === "_top" ? { index: 0, y: 0 } : undefined;
338 },
339 };
341​
342/**
343 * The canvas draws list numbers, and runs no bundled face has glyphs for, with its own fonts, by their Word family.
344 * Adding the document's faces under those names draws them like the rest of its text. A family the app ships no face
345 * for stays with the system's, and CJK faces, tens of megabytes each, are not held twice.
346 */
347async function addCanvasFaces(
348 fonts: FontFaceSet,
349 requirements: readonly { family: string; bold: boolean; italic: boolean }[],
350) {
351 const faces = new Map<string, Promise<FontFace>>();
352​
353 for (const { family, bold, italic } of requirements) {
354 const face = sheetFace(family, bold, italic);
355​
356 if (!face || face.script?.startsWith("cjk-")) continue;
357​
358 const key = `${family}\u0000${face.weight}\u0000${face.style}`;
359​
360 if (faces.has(key)) continue;
361​
362 // A family without the requested style gets the face it has, which the browser then slants or emboldens.
363 faces.set(
364 key,
365 loadFace(face).then((source) =>
366 new FontFace(family, source, {
367 weight: face.weight === 700 ? "700" : "400",
368 style: face.style === "italic" ? "italic" : "normal",
369 }).load(),
370 ),
371 );
372 }
373​
374 await Promise.all(
375 [...faces.values()].map((face) =>
376 face.then((loaded) => void fonts.add(loaded)).catch(() => undefined),
377 ),
378 );
380​
381/** Each picture's drawn size on a page in device pixels at `density`, up to `maxImageSide`. */
382function pictureSizes(page: DisplayPage, density: number) {
383 const sizes = new Map<string, { width: number; height: number }>();
384 const primitives = [
385 ...page.primitives,
386 ...(page.header?.primitives ?? []),
387 ...(page.footer?.primitives ?? []),
388 ...(page.noteAreas ?? []).flatMap((area) => area.primitives ?? []),
389 ];
390​
391 for (const primitive of primitives) {
392 if (primitive.kind !== "image") continue;
393​
394 const width = (primitive.contentFrame?.w ?? primitive.w) * density;
395 const height = (primitive.contentFrame?.h ?? primitive.h) * density;
396 const shrink = Math.min(1, maxImageSide / Math.max(width, height));
397 const previous = sizes.get(primitive.relId);
398​
399 sizes.set(primitive.relId, {
400 width: Math.max(previous?.width ?? 1, Math.round(width * shrink)),
401 height: Math.max(previous?.height ?? 1, Math.round(height * shrink)),
402 });
403 }
404​
405 return sizes;
407​
408export function pageSize(page: DisplayPage): WordPageSize {
409 return { width: page.width, height: page.height, background: page.background };
411​
412/** Measures the text layer's font with a detached canvas; undefined where the worker offers no 2D context. */
413function layerMeasure(): TextMeasure | undefined {
414 const context = new OffscreenCanvas(1, 1).getContext("2d");
415​
416 if (!context) return undefined;
417​
418 // Measured large and scaled down, so rounding stays well below a pixel at any zoom.
419 const size = 100;
420​
421 context.font = `${size}px ${layerFont}`;
422​
423 const metrics = context.measureText("Hg");
424​
425 return {
426 width: (text) => context.measureText(text).width / size,
427 ascent: metrics.fontBoundingBoxAscent / size,
428 descent: metrics.fontBoundingBoxDescent / size,
429 };