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

sheet-frame.ts

268 lines9.3 KB

1// Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode.
2import {
3 paintDisplayList,
4 type DisplayList,
5 type GridMeta,
6 type WorkbookHandle,
7} from "@betteroffice/xlsx";
8import { attempt } from "./sheet-cells";
9import { frozenSize } from "./sheet-paint";
10import type { SheetViewport } from "./sheet-protocol";
11​
12// The workbook worker's frames: the display lists of one viewport, split into tiles where the engine refuses it whole,
13// and their painting.
14​
15/** The most tiles a side of a frame splits into before the frame gives up. */
16const maxSplit = 8;
17​
18/**
19 * How far a tile reaches past each of its seams, in CSS pixels. The engine draws pane dividers and cut tracks at a
20 * frame's edges, which the next tile has to cover, and the seam falls inside a device pixel.
21 */
22const seamOverlap = 8;
23​
24/** Part of a frame: its display list, where its origin sits in the frame, and where it starts to show. */
25type Tile = {
26 readonly list: DisplayList;
27 readonly x: number;
28 readonly y: number;
29 readonly clipX: number;
30 readonly clipY: number;
31};
32​
33/**
34 * A frame of cells: the tiles that draw it, one display list for its grid and links, without draw commands, and the
35 * viewport it shows.
36 */
37export type Frame = {
38 readonly list: DisplayList;
39 readonly tiles: readonly Tile[];
40 readonly viewport: SheetViewport;
41};
42​
43/**
44 * The cells of one viewport, as the engine draws them; `undefined` when even its smallest tiles fail. A `wider`
45 * viewport around it is drawn instead when the engine takes it whole.
46 */
47export function drawFrame(
48 workbook: WorkbookHandle,
49 viewport: SheetViewport,
50 frozen: { readonly rows: number; readonly cols: number },
51 wider?: SheetViewport,
52): Frame | undefined {
53 const around = wider && untruncated(workbook, wider);
54​
55 if (wider && around)
56 return {
57 list: around,
58 tiles: [{ list: around, x: 0, y: 0, clipX: 0, clipY: 0 }],
59 viewport: wider,
60 };
61​
62 const whole = untruncated(workbook, viewport);
63​
64 if (whole)
65 return { list: whole, tiles: [{ list: whole, x: 0, y: 0, clipX: 0, clipY: 0 }], viewport };
66​
67 // The engine refuses a viewport with too many cells, as a wide pane of narrow columns has. One row and one column of
68 // the frame give its tracks, and so where its frozen panes end.
69 const columns = untruncated(workbook, { ...viewport, height: 1 })?.grid;
70 const rows = untruncated(workbook, { ...viewport, width: 1 })?.grid;
71​
72 if (!columns || !rows) return undefined;
73​
74 const grid = {
75 startRow: rows.startRow,
76 ...(rows.rowIndices ? { rowIndices: rows.rowIndices } : {}),
77 rowOffsets: rows.rowOffsets,
78 startCol: columns.startCol,
79 ...(columns.colIndices ? { colIndices: columns.colIndices } : {}),
80 colOffsets: columns.colOffsets,
81 };
82​
83 return tiled(workbook, viewport, frozenSize(grid, frozen), grid, 2);
85​
86/** The engine's screen gridlines: one-pixel solid lines in its gridline grey, unlike any border it draws. */
87const gridline = (command: DisplayList["commands"][number]) =>
88 command.op === "line" && command.color === "#d4d4d4" && command.width === 1 && !command.style;
89​
90/** A frame without the gridlines the engine draws on every sheet, for a sheet whose view hides them. */
91export function withoutGridlines(frame: Frame): Frame {
92 return {
93 ...frame,
94 tiles: frame.tiles.map((tile) => ({
95 ...tile,
96 list: { ...tile.list, commands: tile.list.commands.filter((command) => !gridline(command)) },
97 })),
98 };
100​
101/**
102 * A frame whose chart text names real fonts. The engine leaves the theme's `+mj-lt` and `+mn-lt` in chart text and the
103 * painter sets chart families as CSS as they are, so an unresolved or missing family would keep the previous font.
104 */
105export function chartFonts(
106 frame: Frame,
107 theme: { readonly major: string; readonly minor: string },
108): Frame {
109 const family = (name: string) => {
110 const resolved = name.startsWith("+mj-")
111 ? theme.major
112 : name.startsWith("+mn-")
113 ? theme.minor
114 : name;
115 return `"${resolved.replaceAll('"', "")}", sans-serif`;
116 };
117​
118 return {
119 ...frame,
120 tiles: frame.tiles.map((tile) => ({
121 ...tile,
122 list: {
123 ...tile.list,
124 commands: tile.list.commands.map((command) =>
125 command.op === "text" && command.chart
126 ? { ...command, fontFamily: family(command.fontFamily ?? theme.minor) }
127 : command,
128 ),
129 },
130 })),
131 };
133​
134/**
135 * Draws a frame's tiles with the cells' top-left corner at `origin`, in device pixels, each tile from its seam on, so a
136 * tile covers the panes it repeats.
137 */
138export function paintFrame(
139 context: OffscreenCanvasRenderingContext2D,
140 frame: Frame,
141 input: { readonly ratio: number; readonly origin: { readonly x: number; readonly y: number } },
142) {
143 const ratio = input.ratio;
144 const origin = input.origin;
145 // SAFETY: the painter draws through the members a worker's 2D context shares with the window's. Only the window's
146 // context type is declared, and it has focus and attribute members the worker's lacks, hence the double cast.
147 const painter = context as unknown as CanvasRenderingContext2D;
148 const tiled = frame.tiles.length > 1;
149 const text = tiled ? new OffscreenCanvas(context.canvas.width, context.canvas.height) : undefined;
150 const textContext = text?.getContext("2d");
151​
152 frame.tiles.forEach((tile) => {
153 const at = { x: origin.x + tile.x * ratio, y: origin.y + tile.y * ratio };
154 const list = tiled
155 ? { ...tile.list, commands: tile.list.commands.filter((command) => command.op !== "text") }
156 : tile.list;
157​
158 if (tile.clipX === 0 && tile.clipY === 0) return paintDisplayList(painter, list, ratio, at);
159​
160 context.save();
161 context.setTransform(1, 0, 0, 1, 0, 0);
162 context.beginPath();
163 context.rect(
164 Math.round(origin.x + tile.clipX * ratio),
165 Math.round(origin.y + tile.clipY * ratio),
166 context.canvas.width,
167 context.canvas.height,
168 );
169 context.clip();
170 paintDisplayList(painter, list, ratio, at);
171 context.restore();
172 });
173 // Text clips can span a merged range beyond its tile. Composite it after all tile clears and backgrounds.
174 if (text && textContext)
175 frame.tiles.forEach((tile) => {
176 textContext.reset();
177 paintDisplayList(
178 textContext as unknown as CanvasRenderingContext2D,
179 {
180 ...tile.list,
181 width: frame.viewport.width - tile.x,
182 height: frame.viewport.height - tile.y,
183 commands: tile.list.commands.filter((command) => command.op === "text"),
184 },
185 ratio,
186 { x: origin.x + tile.x * ratio, y: origin.y + tile.y * ratio },
187 );
188 context.save();
189 context.setTransform(1, 0, 0, 1, 0, 0);
190 context.beginPath();
191 context.rect(
192 Math.round(origin.x + tile.clipX * ratio),
193 Math.round(origin.y + tile.clipY * ratio),
194 context.canvas.width,
195 context.canvas.height,
196 );
197 context.clip();
198 context.drawImage(text, 0, 0);
199 context.restore();
200 });
202​
203/**
204 * A frame split into `split` tiles a side, each under the engine's cap. A tile past the first along an axis asks for
205 * the frozen panes too, as every viewport has them, so the cells past them start just before its seam.
206 */
207function tiled(
208 workbook: WorkbookHandle,
209 viewport: SheetViewport,
210 panes: { readonly width: number; readonly height: number },
211 grid: GridMeta,
212 split: number,
213): Frame | undefined {
214 const columns = segments(viewport.width, panes.width, split);
215 const rows = segments(viewport.height, panes.height, split);
216 const tiles: Tile[] = [];
217​
218 const complete = rows.every((row) =>
219 columns.every((column) => {
220 const x = column.start === 0 ? 0 : column.start - seamOverlap - panes.width;
221 const y = row.start === 0 ? 0 : row.start - seamOverlap - panes.height;
222 // A tile but the last along an axis reaches past its end, which the next tile paints over.
223 const right = column.end === viewport.width ? column.end : column.end + seamOverlap;
224 const bottom = row.end === viewport.height ? row.end : row.end + seamOverlap;
225​
226 const list = untruncated(workbook, {
227 x: viewport.x + x,
228 y: viewport.y + y,
229 width: right - x,
230 height: bottom - y,
231 });
232​
233 if (list) tiles.push({ list, x, y, clipX: column.start, clipY: row.start });
234​
235 return !!list;
236 }),
237 );
238​
239 if (complete) {
240 const hyperlinks = tiles.flatMap((tile) => tile.list.hyperlinks ?? []);
241​
242 return {
243 list: { width: viewport.width, height: viewport.height, commands: [], grid, hyperlinks },
244 tiles,
245 viewport,
246 };
247 }
248​
249 if (split >= maxSplit) return undefined;
250​
251 return tiled(workbook, viewport, panes, grid, split + 1);
253​
254/** A viewport's display list, or undefined where the engine refuses it or cuts it short at its cell limit. */
255function untruncated(workbook: WorkbookHandle, viewport: SheetViewport) {
256 const list = attempt(() => workbook.displayList(viewport));
257​
258 return list?.truncated ? undefined : list;
260​
261/** Even spans of a side, the first holding the frozen panes whole; a seam inside them is dropped. */
262function segments(size: number, panes: number, split: number) {
263 const starts = Array.from({ length: split }, (_, part) =>
264 Math.round((part * size) / split),
265 ).filter((start, part) => part === 0 || (start >= panes + seamOverlap && start < size));
266​
267 return starts.map((start, part) => ({ start, end: starts[part + 1] ?? size }));