Open History to see the last commit
1// Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode.
2import {
3 fontSizePxFromShorthand,
4 glyphRunRect,
5 textRunRect,
6 type DisplayPage,
7 type DisplayPrimitive,
8 type GeoRect,
9 type GlyphRunPrimitive,
10 type TextRunPrimitive,
11} from "@betteroffice/docx/layout/render";
12import { wordLink } from "./links";
13import { lineHeight, type WordSpan } from "./word-protocol";
14
15/** Measures text in the layer's font. */
16export type TextMeasure = {
17 /** The natural width of `text` at a font size of 1. */
18 readonly width: (text: string) => number;
19 /** The font's ascent and descent, at a font size of 1. */
20 readonly ascent: number;
21 readonly descent: number;
22};
23
24/**
25 * Builds a page's text layer from its display list: its header, body, notes and footer in reading order. Each glyph
26 * run's text sits over its painted glyphs, merged with its neighbours on the line while their text runs on, so
27 * selection, find and screen readers see whole words and lines.
28 */
29export function pageText(page: DisplayPage, measure: TextMeasure): WordSpan[] {
30 const body = page.primitives.slice(
31 Math.min(page.watermarkPrimitiveCount ?? 0, page.primitives.length),
32 );
33 const notes = (page.noteAreas ?? []).map((area) => area.primitives ?? []);
34
35 return [page.header?.primitives ?? [], body, ...notes, page.footer?.primitives ?? []].flatMap(
36 (region) => regionText(region, page, measure),
37 );
38}
39
40/**
41 * The page and height of the first body text at or after a document position, such as a bookmark's: the heading a
42 * table of contents entry points to. Undefined when no text follows it. A page whose content is not built yet is
43 * built through `build` only when its body reaches the position.
44 */
45export function positionTarget(
46 pages: readonly DisplayPage[],
47 position: number,
48 build: (index: number) => DisplayPage | undefined,
49) {
50 for (const [index, listed] of pages.entries()) {
51 const page = listed.unbuilt
52 ? (listed.positionSpan?.[1] ?? -1) >= position
53 ? build(index)
54 : undefined
55 : listed;
56 const first =
57 page && bodyAfter(page, position).toSorted((left, right) => left.start - right.start)[0];
58
59 if (first) return { index, y: first.y };
60 }
61
62 return undefined;
63}
64
65function bodyAfter(page: DisplayPage, position: number) {
66 return page.primitives.flatMap((primitive) => {
67 const piece = toPiece(primitive);
68
69 return piece?.start !== undefined && piece.start >= position
70 ? [{ start: piece.start, y: piece.rect.y }]
71 : [];
72 });
73}
74
75type Piece = {
76 readonly text: string;
77 readonly rect: GeoRect;
78 readonly baseline: number;
79 readonly size: number;
80 readonly dir: "ltr" | "rtl";
81 readonly href: string | undefined;
82 readonly title: string | undefined;
83 readonly rotation: number;
84 readonly line: string;
85 /** A list item's number or bullet, which Word follows with a tab. */
86 readonly marker: boolean;
87 readonly start: number | undefined;
88 readonly end: number | undefined;
89};
90
91function regionText(
92 primitives: readonly DisplayPrimitive[],
93 page: DisplayPage,
94 measure: TextMeasure,
95): WordSpan[] {
96 const lines = primitives
97 .flatMap((primitive) => {
98 const piece = toPiece(primitive);
99
100 return piece ? [piece] : [];
101 })
102 .reduce<Piece[][]>((groups, piece) => {
103 const line = groups.at(-1);
104 const first = line?.[0];
105
106 if (
107 line &&
108 first &&
109 first.line === piece.line &&
110 Math.abs(first.baseline - piece.baseline) < first.size * 0.6
111 ) {
112 line.push(piece);
113
114 return groups;
115 }
116
117 groups.push([piece]);
118
119 return groups;
120 }, [])
121 .map((line) => mergeLine(logical(line)));
122
123 return lines.flatMap((line, index) => {
124 const next = lines[index + 1]?.[0];
125 const last = line.at(-1);
126 // A line that wraps inside its paragraph runs on into the next: copied text joins them as one paragraph.
127 const wraps = !!next && last?.end !== undefined && next.start === last.end;
128
129 return line.map((piece, at) => {
130 const following = line[at + 1];
131
132 if (!following) return toSpan(piece, page, measure, wraps ? undefined : "br");
133
134 return toSpan(piece, page, measure, separated(piece, following) ? "tab" : undefined);
135 });
136 });
137}
138
139/** Neighbours on a line with a gap between them and no space on either side of it read as separate words. */
140function separated(previous: Piece, next: Piece) {
141 if (/\s$/u.test(previous.text) || /^\s/u.test(next.text)) return false;
142
143 // A number's painted width runs up to its text, over the tab between them.
144 if (previous.marker) return true;
145
146 const gap = Math.max(
147 next.rect.x - (previous.rect.x + previous.rect.w),
148 previous.rect.x - (next.rect.x + next.rect.w),
149 );
150
151 return gap > next.size * 0.35;
152}
153
154function toPiece(primitive: DisplayPrimitive): Piece | undefined {
155 if (primitive.kind === "glyphRun") return glyphPiece(primitive);
156
157 if (primitive.kind === "text") return textPiece(primitive);
158
159 return undefined;
160}
161
162function glyphPiece(primitive: GlyphRunPrimitive): Piece | undefined {
163 if (!primitive.text || primitive.glyphs.length === 0 || primitive.decorative) return undefined;
164
165 // Marks sit off the baseline with no advance; the first glyph that advances sits on it.
166 const base = primitive.glyphs.find((glyph) => glyph.advance) ?? primitive.glyphs[0];
167
168 return {
169 ...common(primitive),
170 text: primitive.text,
171 rect: glyphRunRect(primitive),
172 baseline: base?.y ?? 0,
173 size: primitive.size,
174 };
175}
176
177function textPiece(primitive: TextRunPrimitive): Piece | undefined {
178 if (!primitive.text || primitive.decorative) return undefined;
179
180 return {
181 ...common(primitive),
182 text: primitive.text,
183 rect: textRunRect(primitive),
184 baseline: primitive.baselineY,
185 size: fontSizePxFromShorthand(primitive.font),
186 };
187}
188
189function common(primitive: GlyphRunPrimitive | TextRunPrimitive) {
190 const link = wordLink(primitive.href);
191
192 return {
193 dir:
194 primitive.rtl || (primitive.bidiLevel ?? 0) % 2 === 1 ? ("rtl" as const) : ("ltr" as const),
195 href: link ? primitive.href : undefined,
196 title:
197 primitive.linkTitle ??
198 primitive.tooltip ??
199 (link?.kind === "external" ? link.url : undefined),
200 rotation: primitive.rotationDeg ?? 0,
201 line: `${primitive.lineIndex ?? ""}|${primitive.cell?.cellId ?? `${primitive.cell?.row ?? ""},${primitive.cell?.col ?? ""}`}`,
202 marker: primitive.listMarker ?? false,
203 start: primitive.docStart,
204 end: primitive.docEnd,
205 };
206}
207
208/** A line paints right-to-left runs in visual order; document positions restore the order they are read in. */
209function logical(line: Piece[]) {
210 if (line.some((piece) => piece.start === undefined)) return line;
211
212 return line.toSorted((left, right) => (left.start ?? 0) - (right.start ?? 0));
213}
214
215/**
216 * Joins pieces whose text runs on: same link, direction and size, next in the document and touching on the page. A wide
217 * gap, such as a tab or a justified space, starts a new span, so each span's text stays close to its glyphs.
218 */
219function mergeLine(line: Piece[]) {
220 return line.reduce<Piece[]>((merged, piece) => {
221 const previous = merged.at(-1);
222
223 if (!previous || !joins(previous, piece)) {
224 merged.push(piece);
225
226 return merged;
227 }
228
229 const left = Math.min(previous.rect.x, piece.rect.x);
230 const right = Math.max(previous.rect.x + previous.rect.w, piece.rect.x + piece.rect.w);
231
232 merged[merged.length - 1] = {
233 ...previous,
234 text: previous.text + piece.text,
235 rect: { ...previous.rect, x: left, w: right - left },
236 end: piece.end,
237 };
238
239 return merged;
240 }, []);
241}
242
243function joins(previous: Piece, piece: Piece) {
244 if (previous.marker || piece.marker) return false;
245
246 if (previous.href !== piece.href || previous.dir !== piece.dir || previous.title !== piece.title)
247 return false;
248
249 if (
250 previous.rotation !== 0 ||
251 piece.rotation !== 0 ||
252 Math.abs(previous.size - piece.size) > 0.01
253 )
254 return false;
255
256 if (previous.end === undefined || previous.end !== piece.start) return false;
257
258 const gap =
259 piece.dir === "rtl"
260 ? previous.rect.x - (piece.rect.x + piece.rect.w)
261 : piece.rect.x - (previous.rect.x + previous.rect.w);
262
263 return Math.abs(gap) < piece.size * 0.35;
264}
265
266function toSpan(
267 piece: Piece,
268 page: DisplayPage,
269 measure: TextMeasure,
270 after: WordSpan["after"],
271): WordSpan {
272 const natural = measure.width(piece.text);
273 const stretch = natural > 0 && piece.rect.w > 0 ? piece.rect.w / (natural * piece.size) : 1;
274 // The line box puts the baseline this many ems below its top.
275 const above = (lineHeight + measure.ascent - measure.descent) / 2;
276 const top = piece.baseline - piece.size * above;
277 const width = piece.rect.w / piece.size;
278 const middle = (piece.rect.y + piece.rect.h / 2 - top) / piece.size;
279
280 // A rotated run turns about its painted box's centre; ems keep the turn in step with the page's scale.
281 const rotation =
282 piece.rotation === 0
283 ? ""
284 : `translate(${width / 2}em, ${middle}em) rotate(${piece.rotation}deg) translate(${-width / 2}em, ${-middle}em) `;
285
286 return {
287 text: piece.text,
288 left: piece.rect.x / page.width,
289 top: top / page.height,
290 size: piece.size / page.width,
291 transform: `${rotation}scaleX(${stretch})`,
292 dir: piece.dir,
293 link: wordLink(piece.href),
294 title: piece.title,
295 after,
296 };
297}