@lotics/xlsx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,156 @@
1
+ // =============================================================================
2
+ // Canvas Fill Helpers
3
+ // =============================================================================
4
+ //
5
+ // Pattern fills (hatches/stripes) and gradient fills drawn via Canvas 2D API.
6
+ // Patterns are cached by pattern type key for reuse.
7
+ // =============================================================================
8
+
9
+ import type { GradientData } from "./types";
10
+
11
+ // =============================================================================
12
+ // Pattern fills
13
+ // =============================================================================
14
+
15
+ const patternCache = new Map<string, CanvasPattern | null>();
16
+
17
+ /**
18
+ * Create a repeating CanvasPattern for a given OOXML pattern type.
19
+ * Colors are extracted from the CSS string (output of getPatternFillCSS).
20
+ * Pattern geometry is determined by the OOXML patternType name.
21
+ */
22
+ export function createPatternFill(
23
+ ctx: CanvasRenderingContext2D,
24
+ patternCss: string,
25
+ patternType?: string,
26
+ ): CanvasPattern | null {
27
+ const cacheKey = `${patternType ?? ""}|${patternCss}`;
28
+ const cached = patternCache.get(cacheKey);
29
+ if (cached !== undefined) return cached;
30
+
31
+ const size = 8;
32
+ // Use OffscreenCanvas when available, fall back to a regular canvas element (Safari < 16.4, older WebViews)
33
+ let tile: HTMLCanvasElement | OffscreenCanvas;
34
+ if (typeof OffscreenCanvas !== "undefined") {
35
+ tile = new OffscreenCanvas(size, size);
36
+ } else {
37
+ const el = document.createElement("canvas");
38
+ el.width = size;
39
+ el.height = size;
40
+ tile = el;
41
+ }
42
+ const tileCtx = tile.getContext("2d") as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
43
+ if (!tileCtx) {
44
+ patternCache.set(cacheKey, null);
45
+ return null;
46
+ }
47
+
48
+ const colors = extractPatternColors(patternCss);
49
+ drawPatternTile(tileCtx, size, patternType ?? "", colors.fg, colors.bg);
50
+
51
+ const pattern = ctx.createPattern(tile, "repeat");
52
+ patternCache.set(cacheKey, pattern);
53
+ return pattern;
54
+ }
55
+
56
+ function drawPatternTile(
57
+ tileCtx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
58
+ size: number,
59
+ pt: string,
60
+ fg: string,
61
+ bg: string,
62
+ ): void {
63
+ tileCtx.fillStyle = bg;
64
+ tileCtx.fillRect(0, 0, size, size);
65
+ tileCtx.strokeStyle = fg;
66
+ tileCtx.lineWidth = 1;
67
+
68
+ if (pt.includes("Vertical") || pt === "lightVertical") {
69
+ tileCtx.beginPath();
70
+ tileCtx.moveTo(4, 0);
71
+ tileCtx.lineTo(4, size);
72
+ tileCtx.stroke();
73
+ } else if (pt.includes("Horizontal") || pt === "lightHorizontal") {
74
+ tileCtx.beginPath();
75
+ tileCtx.moveTo(0, 4);
76
+ tileCtx.lineTo(size, 4);
77
+ tileCtx.stroke();
78
+ } else if (pt.includes("Grid") || pt === "lightGrid") {
79
+ tileCtx.beginPath();
80
+ tileCtx.moveTo(4, 0);
81
+ tileCtx.lineTo(4, size);
82
+ tileCtx.moveTo(0, 4);
83
+ tileCtx.lineTo(size, 4);
84
+ tileCtx.stroke();
85
+ } else if (pt.includes("Diagonal") || pt.includes("diagonal")) {
86
+ tileCtx.beginPath();
87
+ tileCtx.moveTo(0, 0);
88
+ tileCtx.lineTo(size, size);
89
+ tileCtx.stroke();
90
+ } else if (pt.includes("gray") || pt === "gray125" || pt === "gray0625") {
91
+ tileCtx.fillStyle = fg;
92
+ for (let x = 0; x < size; x += 2) {
93
+ for (let y = 0; y < size; y += 2) {
94
+ tileCtx.fillRect(x, y, 1, 1);
95
+ }
96
+ }
97
+ } else {
98
+ tileCtx.beginPath();
99
+ tileCtx.moveTo(0, 0);
100
+ tileCtx.lineTo(size, size);
101
+ tileCtx.stroke();
102
+ }
103
+ }
104
+
105
+ function extractPatternColors(css: string): { fg: string; bg: string } {
106
+ // Try to extract rgb/rgba colors from the CSS string
107
+ const colorMatches = css.match(/rgba?\([^)]+\)/g);
108
+ if (colorMatches && colorMatches.length >= 2) {
109
+ return { fg: colorMatches[0], bg: colorMatches[1] };
110
+ }
111
+ if (colorMatches && colorMatches.length === 1) {
112
+ return { fg: colorMatches[0], bg: "rgb(255, 255, 255)" };
113
+ }
114
+ // Try hex colors
115
+ const hexMatches = css.match(/#[0-9a-fA-F]{6}/g);
116
+ if (hexMatches && hexMatches.length >= 2) {
117
+ return { fg: hexMatches[0], bg: hexMatches[1] };
118
+ }
119
+ return { fg: "rgb(0, 0, 0)", bg: "rgb(255, 255, 255)" };
120
+ }
121
+
122
+ // =============================================================================
123
+ // Gradient fills
124
+ // =============================================================================
125
+
126
+ export function createGradientFill(
127
+ ctx: CanvasRenderingContext2D,
128
+ gradientData: GradientData,
129
+ x: number,
130
+ y: number,
131
+ w: number,
132
+ h: number,
133
+ ): CanvasGradient {
134
+ let gradient: CanvasGradient;
135
+
136
+ if (gradientData.type === "radial") {
137
+ const cx = x + w / 2;
138
+ const cy = y + h / 2;
139
+ const r = Math.max(w, h) / 2;
140
+ gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
141
+ } else {
142
+ // Linear gradient — convert degree to start/end points
143
+ const rad = (gradientData.degree * Math.PI) / 180;
144
+ const dx = Math.cos(rad) * w / 2;
145
+ const dy = Math.sin(rad) * h / 2;
146
+ const cx = x + w / 2;
147
+ const cy = y + h / 2;
148
+ gradient = ctx.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy);
149
+ }
150
+
151
+ for (const stop of gradientData.stops) {
152
+ gradient.addColorStop(stop.position, stop.color);
153
+ }
154
+
155
+ return gradient;
156
+ }
@@ -0,0 +1,99 @@
1
+ // =============================================================================
2
+ // Canvas Image Compositing
3
+ // =============================================================================
4
+ //
5
+ // Draws embedded images on the canvas at their anchor positions.
6
+ // Images use two-cell anchors (from/to) with optional EMU sub-cell offsets.
7
+ // =============================================================================
8
+
9
+ import type { LayoutEngine } from "./canvas_layout";
10
+ import type { ParsedImage } from "./types";
11
+
12
+ // =============================================================================
13
+ // Constants
14
+ // =============================================================================
15
+
16
+ /** EMU per inch — Excel Measurement Unit. */
17
+ const EMU_PER_INCH = 914400;
18
+ /** Pixels per inch (screen DPI). */
19
+ const PPI = 96;
20
+ /** EMU per pixel. */
21
+ const EMU_PER_PX = EMU_PER_INCH / PPI;
22
+
23
+ // =============================================================================
24
+ // Image drawing
25
+ // =============================================================================
26
+
27
+ /** Cache for loaded image elements. */
28
+ const imageCache = new Map<string, HTMLImageElement>();
29
+
30
+ /**
31
+ * Draw all images for the visible area.
32
+ */
33
+ export function drawImages(
34
+ ctx: CanvasRenderingContext2D,
35
+ images: ParsedImage[],
36
+ layout: LayoutEngine,
37
+ scrollX: number,
38
+ scrollY: number,
39
+ viewWidth: number,
40
+ viewHeight: number,
41
+ ): void {
42
+ for (const img of images) {
43
+ drawImage(ctx, img, layout, scrollX, scrollY, viewWidth, viewHeight);
44
+ }
45
+ }
46
+
47
+ function drawImage(
48
+ ctx: CanvasRenderingContext2D,
49
+ img: ParsedImage,
50
+ layout: LayoutEngine,
51
+ scrollX: number,
52
+ scrollY: number,
53
+ viewWidth: number,
54
+ viewHeight: number,
55
+ ): void {
56
+ // Compute pixel bounds from anchor
57
+ const x1 = layout.colX[img.tl.col] ?? 0;
58
+ const y1 = layout.rowY[img.tl.row] ?? 0;
59
+ const x2 = (layout.colX[img.br.col] ?? 0) + (layout.colW[img.br.col] ?? 0);
60
+ const y2 = (layout.rowY[img.br.row] ?? 0) + (layout.rowH[img.br.row] ?? 0);
61
+
62
+ // Apply scroll
63
+ const drawX = x1 - scrollX;
64
+ const drawY = y1 - scrollY;
65
+ const drawW = x2 - x1;
66
+ const drawH = y2 - y1;
67
+
68
+ // Skip if outside viewport
69
+ if (drawX + drawW < 0 || drawY + drawH < 0 || drawX > viewWidth || drawY > viewHeight) {
70
+ return;
71
+ }
72
+
73
+ // Load or retrieve cached image
74
+ let imgEl = imageCache.get(img.dataUrl);
75
+ if (!imgEl) {
76
+ imgEl = new Image();
77
+ imgEl.src = img.dataUrl;
78
+ imageCache.set(img.dataUrl, imgEl);
79
+ }
80
+
81
+ if (imgEl.complete && imgEl.naturalWidth > 0) {
82
+ ctx.drawImage(imgEl, drawX, drawY, drawW, drawH);
83
+ }
84
+ // If not loaded yet, it will appear on the next redraw
85
+ }
86
+
87
+ /**
88
+ * Convert EMU offset to pixels.
89
+ */
90
+ export function emuToPixels(emu: number): number {
91
+ return emu / EMU_PER_PX;
92
+ }
93
+
94
+ /**
95
+ * Clear the image cache (call on workbook change).
96
+ */
97
+ export function clearImageCache(): void {
98
+ imageCache.clear();
99
+ }
@@ -0,0 +1,159 @@
1
+ import {
2
+ LayoutEngine,
3
+ ROW_HEADER_WIDTH,
4
+ COL_HEADER_HEIGHT,
5
+ MIN_COL_WIDTH_PX,
6
+ } from "./canvas_layout";
7
+
8
+ function makeLayout(overrides?: {
9
+ colCount?: number;
10
+ rowCount?: number;
11
+ colWidths?: Map<number, number>;
12
+ rowHeights?: Map<number, number>;
13
+ hiddenCols?: Set<number>;
14
+ hiddenRows?: Set<number>;
15
+ freeze?: { row: number; col: number };
16
+ }): LayoutEngine {
17
+ return new LayoutEngine({
18
+ colCount: overrides?.colCount ?? 10,
19
+ rowCount: overrides?.rowCount ?? 20,
20
+ colWidths: overrides?.colWidths ?? new Map(),
21
+ rowHeights: overrides?.rowHeights ?? new Map(),
22
+ defaultColWidth: 8,
23
+ defaultRowHeight: 15, // 15pt → 20px after pt-to-px conversion
24
+ hiddenCols: overrides?.hiddenCols ?? new Set(),
25
+ hiddenRows: overrides?.hiddenRows ?? new Set(),
26
+ freeze: overrides?.freeze,
27
+ });
28
+ }
29
+
30
+ describe("LayoutEngine", () => {
31
+ test("computes column positions", () => {
32
+ const layout = makeLayout({ colCount: 3 });
33
+ expect(layout.colX[0]).toBe(ROW_HEADER_WIDTH);
34
+ // Default col width = 8 * CHAR_WIDTH_PX = 64
35
+ expect(layout.colX[1]).toBe(ROW_HEADER_WIDTH);
36
+ expect(layout.colX[2]).toBe(ROW_HEADER_WIDTH + 64);
37
+ expect(layout.colX[3]).toBe(ROW_HEADER_WIDTH + 128);
38
+ expect(layout.colW[1]).toBe(64);
39
+ });
40
+
41
+ test("computes row positions", () => {
42
+ const layout = makeLayout({ rowCount: 3 });
43
+ expect(layout.rowY[0]).toBe(COL_HEADER_HEIGHT);
44
+ expect(layout.rowY[1]).toBe(COL_HEADER_HEIGHT);
45
+ expect(layout.rowY[2]).toBe(COL_HEADER_HEIGHT + 20);
46
+ expect(layout.rowH[1]).toBe(20);
47
+ });
48
+
49
+ test("respects custom column widths", () => {
50
+ const layout = makeLayout({
51
+ colCount: 3,
52
+ colWidths: new Map([[2, 20]]),
53
+ });
54
+ expect(layout.colW[1]).toBe(64); // default
55
+ expect(layout.colW[2]).toBe(160); // 20 * 8
56
+ expect(layout.colW[3]).toBe(64); // default
57
+ });
58
+
59
+ test("respects custom row heights", () => {
60
+ const layout = makeLayout({
61
+ rowCount: 3,
62
+ rowHeights: new Map([[2, 30]]), // 30pt → 40px
63
+ });
64
+ expect(layout.rowH[1]).toBe(20); // default: 15pt → 20px
65
+ expect(layout.rowH[2]).toBe(40); // 30pt → 40px
66
+ expect(layout.rowH[3]).toBe(20); // default
67
+ });
68
+
69
+ test("hidden columns have zero width", () => {
70
+ const layout = makeLayout({
71
+ colCount: 3,
72
+ hiddenCols: new Set([2]),
73
+ });
74
+ expect(layout.colW[2]).toBe(0);
75
+ expect(layout.colX[3]).toBe(layout.colX[2]); // same position
76
+ });
77
+
78
+ test("hidden rows have zero height", () => {
79
+ const layout = makeLayout({
80
+ rowCount: 3,
81
+ hiddenRows: new Set([2]),
82
+ });
83
+ expect(layout.rowH[2]).toBe(0);
84
+ expect(layout.rowY[3]).toBe(layout.rowY[2]);
85
+ });
86
+
87
+ test("totalWidth includes all columns + row header", () => {
88
+ const layout = makeLayout({ colCount: 5 });
89
+ expect(layout.totalWidth).toBe(ROW_HEADER_WIDTH + 5 * 64);
90
+ });
91
+
92
+ test("totalHeight includes all rows + col header", () => {
93
+ const layout = makeLayout({ rowCount: 5 });
94
+ expect(layout.totalHeight).toBe(COL_HEADER_HEIGHT + 5 * 20);
95
+ });
96
+
97
+ test("frozen dimensions", () => {
98
+ const layout = makeLayout({
99
+ colCount: 5,
100
+ rowCount: 10,
101
+ freeze: { row: 2, col: 1 },
102
+ });
103
+ expect(layout.frozenHeight).toBe(40); // 2 rows * 20px
104
+ expect(layout.frozenWidth).toBe(64); // 1 col * 64px
105
+ });
106
+
107
+ test("getVisibleRange returns correct range", () => {
108
+ const layout = makeLayout({ colCount: 100, rowCount: 1000 });
109
+ const range = layout.getVisibleRange(0, 0, 800, 600);
110
+ expect(range.startRow).toBeGreaterThanOrEqual(1);
111
+ expect(range.endRow).toBeLessThanOrEqual(1000);
112
+ expect(range.startCol).toBeGreaterThanOrEqual(1);
113
+ expect(range.endCol).toBeLessThanOrEqual(100);
114
+ });
115
+
116
+ test("getVisibleRange with scroll offset", () => {
117
+ const layout = makeLayout({ colCount: 100, rowCount: 1000 });
118
+ const range = layout.getVisibleRange(640, 2000, 800, 600);
119
+ // With scroll, visible rows/cols should shift
120
+ expect(range.startRow).toBeGreaterThan(1);
121
+ expect(range.startCol).toBeGreaterThan(1);
122
+ });
123
+
124
+ test("hitTest identifies cell region", () => {
125
+ const layout = makeLayout({ colCount: 5, rowCount: 10 });
126
+ const hit = layout.hitTest(ROW_HEADER_WIDTH + 10, COL_HEADER_HEIGHT + 10, 0, 0);
127
+ expect(hit?.region).toBe("cell");
128
+ expect(hit?.row).toBe(1);
129
+ expect(hit?.col).toBe(1);
130
+ });
131
+
132
+ test("hitTest identifies corner", () => {
133
+ const layout = makeLayout();
134
+ const hit = layout.hitTest(5, 5, 0, 0);
135
+ expect(hit?.region).toBe("corner");
136
+ });
137
+
138
+ test("hitTest identifies column header", () => {
139
+ const layout = makeLayout();
140
+ const hit = layout.hitTest(ROW_HEADER_WIDTH + 10, 5, 0, 0);
141
+ expect(hit?.region).toBe("col-header");
142
+ expect(hit?.col).toBe(1);
143
+ });
144
+
145
+ test("hitTest identifies row header", () => {
146
+ const layout = makeLayout();
147
+ const hit = layout.hitTest(5, COL_HEADER_HEIGHT + 10, 0, 0);
148
+ expect(hit?.region).toBe("row-header");
149
+ expect(hit?.row).toBe(1);
150
+ });
151
+
152
+ test("minimum column width enforced", () => {
153
+ const layout = makeLayout({
154
+ colCount: 1,
155
+ colWidths: new Map([[1, 1]]), // 1 char = 8px, less than MIN_COL_WIDTH_PX
156
+ });
157
+ expect(layout.colW[1]).toBe(MIN_COL_WIDTH_PX);
158
+ });
159
+ });
@@ -0,0 +1,239 @@
1
+ // =============================================================================
2
+ // Canvas Layout Engine
3
+ // =============================================================================
4
+ //
5
+ // Pure math for spreadsheet layout calculations:
6
+ // - Column X positions and widths in pixels
7
+ // - Row Y positions and heights in pixels
8
+ // - Visible range computation for virtual scrolling
9
+ // - Hit-testing: pixel → cell reference
10
+ // =============================================================================
11
+
12
+ // =============================================================================
13
+ // Constants
14
+ // =============================================================================
15
+
16
+ export const ROW_HEADER_WIDTH = 50;
17
+ export const COL_HEADER_HEIGHT = 24;
18
+ export const DEFAULT_COL_WIDTH_PX = 64;
19
+ export const MIN_COL_WIDTH_PX = 20;
20
+ export const DEFAULT_ROW_HEIGHT_PX = 21;
21
+ export const CHAR_WIDTH_PX = 8;
22
+ /** Excel stores row heights in points (1pt = 1/72 inch). At 96 DPI, 1pt = 96/72 = 1.333px. */
23
+ export const PT_TO_PX = 96 / 72;
24
+
25
+ // =============================================================================
26
+ // Types
27
+ // =============================================================================
28
+
29
+ export interface LayoutConfig {
30
+ /** Total number of columns. */
31
+ colCount: number;
32
+ /** Total number of rows. */
33
+ rowCount: number;
34
+ /** Column widths in character units (sparse — only non-default). */
35
+ colWidths: Map<number, number>;
36
+ /** Row heights in points (sparse — only non-default). */
37
+ rowHeights: Map<number, number>;
38
+ /** Default column width in character units. */
39
+ defaultColWidth: number;
40
+ /** Default row height in points. */
41
+ defaultRowHeight: number;
42
+ /** Hidden columns. */
43
+ hiddenCols: Set<number>;
44
+ /** Hidden rows. */
45
+ hiddenRows: Set<number>;
46
+ /** Frozen pane: { row, col } — rows/cols before this are frozen. */
47
+ freeze?: { row: number; col: number };
48
+ }
49
+
50
+ export interface VisibleRange {
51
+ startRow: number;
52
+ endRow: number;
53
+ startCol: number;
54
+ endCol: number;
55
+ }
56
+
57
+ export interface HitResult {
58
+ row: number;
59
+ col: number;
60
+ region: "cell" | "row-header" | "col-header" | "corner";
61
+ }
62
+
63
+ // =============================================================================
64
+ // LayoutEngine
65
+ // =============================================================================
66
+
67
+ export class LayoutEngine {
68
+ /** Cumulative X positions: colX[i] = left edge of column i (1-based). colX[0] = ROW_HEADER_WIDTH. */
69
+ readonly colX: Float64Array;
70
+ /** Width of each column in pixels: colW[i] for column i (1-based). */
71
+ readonly colW: Float64Array;
72
+ /** Cumulative Y positions: rowY[i] = top edge of row i (1-based). rowY[0] = COL_HEADER_HEIGHT. */
73
+ readonly rowY: Float64Array;
74
+ /** Height of each row in pixels: rowH[i] for row i (1-based). */
75
+ readonly rowH: Float64Array;
76
+
77
+ /** Total content width (all columns + row header). */
78
+ readonly totalWidth: number;
79
+ /** Total content height (all rows + col header). */
80
+ readonly totalHeight: number;
81
+ /** Frozen area width in pixels (excludes row header). */
82
+ readonly frozenWidth: number;
83
+ /** Frozen area height in pixels (excludes col header). */
84
+ readonly frozenHeight: number;
85
+
86
+ /** Frozen pane configuration (row/col indices). */
87
+ readonly freeze: { row: number; col: number };
88
+
89
+ private config: LayoutConfig;
90
+
91
+ constructor(config: LayoutConfig) {
92
+ this.config = config;
93
+ this.freeze = { row: config.freeze?.row ?? 0, col: config.freeze?.col ?? 0 };
94
+ const { colCount, rowCount } = config;
95
+
96
+ // Build column positions
97
+ this.colW = new Float64Array(colCount + 1);
98
+ this.colX = new Float64Array(colCount + 1);
99
+ this.colX[0] = ROW_HEADER_WIDTH;
100
+ let x = ROW_HEADER_WIDTH;
101
+ for (let c = 1; c <= colCount; c++) {
102
+ const w = config.hiddenCols.has(c)
103
+ ? 0
104
+ : Math.max(
105
+ (config.colWidths.get(c) ?? config.defaultColWidth) * CHAR_WIDTH_PX,
106
+ MIN_COL_WIDTH_PX,
107
+ );
108
+ this.colW[c] = w;
109
+ this.colX[c] = x;
110
+ x += w;
111
+ }
112
+ this.totalWidth = x;
113
+
114
+ // Build row positions
115
+ this.rowH = new Float64Array(rowCount + 1);
116
+ this.rowY = new Float64Array(rowCount + 1);
117
+ this.rowY[0] = COL_HEADER_HEIGHT;
118
+ let y = COL_HEADER_HEIGHT;
119
+ for (let r = 1; r <= rowCount; r++) {
120
+ const rawH = config.hiddenRows.has(r)
121
+ ? 0
122
+ : config.rowHeights.get(r) ?? config.defaultRowHeight;
123
+ const h = rawH > 0 ? Math.round(rawH * PT_TO_PX) : 0;
124
+ this.rowH[r] = h;
125
+ this.rowY[r] = y;
126
+ y += h;
127
+ }
128
+ this.totalHeight = y;
129
+
130
+ // Frozen dimensions — sum heights/widths of frozen rows/cols
131
+ const freezeRow = config.freeze?.row ?? 0;
132
+ const freezeCol = config.freeze?.col ?? 0;
133
+ let fh = 0;
134
+ for (let r = 1; r <= Math.min(freezeRow, rowCount); r++) fh += this.rowH[r];
135
+ this.frozenHeight = fh;
136
+ let fw = 0;
137
+ for (let c = 1; c <= Math.min(freezeCol, colCount); c++) fw += this.colW[c];
138
+ this.frozenWidth = fw;
139
+ }
140
+
141
+ /** Get the visible row/col range for the given scroll position and viewport size. */
142
+ getVisibleRange(
143
+ scrollX: number,
144
+ scrollY: number,
145
+ viewWidth: number,
146
+ viewHeight: number,
147
+ ): VisibleRange {
148
+ const freezeRow = this.config.freeze?.row ?? 0;
149
+ const freezeCol = this.config.freeze?.col ?? 0;
150
+
151
+ // Adjust for frozen area — scrollable content starts after frozen rows/cols
152
+ const contentStartY = COL_HEADER_HEIGHT + this.frozenHeight;
153
+ const contentStartX = ROW_HEADER_WIDTH + this.frozenWidth;
154
+
155
+ const startRow = Math.max(
156
+ freezeRow + 1,
157
+ this.findRow(scrollY + contentStartY),
158
+ );
159
+ const endRow = Math.min(
160
+ this.config.rowCount,
161
+ this.findRow(scrollY + viewHeight) + 1,
162
+ );
163
+ const startCol = Math.max(
164
+ freezeCol + 1,
165
+ this.findCol(scrollX + contentStartX),
166
+ );
167
+ const endCol = Math.min(
168
+ this.config.colCount,
169
+ this.findCol(scrollX + viewWidth) + 1,
170
+ );
171
+
172
+ return { startRow, endRow, startCol, endCol };
173
+ }
174
+
175
+ /** Convert a pixel position to a cell reference. */
176
+ hitTest(
177
+ px: number,
178
+ py: number,
179
+ scrollX: number,
180
+ scrollY: number,
181
+ ): HitResult | undefined {
182
+ // Corner
183
+ if (px < ROW_HEADER_WIDTH && py < COL_HEADER_HEIGHT) {
184
+ return { row: 0, col: 0, region: "corner" };
185
+ }
186
+
187
+ // Column header
188
+ if (py < COL_HEADER_HEIGHT) {
189
+ const col = px < ROW_HEADER_WIDTH + this.frozenWidth
190
+ ? this.findCol(px)
191
+ : this.findCol(px + scrollX);
192
+ return { row: 0, col, region: "col-header" };
193
+ }
194
+
195
+ // Row header
196
+ if (px < ROW_HEADER_WIDTH) {
197
+ const row = py < COL_HEADER_HEIGHT + this.frozenHeight
198
+ ? this.findRow(py)
199
+ : this.findRow(py + scrollY);
200
+ return { row, col: 0, region: "row-header" };
201
+ }
202
+
203
+ // Cell area
204
+ const col = px < ROW_HEADER_WIDTH + this.frozenWidth
205
+ ? this.findCol(px)
206
+ : this.findCol(px + scrollX);
207
+ const row = py < COL_HEADER_HEIGHT + this.frozenHeight
208
+ ? this.findRow(py)
209
+ : this.findRow(py + scrollY);
210
+
211
+ if (row < 1 || col < 1) return undefined;
212
+ return { row, col, region: "cell" };
213
+ }
214
+
215
+ /** Binary search for the row at pixel position y. */
216
+ private findRow(y: number): number {
217
+ let lo = 1;
218
+ let hi = this.config.rowCount;
219
+ while (lo <= hi) {
220
+ const mid = (lo + hi) >>> 1;
221
+ if (this.rowY[mid] <= y) lo = mid + 1;
222
+ else hi = mid - 1;
223
+ }
224
+ return Math.max(1, hi);
225
+ }
226
+
227
+ /** Binary search for the column at pixel position x. */
228
+ private findCol(x: number): number {
229
+ let lo = 1;
230
+ let hi = this.config.colCount;
231
+ while (lo <= hi) {
232
+ const mid = (lo + hi) >>> 1;
233
+ if (this.colX[mid] <= x) lo = mid + 1;
234
+ else hi = mid - 1;
235
+ }
236
+ return Math.max(1, hi);
237
+ }
238
+ }
239
+