@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.
- package/package.json +30 -0
- package/src/auto_sum.test.ts +71 -0
- package/src/auto_sum.ts +83 -0
- package/src/canvas_cf.ts +135 -0
- package/src/canvas_chart.ts +687 -0
- package/src/canvas_fill.ts +156 -0
- package/src/canvas_images.ts +99 -0
- package/src/canvas_layout.test.ts +159 -0
- package/src/canvas_layout.ts +239 -0
- package/src/canvas_renderer.ts +1010 -0
- package/src/canvas_shape.ts +242 -0
- package/src/canvas_sparkline.ts +163 -0
- package/src/canvas_text.ts +454 -0
- package/src/cell_editor.ts +558 -0
- package/src/clipboard.test.ts +145 -0
- package/src/clipboard.ts +341 -0
- package/src/command_history.ts +102 -0
- package/src/csv_parser.test.ts +130 -0
- package/src/csv_parser.ts +172 -0
- package/src/data_validation.test.ts +95 -0
- package/src/data_validation.ts +114 -0
- package/src/editing_layer.test.ts +309 -0
- package/src/excel_cf_evaluate.test.ts +293 -0
- package/src/excel_cf_evaluate.ts +719 -0
- package/src/excel_cf_icons.ts +108 -0
- package/src/excel_cf_types.ts +67 -0
- package/src/excel_numfmt.test.ts +607 -0
- package/src/excel_numfmt.ts +1033 -0
- package/src/excel_parser.test.ts +1061 -0
- package/src/excel_parser.ts +393 -0
- package/src/excel_pattern_fills.ts +64 -0
- package/src/excel_table_styles.ts +160 -0
- package/src/excel_utils.test.ts +108 -0
- package/src/excel_utils.ts +162 -0
- package/src/fast-formula-parser.d.ts +53 -0
- package/src/fill_logic.ts +257 -0
- package/src/fill_patterns.test.ts +90 -0
- package/src/fill_patterns.ts +71 -0
- package/src/flash_fill.test.ts +75 -0
- package/src/flash_fill.ts +221 -0
- package/src/formula_deps.ts +189 -0
- package/src/formula_engine.test.ts +348 -0
- package/src/formula_engine.ts +401 -0
- package/src/formula_highlight.test.ts +81 -0
- package/src/formula_highlight.ts +139 -0
- package/src/formula_suggest.ts +100 -0
- package/src/hidden_sheets.test.ts +49 -0
- package/src/index.ts +149 -0
- package/src/keyboard_nav.test.ts +394 -0
- package/src/keyboard_nav.ts +891 -0
- package/src/move_logic.ts +63 -0
- package/src/numfmt_type.test.ts +158 -0
- package/src/numfmt_type.ts +231 -0
- package/src/ooxml_cell_format.ts +70 -0
- package/src/ooxml_chart.test.ts +85 -0
- package/src/ooxml_chart.ts +347 -0
- package/src/ooxml_chart_types.ts +207 -0
- package/src/ooxml_color.ts +339 -0
- package/src/ooxml_drawing.test.ts +87 -0
- package/src/ooxml_drawing.ts +287 -0
- package/src/ooxml_pivot.test.ts +195 -0
- package/src/ooxml_pivot.ts +468 -0
- package/src/ooxml_pivot_types.ts +165 -0
- package/src/ooxml_shape.ts +355 -0
- package/src/ooxml_sheet.ts +1271 -0
- package/src/ooxml_styles.ts +556 -0
- package/src/ooxml_table.test.ts +70 -0
- package/src/ooxml_table.ts +131 -0
- package/src/ooxml_workbook.test.ts +40 -0
- package/src/ooxml_workbook.ts +259 -0
- package/src/ooxml_zip.ts +33 -0
- package/src/pivot_model.ts +237 -0
- package/src/pivot_recompute.test.ts +210 -0
- package/src/pivot_recompute.ts +413 -0
- package/src/selection_model.ts +364 -0
- package/src/spreadsheet_commands.test.ts +772 -0
- package/src/spreadsheet_commands.ts +1805 -0
- package/src/structured_refs.test.ts +128 -0
- package/src/structured_refs.ts +160 -0
- package/src/style_helpers.test.ts +33 -0
- package/src/style_helpers.ts +30 -0
- package/src/template_builder.test.ts +139 -0
- package/src/template_builder.ts +36 -0
- package/src/types.ts +239 -0
- package/src/workbook_model.test.ts +536 -0
- package/src/workbook_model.ts +911 -0
- package/src/xlsx_round_trip.test.ts +279 -0
- package/src/xlsx_writer.test.ts +488 -0
- package/src/xlsx_writer.ts +1549 -0
- 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
|
+
|