@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,108 @@
|
|
|
1
|
+
import { parseNameBoxInput, buildNamedRangeExpr } from "./excel_utils";
|
|
2
|
+
|
|
3
|
+
describe("parseNameBoxInput", () => {
|
|
4
|
+
test("parses single cell reference", () => {
|
|
5
|
+
expect(parseNameBoxInput("A1")).toEqual({ kind: "cell", row: 1, col: 1 });
|
|
6
|
+
expect(parseNameBoxInput("C10")).toEqual({ kind: "cell", row: 10, col: 3 });
|
|
7
|
+
expect(parseNameBoxInput("AA1")).toEqual({ kind: "cell", row: 1, col: 27 });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("parses range reference", () => {
|
|
11
|
+
expect(parseNameBoxInput("A1:C10")).toEqual({
|
|
12
|
+
kind: "range",
|
|
13
|
+
startRow: 1,
|
|
14
|
+
startCol: 1,
|
|
15
|
+
endRow: 10,
|
|
16
|
+
endCol: 3,
|
|
17
|
+
});
|
|
18
|
+
expect(parseNameBoxInput("B2:D5")).toEqual({
|
|
19
|
+
kind: "range",
|
|
20
|
+
startRow: 2,
|
|
21
|
+
startCol: 2,
|
|
22
|
+
endRow: 5,
|
|
23
|
+
endCol: 4,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("normalizes reversed range", () => {
|
|
28
|
+
expect(parseNameBoxInput("C10:A1")).toEqual({
|
|
29
|
+
kind: "range",
|
|
30
|
+
startRow: 1,
|
|
31
|
+
startCol: 1,
|
|
32
|
+
endRow: 10,
|
|
33
|
+
endCol: 3,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("parses row range", () => {
|
|
38
|
+
expect(parseNameBoxInput("1:5")).toEqual({ kind: "rowRange", startRow: 1, endRow: 5 });
|
|
39
|
+
expect(parseNameBoxInput("3:1")).toEqual({ kind: "rowRange", startRow: 1, endRow: 3 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("parses column range", () => {
|
|
43
|
+
expect(parseNameBoxInput("A:C")).toEqual({ kind: "colRange", startCol: 1, endCol: 3 });
|
|
44
|
+
expect(parseNameBoxInput("D:B")).toEqual({ kind: "colRange", startCol: 2, endCol: 4 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("strips $ signs (absolute refs)", () => {
|
|
48
|
+
expect(parseNameBoxInput("$A$1")).toEqual({ kind: "cell", row: 1, col: 1 });
|
|
49
|
+
expect(parseNameBoxInput("$A$1:$C$10")).toEqual({
|
|
50
|
+
kind: "range",
|
|
51
|
+
startRow: 1,
|
|
52
|
+
startCol: 1,
|
|
53
|
+
endRow: 10,
|
|
54
|
+
endCol: 3,
|
|
55
|
+
});
|
|
56
|
+
expect(parseNameBoxInput("$B:$D")).toEqual({ kind: "colRange", startCol: 2, endCol: 4 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("is case-insensitive", () => {
|
|
60
|
+
expect(parseNameBoxInput("a1")).toEqual({ kind: "cell", row: 1, col: 1 });
|
|
61
|
+
expect(parseNameBoxInput("b2:d5")).toEqual({
|
|
62
|
+
kind: "range",
|
|
63
|
+
startRow: 2,
|
|
64
|
+
startCol: 2,
|
|
65
|
+
endRow: 5,
|
|
66
|
+
endCol: 4,
|
|
67
|
+
});
|
|
68
|
+
expect(parseNameBoxInput("a:c")).toEqual({ kind: "colRange", startCol: 1, endCol: 3 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("returns undefined for invalid input", () => {
|
|
72
|
+
expect(parseNameBoxInput("")).toBeUndefined();
|
|
73
|
+
expect(parseNameBoxInput(" ")).toBeUndefined();
|
|
74
|
+
expect(parseNameBoxInput("hello")).toBeUndefined();
|
|
75
|
+
expect(parseNameBoxInput("A")).toBeUndefined();
|
|
76
|
+
expect(parseNameBoxInput("123")).toBeUndefined();
|
|
77
|
+
expect(parseNameBoxInput("0:0")).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("returns undefined for row range with zero", () => {
|
|
81
|
+
expect(parseNameBoxInput("0:5")).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("handles whitespace around input", () => {
|
|
85
|
+
expect(parseNameBoxInput(" A1 ")).toEqual({ kind: "cell", row: 1, col: 1 });
|
|
86
|
+
expect(parseNameBoxInput(" B2:D5 ")).toEqual({
|
|
87
|
+
kind: "range",
|
|
88
|
+
startRow: 2,
|
|
89
|
+
startCol: 2,
|
|
90
|
+
endRow: 5,
|
|
91
|
+
endCol: 4,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("buildNamedRangeExpr", () => {
|
|
97
|
+
test("builds absolute reference expression", () => {
|
|
98
|
+
expect(buildNamedRangeExpr("Sheet1", 1, 1, 10, 3)).toBe("Sheet1!$A$1:$C$10");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("handles single cell range", () => {
|
|
102
|
+
expect(buildNamedRangeExpr("Demo", 5, 2, 5, 2)).toBe("Demo!$B$5:$B$5");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("handles multi-letter columns", () => {
|
|
106
|
+
expect(buildNamedRangeExpr("Data", 1, 27, 10, 28)).toBe("Data!$AA$1:$AB$10");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Name Box input parsing
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
export type ParsedInput =
|
|
6
|
+
| { kind: "cell"; row: number; col: number }
|
|
7
|
+
| { kind: "range"; startRow: number; startCol: number; endRow: number; endCol: number }
|
|
8
|
+
| { kind: "rowRange"; startRow: number; endRow: number }
|
|
9
|
+
| { kind: "colRange"; startCol: number; endCol: number };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse all address-string forms the Name Box accepts.
|
|
13
|
+
* Strips `$`, uppercases, then tries: range, column range, row range, single cell.
|
|
14
|
+
*/
|
|
15
|
+
export function parseNameBoxInput(input: string): ParsedInput | undefined {
|
|
16
|
+
const val = input.trim().replace(/\$/g, "").toUpperCase();
|
|
17
|
+
if (!val) return undefined;
|
|
18
|
+
|
|
19
|
+
// Range: A1:C10
|
|
20
|
+
const rangeMatch = val.match(/^([A-Z]+\d+):([A-Z]+\d+)$/);
|
|
21
|
+
if (rangeMatch) {
|
|
22
|
+
const start = refToRowCol(rangeMatch[1]);
|
|
23
|
+
const end = refToRowCol(rangeMatch[2]);
|
|
24
|
+
if (start && end) {
|
|
25
|
+
return {
|
|
26
|
+
kind: "range",
|
|
27
|
+
startRow: Math.min(start.row, end.row),
|
|
28
|
+
startCol: Math.min(start.col, end.col),
|
|
29
|
+
endRow: Math.max(start.row, end.row),
|
|
30
|
+
endCol: Math.max(start.col, end.col),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Column range: A:C
|
|
36
|
+
const colRangeMatch = val.match(/^([A-Z]+):([A-Z]+)$/);
|
|
37
|
+
if (colRangeMatch) {
|
|
38
|
+
const startCol = colLettersToNum(colRangeMatch[1]);
|
|
39
|
+
const endCol = colLettersToNum(colRangeMatch[2]);
|
|
40
|
+
return {
|
|
41
|
+
kind: "colRange",
|
|
42
|
+
startCol: Math.min(startCol, endCol),
|
|
43
|
+
endCol: Math.max(startCol, endCol),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Row range: 1:5
|
|
48
|
+
const rowRangeMatch = val.match(/^(\d+):(\d+)$/);
|
|
49
|
+
if (rowRangeMatch) {
|
|
50
|
+
const startRow = parseInt(rowRangeMatch[1], 10);
|
|
51
|
+
const endRow = parseInt(rowRangeMatch[2], 10);
|
|
52
|
+
if (startRow > 0 && endRow > 0) {
|
|
53
|
+
return {
|
|
54
|
+
kind: "rowRange",
|
|
55
|
+
startRow: Math.min(startRow, endRow),
|
|
56
|
+
endRow: Math.max(startRow, endRow),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Single cell: A1
|
|
63
|
+
const cell = refToRowCol(val);
|
|
64
|
+
if (cell) return { kind: "cell", row: cell.row, col: cell.col };
|
|
65
|
+
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build an absolute reference expression for a named range, e.g. "Sheet1!$A$1:$C$10". */
|
|
70
|
+
export function buildNamedRangeExpr(
|
|
71
|
+
sheetName: string,
|
|
72
|
+
startRow: number,
|
|
73
|
+
startCol: number,
|
|
74
|
+
endRow: number,
|
|
75
|
+
endCol: number,
|
|
76
|
+
): string {
|
|
77
|
+
const start = `$${colNumToLetters(startCol)}$${startRow}`;
|
|
78
|
+
const end = `$${colNumToLetters(endCol)}$${endRow}`;
|
|
79
|
+
return `${sheetName}!${start}:${end}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// =============================================================================
|
|
83
|
+
// Column letter/number conversion
|
|
84
|
+
// =============================================================================
|
|
85
|
+
|
|
86
|
+
/** Convert column letters (A, B, ..., Z, AA, AB, ...) to 1-based column number. */
|
|
87
|
+
export function colLettersToNum(letters: string): number {
|
|
88
|
+
let result = 0;
|
|
89
|
+
for (let i = 0; i < letters.length; i++) {
|
|
90
|
+
result = result * 26 + (letters.charCodeAt(i) - 64);
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Convert 1-based column number to column letters (1→A, 26→Z, 27→AA, ...). */
|
|
96
|
+
export function colNumToLetters(col: number): string {
|
|
97
|
+
let result = "";
|
|
98
|
+
let n = col;
|
|
99
|
+
while (n > 0) {
|
|
100
|
+
n--;
|
|
101
|
+
result = String.fromCharCode(65 + (n % 26)) + result;
|
|
102
|
+
n = Math.floor(n / 26);
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// =============================================================================
|
|
108
|
+
// A1-style cell reference utilities
|
|
109
|
+
// =============================================================================
|
|
110
|
+
|
|
111
|
+
/** Convert A1-style ref to { row, col } (both 1-based). */
|
|
112
|
+
export function refToRowCol(ref: string): { row: number; col: number } | undefined {
|
|
113
|
+
const match = ref.match(/^(\$?)([A-Z]+)(\$?)(\d+)$/);
|
|
114
|
+
if (!match) return undefined;
|
|
115
|
+
return { col: colLettersToNum(match[2]), row: parseInt(match[4], 10) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Convert { row, col } (both 1-based) to A1-style ref. */
|
|
119
|
+
export function rowColToRef(row: number, col: number): string {
|
|
120
|
+
return colNumToLetters(col) + String(row);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// =============================================================================
|
|
124
|
+
// Packed cell key (row, col) → number
|
|
125
|
+
// =============================================================================
|
|
126
|
+
//
|
|
127
|
+
// Internal canonical key for `SheetModel.cells`. Row gets the high bits,
|
|
128
|
+
// column gets the low 14 bits (Excel's max column count is 16384, which fits
|
|
129
|
+
// in 14 bits exactly). The encoding uses multiplication, not bit-shift,
|
|
130
|
+
// because Excel's max row count (1,048,576) combined with the column bits
|
|
131
|
+
// would overflow the 32-bit limit of JavaScript bitwise operators.
|
|
132
|
+
//
|
|
133
|
+
// Stored values stay well under `Number.MAX_SAFE_INTEGER` for all valid
|
|
134
|
+
// Excel coordinates, so the int packing is lossless.
|
|
135
|
+
|
|
136
|
+
const PACK_COL_BITS = 14;
|
|
137
|
+
const PACK_COL_MULT = 1 << PACK_COL_BITS; // 16384
|
|
138
|
+
|
|
139
|
+
/** Pack a (1-based row, 1-based col) pair into a single integer key. */
|
|
140
|
+
export function packRowCol(row: number, col: number): number {
|
|
141
|
+
return row * PACK_COL_MULT + col;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Inverse of `packRowCol`. */
|
|
145
|
+
export function unpackRowCol(packed: number): { row: number; col: number } {
|
|
146
|
+
const row = Math.floor(packed / PACK_COL_MULT);
|
|
147
|
+
const col = packed - row * PACK_COL_MULT;
|
|
148
|
+
return { row, col };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Pack an A1-style ref into the same integer key. Returns -1 for invalid refs. */
|
|
152
|
+
export function packRef(ref: string): number {
|
|
153
|
+
const rc = refToRowCol(ref);
|
|
154
|
+
if (!rc) return -1;
|
|
155
|
+
return packRowCol(rc.row, rc.col);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Inverse of `packRef`. */
|
|
159
|
+
export function unpackRef(packed: number): string {
|
|
160
|
+
const { row, col } = unpackRowCol(packed);
|
|
161
|
+
return rowColToRef(row, col);
|
|
162
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
declare module "fast-formula-parser" {
|
|
2
|
+
interface CellRefArg {
|
|
3
|
+
row: number;
|
|
4
|
+
col: number;
|
|
5
|
+
sheet?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface RangeRefArg {
|
|
9
|
+
from: { row: number; col: number };
|
|
10
|
+
to: { row: number; col: number };
|
|
11
|
+
sheet?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ParserConfig {
|
|
15
|
+
onCell?: (ref: CellRefArg) => unknown;
|
|
16
|
+
onRange?: (ref: RangeRefArg) => unknown;
|
|
17
|
+
functions?: Record<string, (...args: unknown[]) => unknown>;
|
|
18
|
+
functionsNeedContext?: Record<string, (...args: unknown[]) => unknown>;
|
|
19
|
+
onVariable?: (name: string) => unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ParsePosition {
|
|
23
|
+
sheet: string;
|
|
24
|
+
row: number;
|
|
25
|
+
col: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DepRef {
|
|
29
|
+
row?: number;
|
|
30
|
+
col?: number;
|
|
31
|
+
sheet?: string;
|
|
32
|
+
from?: { row: number; col: number };
|
|
33
|
+
to?: { row: number; col: number };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class FormulaParser {
|
|
37
|
+
constructor(config?: ParserConfig);
|
|
38
|
+
parse(formula: string, position: ParsePosition): unknown;
|
|
39
|
+
static DepParser: typeof DepParser;
|
|
40
|
+
static FormulaError: typeof FormulaError;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class DepParser {
|
|
44
|
+
data: DepRef[];
|
|
45
|
+
parse(formula: string, position: ParsePosition): DepRef[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class FormulaError extends Error {
|
|
49
|
+
name: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default FormulaParser;
|
|
53
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Fill Logic
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Pure spreadsheet fill-series computation. Given a source range and a target
|
|
6
|
+
// range, computes the fill values (numeric series, cycle patterns, single-cell
|
|
7
|
+
// increment, or modular copy).
|
|
8
|
+
//
|
|
9
|
+
// No DOM or React dependencies.
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
import type { Command } from "./command_history";
|
|
13
|
+
import type { WorkbookModel } from "./workbook_model";
|
|
14
|
+
import type { FormulaEngine } from "./formula_engine";
|
|
15
|
+
import type { CellRange } from "./selection_model";
|
|
16
|
+
import { rowColToRef } from "./excel_utils";
|
|
17
|
+
import { setCellValueCommand, batchCommand } from "./spreadsheet_commands";
|
|
18
|
+
import { detectCyclePattern } from "./fill_patterns";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compute fill commands for dragging a source range into a target range.
|
|
22
|
+
* Returns a batch command, or null if nothing to fill.
|
|
23
|
+
*/
|
|
24
|
+
export function computeFillCommand(
|
|
25
|
+
workbook: WorkbookModel,
|
|
26
|
+
engine: FormulaEngine | undefined,
|
|
27
|
+
sheetIndex: number,
|
|
28
|
+
sourceRange: { start: { row: number; col: number }; end: { row: number; col: number } },
|
|
29
|
+
fillTarget: CellRange,
|
|
30
|
+
): Command | null {
|
|
31
|
+
const sheet = workbook.sheets[sheetIndex];
|
|
32
|
+
const srcRowCount = sourceRange.end.row - sourceRange.start.row + 1;
|
|
33
|
+
const srcColCount = sourceRange.end.col - sourceRange.start.col + 1;
|
|
34
|
+
const nt = {
|
|
35
|
+
startRow: Math.min(fillTarget.start.row, fillTarget.end.row),
|
|
36
|
+
startCol: Math.min(fillTarget.start.col, fillTarget.end.col),
|
|
37
|
+
endRow: Math.max(fillTarget.start.row, fillTarget.end.row),
|
|
38
|
+
endCol: Math.max(fillTarget.start.col, fillTarget.end.col),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Detect numeric series with constant increment per column
|
|
42
|
+
const fillsVertically = nt.startCol >= sourceRange.start.col && nt.endCol <= sourceRange.end.col;
|
|
43
|
+
const colIncrements = new Map<number, { lastValue: number; increment: number }>();
|
|
44
|
+
if (fillsVertically && srcRowCount >= 2) {
|
|
45
|
+
for (let c = sourceRange.start.col; c <= sourceRange.end.col; c++) {
|
|
46
|
+
const values: number[] = [];
|
|
47
|
+
for (let r = sourceRange.start.row; r <= sourceRange.end.row; r++) {
|
|
48
|
+
const ref = rowColToRef(r, c);
|
|
49
|
+
const cell = sheet.getCell(ref);
|
|
50
|
+
const dv = cell?.displayValue ?? "";
|
|
51
|
+
const num = Number(dv);
|
|
52
|
+
if (isNaN(num) || dv.trim() === "") break;
|
|
53
|
+
values.push(num);
|
|
54
|
+
}
|
|
55
|
+
if (values.length === srcRowCount) {
|
|
56
|
+
const inc = values[1] - values[0];
|
|
57
|
+
let constant = true;
|
|
58
|
+
for (let i = 2; i < values.length; i++) {
|
|
59
|
+
if (Math.abs((values[i] - values[i - 1]) - inc) > 1e-10) {
|
|
60
|
+
constant = false;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (constant) {
|
|
65
|
+
colIncrements.set(c, { lastValue: values[values.length - 1], increment: inc });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Detect date series per column. We parse displayValue as a date; if every
|
|
72
|
+
// source value parses, and the day-deltas are constant, we can extrapolate.
|
|
73
|
+
// Stored format string is preserved by re-emitting in ISO YYYY-MM-DD.
|
|
74
|
+
const colDates = new Map<number, { lastDay: number; increment: number }>();
|
|
75
|
+
if (fillsVertically && srcRowCount >= 2) {
|
|
76
|
+
for (let c = sourceRange.start.col; c <= sourceRange.end.col; c++) {
|
|
77
|
+
if (colIncrements.has(c)) continue;
|
|
78
|
+
const days: number[] = [];
|
|
79
|
+
let allParsed = true;
|
|
80
|
+
for (let r = sourceRange.start.row; r <= sourceRange.end.row; r++) {
|
|
81
|
+
const ref = rowColToRef(r, c);
|
|
82
|
+
const cell = sheet.getCell(ref);
|
|
83
|
+
const dv = cell?.displayValue ?? "";
|
|
84
|
+
const day = parseDateToDayNumber(dv);
|
|
85
|
+
if (day === undefined) { allParsed = false; break; }
|
|
86
|
+
days.push(day);
|
|
87
|
+
}
|
|
88
|
+
if (allParsed && days.length === srcRowCount) {
|
|
89
|
+
const inc = days[1] - days[0];
|
|
90
|
+
let constant = true;
|
|
91
|
+
for (let i = 2; i < days.length; i++) {
|
|
92
|
+
if (days[i] - days[i - 1] !== inc) { constant = false; break; }
|
|
93
|
+
}
|
|
94
|
+
if (constant && inc !== 0) {
|
|
95
|
+
colDates.set(c, { lastDay: days[days.length - 1], increment: inc });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Detect cycle patterns (months, days, quarters) per column
|
|
102
|
+
const colCycles = new Map<number, { list: string[]; startIndex: number; count: number }>();
|
|
103
|
+
if (fillsVertically) {
|
|
104
|
+
for (let c = sourceRange.start.col; c <= sourceRange.end.col; c++) {
|
|
105
|
+
if (colIncrements.has(c)) continue;
|
|
106
|
+
const values: string[] = [];
|
|
107
|
+
for (let r = sourceRange.start.row; r <= sourceRange.end.row; r++) {
|
|
108
|
+
const ref = rowColToRef(r, c);
|
|
109
|
+
const cell = sheet.getCell(ref);
|
|
110
|
+
values.push(cell?.displayValue ?? "");
|
|
111
|
+
}
|
|
112
|
+
const match = detectCyclePattern(values.filter((v) => v !== ""));
|
|
113
|
+
if (match) {
|
|
114
|
+
colCycles.set(c, { ...match, count: values.filter((v) => v !== "").length });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const commands: Command[] = [];
|
|
120
|
+
for (let r = nt.startRow; r <= nt.endRow; r++) {
|
|
121
|
+
for (let c = nt.startCol; c <= nt.endCol; c++) {
|
|
122
|
+
// Multi-cell numeric series
|
|
123
|
+
const series = colIncrements.get(c);
|
|
124
|
+
if (series) {
|
|
125
|
+
const fillDown = nt.startRow > sourceRange.end.row;
|
|
126
|
+
const step = fillDown
|
|
127
|
+
? (r - sourceRange.end.row)
|
|
128
|
+
: (sourceRange.start.row - r);
|
|
129
|
+
const value = fillDown
|
|
130
|
+
? series.lastValue + step * series.increment
|
|
131
|
+
: (series.lastValue - (srcRowCount - 1) * series.increment) - step * series.increment;
|
|
132
|
+
commands.push(setCellValueCommand(workbook, engine, sheetIndex, r, c, String(value)));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Date series (constant-day-delta over parseable date values)
|
|
137
|
+
const dateSeries = colDates.get(c);
|
|
138
|
+
if (dateSeries) {
|
|
139
|
+
const fillDown = nt.startRow > sourceRange.end.row;
|
|
140
|
+
const step = fillDown ? (r - sourceRange.end.row) : (sourceRange.start.row - r);
|
|
141
|
+
const day = fillDown
|
|
142
|
+
? dateSeries.lastDay + step * dateSeries.increment
|
|
143
|
+
: (dateSeries.lastDay - (srcRowCount - 1) * dateSeries.increment) - step * dateSeries.increment;
|
|
144
|
+
commands.push(setCellValueCommand(workbook, engine, sheetIndex, r, c, dayNumberToIso(day)));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Cycle pattern (month/day/quarter)
|
|
149
|
+
const cycle = colCycles.get(c);
|
|
150
|
+
if (cycle) {
|
|
151
|
+
const fillDown = nt.startRow > sourceRange.end.row;
|
|
152
|
+
const step = fillDown ? (r - sourceRange.end.row) : (sourceRange.start.row - r);
|
|
153
|
+
const idx = fillDown
|
|
154
|
+
? (cycle.startIndex + cycle.count + step - 1) % cycle.list.length
|
|
155
|
+
: (cycle.startIndex - step + cycle.list.length) % cycle.list.length;
|
|
156
|
+
commands.push(setCellValueCommand(workbook, engine, sheetIndex, r, c, cycle.list[idx]));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Single-cell numeric: increment by 1
|
|
161
|
+
if (srcRowCount === 1 && srcColCount === 1) {
|
|
162
|
+
const srcRef = rowColToRef(sourceRange.start.row, sourceRange.start.col);
|
|
163
|
+
const srcCell = sheet.getCell(srcRef);
|
|
164
|
+
if (srcCell) {
|
|
165
|
+
const num = Number(srcCell.displayValue);
|
|
166
|
+
if (!isNaN(num) && srcCell.displayValue.trim() !== "") {
|
|
167
|
+
const rowOffset = r - sourceRange.start.row;
|
|
168
|
+
const colOffset = c - sourceRange.start.col;
|
|
169
|
+
const offset = Math.max(Math.abs(rowOffset), Math.abs(colOffset));
|
|
170
|
+
const sign = (rowOffset + colOffset) >= 0 ? 1 : -1;
|
|
171
|
+
commands.push(setCellValueCommand(workbook, engine, sheetIndex, r, c, String(num + sign * offset)));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Default: copy via modular wrapping
|
|
178
|
+
const srcRow = sourceRange.start.row + ((r - nt.startRow) % srcRowCount);
|
|
179
|
+
const srcCol = sourceRange.start.col + ((c - nt.startCol) % srcColCount);
|
|
180
|
+
const srcRef = rowColToRef(srcRow, srcCol);
|
|
181
|
+
const srcCell = sheet.getCell(srcRef);
|
|
182
|
+
const srcValue = srcCell?.formula ? `=${srcCell.formula}` : srcCell?.displayValue ?? "";
|
|
183
|
+
commands.push(setCellValueCommand(workbook, engine, sheetIndex, r, c, srcValue));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (commands.length === 0) return null;
|
|
188
|
+
return batchCommand("Fill cells", commands);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// =============================================================================
|
|
192
|
+
// Date helpers
|
|
193
|
+
// =============================================================================
|
|
194
|
+
|
|
195
|
+
const MS_PER_DAY = 86_400_000;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Parse common date display formats into a "day number" (UTC day count since
|
|
199
|
+
* epoch, integer). Returns undefined if not parseable. Recognises:
|
|
200
|
+
* - ISO `YYYY-MM-DD`
|
|
201
|
+
* - US `m/d/yyyy` and `mm/dd/yyyy`
|
|
202
|
+
* - European `d/m/yyyy` (only when day > 12, otherwise ambiguous → US wins)
|
|
203
|
+
* - `d-MMM-yy` / `d-MMM-yyyy` and `d MMM yyyy` (`Mar 18 2026`)
|
|
204
|
+
*/
|
|
205
|
+
function parseDateToDayNumber(s: string): number | undefined {
|
|
206
|
+
if (!s) return undefined;
|
|
207
|
+
const t = s.trim();
|
|
208
|
+
|
|
209
|
+
const iso = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
|
210
|
+
if (iso) return toDay(parseInt(iso[1], 10), parseInt(iso[2], 10), parseInt(iso[3], 10));
|
|
211
|
+
|
|
212
|
+
const us = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/);
|
|
213
|
+
if (us) {
|
|
214
|
+
const m = parseInt(us[1], 10);
|
|
215
|
+
const d = parseInt(us[2], 10);
|
|
216
|
+
let y = parseInt(us[3], 10);
|
|
217
|
+
if (y < 100) y += y < 30 ? 2000 : 1900;
|
|
218
|
+
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return toDay(y, m, d);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// d-MMM-yy / d-MMM-yyyy / d MMM yyyy
|
|
222
|
+
const monthMap: Record<string, number> = {
|
|
223
|
+
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
|
224
|
+
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
|
|
225
|
+
};
|
|
226
|
+
const named = t.match(/^(\d{1,2})[\s-]+([A-Za-z]{3,})[\s-]+(\d{2,4})$/);
|
|
227
|
+
if (named) {
|
|
228
|
+
const d = parseInt(named[1], 10);
|
|
229
|
+
const mname = named[2].slice(0, 3).toLowerCase();
|
|
230
|
+
const m = monthMap[mname];
|
|
231
|
+
let y = parseInt(named[3], 10);
|
|
232
|
+
if (m && y) {
|
|
233
|
+
if (y < 100) y += y < 30 ? 2000 : 1900;
|
|
234
|
+
return toDay(y, m, d);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Last resort: native Date.parse — handles many locale variants. Strip time
|
|
239
|
+
// by floor-dividing.
|
|
240
|
+
const parsed = Date.parse(t);
|
|
241
|
+
if (!Number.isNaN(parsed)) {
|
|
242
|
+
return Math.floor(parsed / MS_PER_DAY);
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function toDay(year: number, month: number, day: number): number {
|
|
248
|
+
return Math.floor(Date.UTC(year, month - 1, day) / MS_PER_DAY);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function dayNumberToIso(day: number): string {
|
|
252
|
+
const date = new Date(day * MS_PER_DAY);
|
|
253
|
+
const y = date.getUTCFullYear();
|
|
254
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
255
|
+
const d = String(date.getUTCDate()).padStart(2, "0");
|
|
256
|
+
return `${y}-${m}-${d}`;
|
|
257
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { detectCyclePattern, continueCycle } from "./fill_patterns";
|
|
2
|
+
|
|
3
|
+
describe("detectCyclePattern", () => {
|
|
4
|
+
test("detects short month names", () => {
|
|
5
|
+
const result = detectCyclePattern(["Jan", "Feb", "Mar"]);
|
|
6
|
+
expect(result).not.toBeNull();
|
|
7
|
+
expect(result!.list[0]).toBe("Jan");
|
|
8
|
+
expect(result!.startIndex).toBe(0);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("detects full month names mid-cycle", () => {
|
|
12
|
+
const result = detectCyclePattern(["March", "April", "May"]);
|
|
13
|
+
expect(result).not.toBeNull();
|
|
14
|
+
expect(result!.startIndex).toBe(2);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("detects short day names", () => {
|
|
18
|
+
const result = detectCyclePattern(["Mon", "Tue", "Wed"]);
|
|
19
|
+
expect(result).not.toBeNull();
|
|
20
|
+
expect(result!.list).toContain("Sun");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("detects full day names", () => {
|
|
24
|
+
const result = detectCyclePattern(["Friday", "Saturday", "Sunday"]);
|
|
25
|
+
expect(result).not.toBeNull();
|
|
26
|
+
expect(result!.startIndex).toBe(4);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("detects quarters", () => {
|
|
30
|
+
const result = detectCyclePattern(["Q1", "Q2"]);
|
|
31
|
+
expect(result).not.toBeNull();
|
|
32
|
+
expect(result!.list).toEqual(["Q1", "Q2", "Q3", "Q4"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("case insensitive matching", () => {
|
|
36
|
+
const result = detectCyclePattern(["jan", "feb", "mar"]);
|
|
37
|
+
expect(result).not.toBeNull();
|
|
38
|
+
expect(result!.list[0]).toBe("Jan");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns null for no match", () => {
|
|
42
|
+
expect(detectCyclePattern(["Hello", "World"])).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("returns null for empty array", () => {
|
|
46
|
+
expect(detectCyclePattern([])).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns null for non-contiguous values", () => {
|
|
50
|
+
expect(detectCyclePattern(["Jan", "Mar", "May"])).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("wraps around end of cycle", () => {
|
|
54
|
+
const result = detectCyclePattern(["Nov", "Dec"]);
|
|
55
|
+
expect(result).not.toBeNull();
|
|
56
|
+
expect(result!.startIndex).toBe(10);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("single value matches", () => {
|
|
60
|
+
const result = detectCyclePattern(["Q3"]);
|
|
61
|
+
expect(result).not.toBeNull();
|
|
62
|
+
expect(result!.startIndex).toBe(2);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("continueCycle", () => {
|
|
67
|
+
test("continues month sequence", () => {
|
|
68
|
+
const match = detectCyclePattern(["Jan", "Feb", "Mar"])!;
|
|
69
|
+
const next = continueCycle(match, 3, 3);
|
|
70
|
+
expect(next).toEqual(["Apr", "May", "Jun"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("wraps around end of year", () => {
|
|
74
|
+
const match = detectCyclePattern(["Nov", "Dec"])!;
|
|
75
|
+
const next = continueCycle(match, 2, 3);
|
|
76
|
+
expect(next).toEqual(["Jan", "Feb", "Mar"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("continues quarter sequence", () => {
|
|
80
|
+
const match = detectCyclePattern(["Q3", "Q4"])!;
|
|
81
|
+
const next = continueCycle(match, 2, 2);
|
|
82
|
+
expect(next).toEqual(["Q1", "Q2"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("continues day sequence wrapping", () => {
|
|
86
|
+
const match = detectCyclePattern(["Sat", "Sun"])!;
|
|
87
|
+
const next = continueCycle(match, 2, 2);
|
|
88
|
+
expect(next).toEqual(["Mon", "Tue"]);
|
|
89
|
+
});
|
|
90
|
+
});
|