1import type {
2 DisplayPage,
3 DisplayPrimitive,
4 GlyphRunPrimitive,
5} from "@betteroffice/docx/layout/render";
6
7/**
8 * The page as the canvas paints it. The engine sets each glyph no bundled face has, such as Thai or Devanagari ones and
9 * emoji, as a run of its own, which the canvas draws with the system's fonts. Neighbouring runs join here, so the
10 * canvas shapes their text whole and marks join their letters. A run wider than the room the engine left it shrinks
11 * into that room, rather than over the text after it.
12 */
13export function withWholeMissingGlyphs(
14 page: DisplayPage,
15 measure: OffscreenCanvasRenderingContext2D,
16): DisplayPage {
17 const regions = [
18 page.primitives,
19 page.header?.primitives,
20 page.footer?.primitives,
21 ...(page.noteAreas ?? []).map((area) => area.primitives),
22 ];
23
24 if (!regions.some((primitives) => primitives?.some(missing))) return page;
25
26 const join = (primitives: readonly DisplayPrimitive[]) =>
27 primitives
28 .reduce<DisplayPrimitive[]>((joined, primitive) => {
29 const previous = joined.at(-1);
30
31 if (missing(previous) && missing(primitive) && continues(previous, primitive))
32 joined[joined.length - 1] = {
33 ...previous,
34 text: previous.text + primitive.text,
35 glyphs: [...previous.glyphs, ...shifted(primitive.glyphs, previous.text)],
36 ...(primitive.docEnd === undefined ? {} : { docEnd: primitive.docEnd }),
37 };
38 else joined.push(primitive);
39
40 return joined;
41 }, [])
42 .map((primitive) => (missing(primitive) ? fitted(primitive, measure) : primitive));
43
44 // Watermark primitives lead the body and count apart, so they join apart.
45 const marks = join(page.primitives.slice(0, page.watermarkPrimitiveCount ?? 0));
46
47 return {
48 ...page,
49 primitives: [...marks, ...join(page.primitives.slice(page.watermarkPrimitiveCount ?? 0))],
50 ...(page.watermarkPrimitiveCount === undefined
51 ? {}
52 : { watermarkPrimitiveCount: marks.length }),
53 ...(page.header
54 ? { header: { ...page.header, primitives: join(page.header.primitives) } }
55 : {}),
56 ...(page.footer
57 ? { footer: { ...page.footer, primitives: join(page.footer.primitives) } }
58 : {}),
59 ...(page.noteAreas
60 ? {
61 noteAreas: page.noteAreas.map((area) =>
62 area.primitives ? { ...area, primitives: join(area.primitives) } : area,
63 ),
64 }
65 : {}),
66 };
67}
68
69function missing(primitive: DisplayPrimitive | undefined): primitive is GlyphRunPrimitive {
70 return (
71 primitive?.kind === "glyphRun" && !primitive.rtl && primitive.glyphs.some((glyph) => !glyph.id)
72 );
73}
74
75/** Neighbours in the document on one baseline whose runs differ only in their text and where it sits. */
76function continues(previous: GlyphRunPrimitive, next: GlyphRunPrimitive) {
77 return (
78 previous.docEnd !== undefined &&
79 previous.docEnd === next.docStart &&
80 previous.glyphs[0]?.y === next.glyphs[0]?.y &&
81 style(previous) === style(next)
82 );
83}
84
85function style(run: GlyphRunPrimitive) {
86 return JSON.stringify({
87 ...run,
88 text: undefined,
89 glyphs: undefined,
90 docStart: undefined,
91 docEnd: undefined,
92 logicalOrder: undefined,
93 });
94}
95
96/** Glyphs whose clusters, UTF-8 offsets into their run's text, now follow `before`. */
97function shifted(glyphs: GlyphRunPrimitive["glyphs"], before: string) {
98 const offset = new TextEncoder().encode(before).length;
99
100 return glyphs.map((glyph) => ({ ...glyph, cluster: glyph.cluster + offset }));
101}
102
103function fitted(run: GlyphRunPrimitive, measure: OffscreenCanvasRenderingContext2D) {
104 const first = run.glyphs[0];
105 const last = run.glyphs.at(-1);
106
107 if (!first || !last) return run;
108
109 const room = last.x + (last.advance ?? 0) - first.x;
110 const font = run.fallbackFont ?? `${run.size}px sans-serif`;
111
112 measure.font = font;
113
114 const width = measure.measureText(run.text).width;
115
116 if (width <= room || room <= 0) return run;
117
118 // Smaller rather than narrower, so emoji keep their shape.
119 return {
120 ...run,
121 fallbackFont: font.replace(
122 /(\d+(?:\.\d+)?)px/u,
123 (_, size) => `${(Number(size) * room) / width}px`,
124 ),
125 };
126}