@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,293 @@
1
+ import { evaluateConditionalFormats, excelSerialToDate } from "./excel_cf_evaluate";
2
+ import type { ParsedConditionalFormat } from "./excel_cf_types";
3
+ import type { ParsedRow } from "./types";
4
+
5
+ // =============================================================================
6
+ // excelSerialToDate
7
+ // =============================================================================
8
+
9
+ describe("excelSerialToDate", () => {
10
+ test("day 1 = Jan 1, 1900 (1900 system)", () => {
11
+ const d = excelSerialToDate(1);
12
+ expect(d.getUTCFullYear()).toBe(1900);
13
+ expect(d.getUTCMonth()).toBe(0);
14
+ expect(d.getUTCDate()).toBe(1);
15
+ });
16
+
17
+ test("handles 1900 leap year bug (day 60 = Feb 29)", () => {
18
+ // Excel treats day 60 as Feb 29, 1900 (which doesn't exist).
19
+ // Day 61 should be Mar 1, 1900.
20
+ const d = excelSerialToDate(61);
21
+ expect(d.getUTCFullYear()).toBe(1900);
22
+ expect(d.getUTCMonth()).toBe(2); // March
23
+ expect(d.getUTCDate()).toBe(1);
24
+ });
25
+
26
+ test("modern date: 2024-01-15 = serial 45306", () => {
27
+ // Excel serial for 2024-01-15 is 45306
28
+ const d = excelSerialToDate(45306);
29
+ expect(d.getUTCFullYear()).toBe(2024);
30
+ expect(d.getUTCMonth()).toBe(0);
31
+ expect(d.getUTCDate()).toBe(15);
32
+ });
33
+
34
+ test("1904 date system: day 0 = Jan 1, 1904", () => {
35
+ const d = excelSerialToDate(0, true);
36
+ expect(d.getUTCFullYear()).toBe(1904);
37
+ expect(d.getUTCMonth()).toBe(0);
38
+ expect(d.getUTCDate()).toBe(1);
39
+ });
40
+
41
+ test("1904 date system: day 1 = Jan 2, 1904", () => {
42
+ const d = excelSerialToDate(1, true);
43
+ expect(d.getUTCFullYear()).toBe(1904);
44
+ expect(d.getUTCMonth()).toBe(0);
45
+ expect(d.getUTCDate()).toBe(2);
46
+ });
47
+
48
+ test("1904 date system: modern date", () => {
49
+ // In 1904 system, the serial is 1462 days less than 1900 system
50
+ // 2024-01-15 in 1904 system = 45306 - 1462 = 43844
51
+ const d = excelSerialToDate(43844, true);
52
+ expect(d.getUTCFullYear()).toBe(2024);
53
+ expect(d.getUTCMonth()).toBe(0);
54
+ expect(d.getUTCDate()).toBe(15);
55
+ });
56
+ });
57
+
58
+ // =============================================================================
59
+ // CF expression rules
60
+ // =============================================================================
61
+
62
+ function makeRows(values: Array<{ row: number; col: number; value: string }>): ParsedRow[] {
63
+ const rowMap = new Map<number, ParsedRow>();
64
+ for (const { row, col, value } of values) {
65
+ if (!rowMap.has(row)) {
66
+ rowMap.set(row, { index: row, height: 15, cells: [], hidden: false });
67
+ }
68
+ rowMap.get(row)!.cells.push({
69
+ column: col,
70
+ value,
71
+ content: { type: "plain", text: value },
72
+ style: {},
73
+ });
74
+ }
75
+ return Array.from(rowMap.values());
76
+ }
77
+
78
+ describe("CF expression rules", () => {
79
+ test("expression rule applies when formula returns truthy", () => {
80
+ const rows = makeRows([
81
+ { row: 1, col: 1, value: "150000" },
82
+ ]);
83
+ const cfs: ParsedConditionalFormat[] = [{
84
+ ref: "A1:A1",
85
+ rules: [{
86
+ ruleType: "style" as const,
87
+ priority: 1,
88
+ type: "expression",
89
+ formulae: ["A1>100000"],
90
+ style: { backgroundColor: "#FF0000" },
91
+ }],
92
+ }];
93
+
94
+ const evalFormula = (formula: string) => {
95
+ if (formula === "A1>100000") return true;
96
+ return false;
97
+ };
98
+
99
+ const result = evaluateConditionalFormats(cfs, rows, () => undefined, evalFormula);
100
+ expect(result.get("1:1")?.backgroundColor).toBe("#FF0000");
101
+ });
102
+
103
+ test("expression rule does not apply when formula returns falsy", () => {
104
+ const rows = makeRows([
105
+ { row: 1, col: 1, value: "50000" },
106
+ ]);
107
+ const cfs: ParsedConditionalFormat[] = [{
108
+ ref: "A1:A1",
109
+ rules: [{
110
+ ruleType: "style" as const,
111
+ priority: 1,
112
+ type: "expression",
113
+ formulae: ["A1>100000"],
114
+ style: { backgroundColor: "#FF0000" },
115
+ }],
116
+ }];
117
+
118
+ const evalFormula = () => false;
119
+ const result = evaluateConditionalFormats(cfs, rows, () => undefined, evalFormula);
120
+ expect(result.get("1:1")).toBeUndefined();
121
+ });
122
+
123
+ test("expression rule does not apply when evaluateFormula is not provided", () => {
124
+ const rows = makeRows([
125
+ { row: 1, col: 1, value: "150000" },
126
+ ]);
127
+ const cfs: ParsedConditionalFormat[] = [{
128
+ ref: "A1:A1",
129
+ rules: [{
130
+ ruleType: "style" as const,
131
+ priority: 1,
132
+ type: "expression",
133
+ formulae: ["A1>100000"],
134
+ style: { backgroundColor: "#FF0000" },
135
+ }],
136
+ }];
137
+
138
+ const result = evaluateConditionalFormats(cfs, rows, () => undefined);
139
+ expect(result.get("1:1")).toBeUndefined();
140
+ });
141
+ });
142
+
143
+ // =============================================================================
144
+ // CF timePeriod rules
145
+ // =============================================================================
146
+
147
+ describe("CF timePeriod rules", () => {
148
+ // Helper: convert a JS Date to an Excel serial number (1900 system)
149
+ function dateToSerial(d: Date): number {
150
+ const epoch = Date.UTC(1900, 0, 1);
151
+ const days = Math.floor((d.getTime() - epoch) / 86400000) + 1;
152
+ // Account for 1900 leap year bug
153
+ return days > 59 ? days + 1 : days;
154
+ }
155
+
156
+ test("today rule matches today's serial", () => {
157
+ const now = new Date();
158
+ const serial = dateToSerial(now);
159
+ const rows = makeRows([{ row: 1, col: 1, value: String(serial) }]);
160
+ const cfs: ParsedConditionalFormat[] = [{
161
+ ref: "A1:A1",
162
+ rules: [{
163
+ ruleType: "style" as const,
164
+ priority: 1,
165
+ type: "timePeriod",
166
+ timePeriod: "today",
167
+ style: { backgroundColor: "#00FF00" },
168
+ }],
169
+ }];
170
+
171
+ const result = evaluateConditionalFormats(cfs, rows, () => undefined);
172
+ expect(result.get("1:1")?.backgroundColor).toBe("#00FF00");
173
+ });
174
+
175
+ test("today rule does not match yesterday's serial", () => {
176
+ const yesterday = new Date();
177
+ yesterday.setDate(yesterday.getDate() - 1);
178
+ const serial = dateToSerial(yesterday);
179
+ const rows = makeRows([{ row: 1, col: 1, value: String(serial) }]);
180
+ const cfs: ParsedConditionalFormat[] = [{
181
+ ref: "A1:A1",
182
+ rules: [{
183
+ ruleType: "style" as const,
184
+ priority: 1,
185
+ type: "timePeriod",
186
+ timePeriod: "today",
187
+ style: { backgroundColor: "#00FF00" },
188
+ }],
189
+ }];
190
+
191
+ const result = evaluateConditionalFormats(cfs, rows, () => undefined);
192
+ expect(result.get("1:1")).toBeUndefined();
193
+ });
194
+
195
+ test("yesterday rule matches", () => {
196
+ const yesterday = new Date();
197
+ yesterday.setDate(yesterday.getDate() - 1);
198
+ const serial = dateToSerial(yesterday);
199
+ const rows = makeRows([{ row: 1, col: 1, value: String(serial) }]);
200
+ const cfs: ParsedConditionalFormat[] = [{
201
+ ref: "A1:A1",
202
+ rules: [{
203
+ ruleType: "style" as const,
204
+ priority: 1,
205
+ type: "timePeriod",
206
+ timePeriod: "yesterday",
207
+ style: { backgroundColor: "#00FF00" },
208
+ }],
209
+ }];
210
+
211
+ const result = evaluateConditionalFormats(cfs, rows, () => undefined);
212
+ expect(result.get("1:1")?.backgroundColor).toBe("#00FF00");
213
+ });
214
+
215
+ test("thisMonth rule matches current month", () => {
216
+ const now = new Date();
217
+ const serial = dateToSerial(now);
218
+ const rows = makeRows([{ row: 1, col: 1, value: String(serial) }]);
219
+ const cfs: ParsedConditionalFormat[] = [{
220
+ ref: "A1:A1",
221
+ rules: [{
222
+ ruleType: "style" as const,
223
+ priority: 1,
224
+ type: "timePeriod",
225
+ timePeriod: "thisMonth",
226
+ style: { backgroundColor: "#0000FF" },
227
+ }],
228
+ }];
229
+
230
+ const result = evaluateConditionalFormats(cfs, rows, () => undefined);
231
+ expect(result.get("1:1")?.backgroundColor).toBe("#0000FF");
232
+ });
233
+
234
+ test("last7Days rule matches date within range", () => {
235
+ const threeDaysAgo = new Date();
236
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
237
+ const serial = dateToSerial(threeDaysAgo);
238
+ const rows = makeRows([{ row: 1, col: 1, value: String(serial) }]);
239
+ const cfs: ParsedConditionalFormat[] = [{
240
+ ref: "A1:A1",
241
+ rules: [{
242
+ ruleType: "style" as const,
243
+ priority: 1,
244
+ type: "timePeriod",
245
+ timePeriod: "last7Days",
246
+ style: { backgroundColor: "#FFFF00" },
247
+ }],
248
+ }];
249
+
250
+ const result = evaluateConditionalFormats(cfs, rows, () => undefined);
251
+ expect(result.get("1:1")?.backgroundColor).toBe("#FFFF00");
252
+ });
253
+
254
+ test("non-numeric cell value does not match timePeriod", () => {
255
+ const rows = makeRows([{ row: 1, col: 1, value: "hello" }]);
256
+ const cfs: ParsedConditionalFormat[] = [{
257
+ ref: "A1:A1",
258
+ rules: [{
259
+ ruleType: "style" as const,
260
+ priority: 1,
261
+ type: "timePeriod",
262
+ timePeriod: "today",
263
+ style: { backgroundColor: "#00FF00" },
264
+ }],
265
+ }];
266
+
267
+ const result = evaluateConditionalFormats(cfs, rows, () => undefined);
268
+ expect(result.get("1:1")).toBeUndefined();
269
+ });
270
+
271
+ test("date1904 flag adjusts date calculation", () => {
272
+ // In 1904 mode, serial 0 = Jan 1, 1904
273
+ // The same serial as "today" in 1900 mode will be a different date in 1904 mode
274
+ const now = new Date();
275
+ // Calculate serial for today in 1904 system
276
+ const epoch1904 = Date.UTC(1904, 0, 1);
277
+ const serial1904 = Math.floor((now.getTime() - epoch1904) / 86400000);
278
+ const rows = makeRows([{ row: 1, col: 1, value: String(serial1904) }]);
279
+ const cfs: ParsedConditionalFormat[] = [{
280
+ ref: "A1:A1",
281
+ rules: [{
282
+ ruleType: "style" as const,
283
+ priority: 1,
284
+ type: "timePeriod",
285
+ timePeriod: "today",
286
+ style: { backgroundColor: "#00FF00" },
287
+ }],
288
+ }];
289
+
290
+ const result = evaluateConditionalFormats(cfs, rows, () => undefined, undefined, true);
291
+ expect(result.get("1:1")?.backgroundColor).toBe("#00FF00");
292
+ });
293
+ });