@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,719 @@
1
+ import type {
2
+ ParsedConditionalFormat,
3
+ ParsedCfRule,
4
+ ParsedColorScaleRule,
5
+ ParsedDataBarRule,
6
+ ParsedIconSetRule,
7
+ ParsedStyleRule,
8
+ CfThreshold,
9
+ ParsedCfStyle,
10
+ } from "./excel_cf_types";
11
+ import { colLettersToNum } from "./excel_utils";
12
+ import type { ParsedRow } from "./types";
13
+
14
+ // =============================================================================
15
+ // Types
16
+ // =============================================================================
17
+
18
+ export interface CfOverlay {
19
+ /** Background color override from color scale or style rule */
20
+ backgroundColor?: string;
21
+ /** Font color override from style rule */
22
+ fontColor?: string;
23
+ /** Font bold override from style rule */
24
+ fontBold?: boolean;
25
+ /** Font italic override from style rule */
26
+ fontItalic?: boolean;
27
+ /** Data bar: width as percentage 0-100 */
28
+ dataBarWidth?: number;
29
+ /** Data bar: color */
30
+ dataBarColor?: string;
31
+ /** Data bar: whether to show cell value */
32
+ dataBarShowValue?: boolean;
33
+ /** Icon set: SVG string */
34
+ iconSvg?: string;
35
+ /** Icon set: whether to show cell value */
36
+ iconShowValue?: boolean;
37
+ }
38
+
39
+ // =============================================================================
40
+ // Evaluation
41
+ // =============================================================================
42
+
43
+ /**
44
+ * Evaluate all conditional formatting rules and build a map from "row:col" to overlay.
45
+ * Rules are evaluated by priority (lower = higher priority).
46
+ */
47
+ export function evaluateConditionalFormats(
48
+ cfs: ParsedConditionalFormat[],
49
+ rows: ParsedRow[],
50
+ getIconSvg: (iconSet: string, index: number) => string | undefined,
51
+ evaluateFormula?: (formula: string, sheetName: string, row: number, col: number) => unknown,
52
+ date1904 = false,
53
+ ): Map<string, CfOverlay> {
54
+ const overlays = new Map<string, CfOverlay>();
55
+
56
+ // Build numeric values index for percentile/average calculations
57
+ const cellValues = buildCellValueIndex(rows);
58
+
59
+ for (const cf of cfs) {
60
+ const targetCells = parseRef(cf.ref, rows);
61
+ if (targetCells.length === 0) continue;
62
+
63
+ // Origin = top-left of CF range (for shifting relative formula references)
64
+ const origin = parseRefOrigin(cf.ref);
65
+
66
+ // Sort rules by priority (lower number = higher priority)
67
+ const sortedRules = [...cf.rules].sort((a, b) => a.priority - b.priority);
68
+
69
+ // Cells that hit a stopIfTrue rule are skipped by subsequent rules within
70
+ // the same conditional formatting block (matches Excel's behavior).
71
+ const stoppedCells = new Set<string>();
72
+
73
+ for (const rule of sortedRules) {
74
+ const visibleTargets = stoppedCells.size === 0
75
+ ? targetCells
76
+ : targetCells.filter((c) => !stoppedCells.has(c.key));
77
+ if (visibleTargets.length === 0) continue;
78
+
79
+ const matched = applyRule(rule, visibleTargets, cellValues, overlays, getIconSvg, evaluateFormula, date1904, origin);
80
+
81
+ if (rule.stopIfTrue) {
82
+ for (const key of matched) stoppedCells.add(key);
83
+ }
84
+ }
85
+ }
86
+
87
+ return overlays;
88
+ }
89
+
90
+ // =============================================================================
91
+ // Rule application
92
+ // =============================================================================
93
+
94
+ function applyRule(
95
+ rule: ParsedCfRule,
96
+ targetCells: CellRef[],
97
+ cellValues: Map<string, number>,
98
+ overlays: Map<string, CfOverlay>,
99
+ getIconSvg: (iconSet: string, index: number) => string | undefined,
100
+ evaluateFormula?: (formula: string, sheetName: string, row: number, col: number) => unknown,
101
+ date1904 = false,
102
+ origin?: { row: number; col: number },
103
+ ): Set<string> {
104
+ switch (rule.ruleType) {
105
+ case "colorScale":
106
+ return applyColorScale(rule, targetCells, cellValues, overlays);
107
+ case "dataBar":
108
+ return applyDataBar(rule, targetCells, cellValues, overlays);
109
+ case "iconSet":
110
+ return applyIconSet(rule, targetCells, cellValues, overlays, getIconSvg);
111
+ case "style":
112
+ return applyStyleRule(rule, targetCells, cellValues, overlays, evaluateFormula, date1904, origin);
113
+ }
114
+ }
115
+
116
+ function minMax(values: number[]): { min: number; max: number } {
117
+ let min = values[0];
118
+ let max = values[0];
119
+ for (let i = 1; i < values.length; i++) {
120
+ if (values[i] < min) min = values[i];
121
+ if (values[i] > max) max = values[i];
122
+ }
123
+ return { min, max };
124
+ }
125
+
126
+ function applyColorScale(
127
+ rule: ParsedColorScaleRule,
128
+ targetCells: CellRef[],
129
+ cellValues: Map<string, number>,
130
+ overlays: Map<string, CfOverlay>,
131
+ ): Set<string> {
132
+ const matched = new Set<string>();
133
+ const numericCells = targetCells.filter((c) => cellValues.has(c.key));
134
+ if (numericCells.length === 0) return matched;
135
+
136
+ const values = numericCells.map((c) => cellValues.get(c.key)!);
137
+ const { min, max } = minMax(values);
138
+ if (min === max) return matched;
139
+
140
+ for (const cell of numericCells) {
141
+ const value = cellValues.get(cell.key)!;
142
+ const ratio = (value - min) / (max - min);
143
+ const color = interpolateColors(rule.colors, ratio);
144
+ const overlay = overlays.get(cell.key) ?? {};
145
+ overlay.backgroundColor = color;
146
+ overlays.set(cell.key, overlay);
147
+ matched.add(cell.key);
148
+ }
149
+ return matched;
150
+ }
151
+
152
+ function applyDataBar(
153
+ rule: ParsedDataBarRule,
154
+ targetCells: CellRef[],
155
+ cellValues: Map<string, number>,
156
+ overlays: Map<string, CfOverlay>,
157
+ ): Set<string> {
158
+ const matched = new Set<string>();
159
+ const numericCells = targetCells.filter((c) => cellValues.has(c.key));
160
+ if (numericCells.length === 0) return matched;
161
+
162
+ const values = numericCells.map((c) => cellValues.get(c.key)!);
163
+ const { min, max } = minMax(values);
164
+ const range = max - min;
165
+
166
+ for (const cell of numericCells) {
167
+ const value = cellValues.get(cell.key)!;
168
+ const ratio = range === 0 ? 0.5 : (value - min) / range;
169
+ const width = rule.minLength + ratio * (rule.maxLength - rule.minLength);
170
+ const overlay = overlays.get(cell.key) ?? {};
171
+ overlay.dataBarWidth = Math.max(0, Math.min(100, width));
172
+ overlay.dataBarColor = rule.color;
173
+ overlay.dataBarShowValue = rule.showValue;
174
+ overlays.set(cell.key, overlay);
175
+ matched.add(cell.key);
176
+ }
177
+ return matched;
178
+ }
179
+
180
+ function applyIconSet(
181
+ rule: ParsedIconSetRule,
182
+ targetCells: CellRef[],
183
+ cellValues: Map<string, number>,
184
+ overlays: Map<string, CfOverlay>,
185
+ getIconSvg: (iconSet: string, index: number) => string | undefined,
186
+ ): Set<string> {
187
+ const matched = new Set<string>();
188
+ const numericCells = targetCells.filter((c) => cellValues.has(c.key));
189
+ if (numericCells.length === 0) return matched;
190
+
191
+ const values = numericCells.map((c) => cellValues.get(c.key)!);
192
+ const { min, max } = minMax(values);
193
+ const range = max - min;
194
+
195
+ // Determine icon count from thresholds
196
+ const iconCount = rule.thresholds.length > 0 ? rule.thresholds.length : 3;
197
+
198
+ for (const cell of numericCells) {
199
+ const value = cellValues.get(cell.key)!;
200
+ const percentile = range === 0 ? 50 : ((value - min) / range) * 100;
201
+
202
+ let iconIndex = getIconIndex(percentile, rule.thresholds, iconCount);
203
+ if (rule.reverse) {
204
+ iconIndex = iconCount - 1 - iconIndex;
205
+ }
206
+
207
+ const svg = getIconSvg(rule.iconSet, iconIndex);
208
+ if (svg) {
209
+ const overlay = overlays.get(cell.key) ?? {};
210
+ overlay.iconSvg = svg;
211
+ overlay.iconShowValue = rule.showValue;
212
+ overlays.set(cell.key, overlay);
213
+ matched.add(cell.key);
214
+ }
215
+ }
216
+ return matched;
217
+ }
218
+
219
+ function applyStyleRule(
220
+ rule: ParsedStyleRule,
221
+ targetCells: CellRef[],
222
+ cellValues: Map<string, number>,
223
+ overlays: Map<string, CfOverlay>,
224
+ evaluateFormula?: (formula: string, sheetName: string, row: number, col: number) => unknown,
225
+ date1904 = false,
226
+ origin?: { row: number; col: number },
227
+ ): Set<string> {
228
+ const matched = new Set<string>();
229
+ if (!rule.style) return matched;
230
+
231
+ // Precompute threshold for top10/aboveAverage/duplicates to avoid O(n²)
232
+ let qualifyingSet: Set<string> | undefined;
233
+ if (rule.type === "top10") {
234
+ qualifyingSet = computeTop10Set(rule, targetCells, cellValues);
235
+ } else if (rule.type === "aboveAverage") {
236
+ qualifyingSet = computeAboveAverageSet(rule, targetCells, cellValues);
237
+ } else if (rule.operator === "duplicateValues") {
238
+ qualifyingSet = computeDuplicateSet(targetCells);
239
+ } else if (rule.operator === "uniqueValues") {
240
+ qualifyingSet = computeUniqueSet(targetCells);
241
+ }
242
+
243
+ for (const cell of targetCells) {
244
+ const matches = qualifyingSet
245
+ ? qualifyingSet.has(cell.key)
246
+ : evaluateStyleCondition(rule, cell, cellValues, evaluateFormula, date1904, origin);
247
+ if (matches) {
248
+ const overlay = overlays.get(cell.key) ?? {};
249
+ applyCfStyle(overlay, rule.style);
250
+ overlays.set(cell.key, overlay);
251
+ matched.add(cell.key);
252
+ }
253
+ }
254
+ return matched;
255
+ }
256
+
257
+ function evaluateStyleCondition(
258
+ rule: ParsedStyleRule,
259
+ cell: CellRef,
260
+ cellValues: Map<string, number>,
261
+ evaluateFormula?: (formula: string, sheetName: string, row: number, col: number) => unknown,
262
+ date1904 = false,
263
+ origin?: { row: number; col: number },
264
+ ): boolean {
265
+ switch (rule.type) {
266
+ case "cellIs":
267
+ return evaluateCellIs(rule, cell, cellValues);
268
+ case "containsText":
269
+ return evaluateContainsText(rule, cell);
270
+ case "expression":
271
+ return evaluateExpressionRule(rule, cell, evaluateFormula, origin);
272
+ case "timePeriod":
273
+ return evaluateTimePeriodRule(rule, cell, cellValues, date1904);
274
+ default:
275
+ return false;
276
+ }
277
+ }
278
+
279
+ function evaluateCellIs(
280
+ rule: ParsedStyleRule,
281
+ cell: CellRef,
282
+ cellValues: Map<string, number>,
283
+ ): boolean {
284
+ const cellValue = cellValues.get(cell.key);
285
+ if (cellValue === undefined || !rule.formulae || rule.formulae.length === 0) return false;
286
+
287
+ const threshold = Number(rule.formulae[0]);
288
+ if (isNaN(threshold)) return false;
289
+
290
+ switch (rule.operator) {
291
+ case "greaterThan": return cellValue > threshold;
292
+ case "greaterThanOrEqual": return cellValue >= threshold;
293
+ case "lessThan": return cellValue < threshold;
294
+ case "lessThanOrEqual": return cellValue <= threshold;
295
+ case "equal": return cellValue === threshold;
296
+ case "notEqual": return cellValue !== threshold;
297
+ case "between": {
298
+ if (rule.formulae.length < 2) return false;
299
+ const upper = Number(rule.formulae[1]);
300
+ if (isNaN(upper)) return false;
301
+ return cellValue >= threshold && cellValue <= upper;
302
+ }
303
+ case "notBetween": {
304
+ if (rule.formulae.length < 2) return false;
305
+ const upper = Number(rule.formulae[1]);
306
+ if (isNaN(upper)) return false;
307
+ return cellValue < threshold || cellValue > upper;
308
+ }
309
+ default: return false;
310
+ }
311
+ }
312
+
313
+ function evaluateContainsText(rule: ParsedStyleRule, cell: CellRef): boolean {
314
+ switch (rule.operator) {
315
+ case "containsBlanks":
316
+ return cell.text.trim() === "";
317
+ case "notContainsBlanks":
318
+ return cell.text.trim() !== "";
319
+ case "containsErrors":
320
+ return cell.text.startsWith("#");
321
+ case "notContainsErrors":
322
+ return !cell.text.startsWith("#");
323
+ case "beginsWith":
324
+ if (!rule.text) return false;
325
+ return cell.text.toLowerCase().startsWith(rule.text.toLowerCase());
326
+ case "endsWith":
327
+ if (!rule.text) return false;
328
+ return cell.text.toLowerCase().endsWith(rule.text.toLowerCase());
329
+ case "notContainsText":
330
+ if (!rule.text) return true;
331
+ return !cell.text.toLowerCase().includes(rule.text.toLowerCase());
332
+ case "containsText":
333
+ default:
334
+ if (!rule.text) return false;
335
+ return cell.text.toLowerCase().includes(rule.text.toLowerCase());
336
+ }
337
+ }
338
+
339
+ function evaluateExpressionRule(
340
+ rule: ParsedStyleRule,
341
+ cell: CellRef,
342
+ evaluateFormula?: (formula: string, sheetName: string, row: number, col: number) => unknown,
343
+ origin?: { row: number; col: number },
344
+ ): boolean {
345
+ if (!evaluateFormula || !rule.formulae || rule.formulae.length === 0) return false;
346
+ const baseFormula = String(rule.formulae[0]);
347
+ const dRow = origin ? cell.row - origin.row : 0;
348
+ const dCol = origin ? cell.col - origin.col : 0;
349
+ const formula = shiftFormulaRefs(baseFormula, dRow, dCol);
350
+ const result = evaluateFormula(formula, "", cell.row, cell.col);
351
+ return !!result && result !== 0;
352
+ }
353
+
354
+ /** Excel serial date to JS Date. Supports both 1900 (default) and 1904 (Mac) date systems. */
355
+ export function excelSerialToDate(serial: number, date1904 = false): Date {
356
+ if (date1904) {
357
+ // 1904 system: day 0 = Jan 1, 1904. No leap year bug.
358
+ const ms = serial * 86400000;
359
+ return new Date(Date.UTC(1904, 0, 1) + ms);
360
+ }
361
+ // 1900 system: day 1 = Jan 1, 1900. Leap year bug: day 60 = Feb 29, 1900 (doesn't exist).
362
+ const adjusted = serial > 60 ? serial - 1 : serial;
363
+ const ms = (adjusted - 1) * 86400000;
364
+ return new Date(Date.UTC(1900, 0, 1) + ms);
365
+ }
366
+
367
+ function evaluateTimePeriodRule(
368
+ rule: ParsedStyleRule,
369
+ cell: CellRef,
370
+ cellValues: Map<string, number>,
371
+ date1904 = false,
372
+ ): boolean {
373
+ const serial = cellValues.get(cell.key);
374
+ if (serial === undefined || serial < 1) return false;
375
+
376
+ const cellDate = excelSerialToDate(serial, date1904);
377
+ const now = new Date();
378
+
379
+ switch (rule.timePeriod) {
380
+ case "today": {
381
+ return sameDay(cellDate, now);
382
+ }
383
+ case "yesterday": {
384
+ const yesterday = new Date(now);
385
+ yesterday.setDate(yesterday.getDate() - 1);
386
+ return sameDay(cellDate, yesterday);
387
+ }
388
+ case "tomorrow": {
389
+ const tomorrow = new Date(now);
390
+ tomorrow.setDate(tomorrow.getDate() + 1);
391
+ return sameDay(cellDate, tomorrow);
392
+ }
393
+ case "last7Days": {
394
+ const weekAgo = new Date(now);
395
+ weekAgo.setDate(weekAgo.getDate() - 7);
396
+ return cellDate >= weekAgo && cellDate <= now;
397
+ }
398
+ case "lastWeek": {
399
+ const startOfThisWeek = getStartOfWeek(now);
400
+ const startOfLastWeek = new Date(startOfThisWeek);
401
+ startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
402
+ return cellDate >= startOfLastWeek && cellDate < startOfThisWeek;
403
+ }
404
+ case "thisWeek": {
405
+ const startOfWeek = getStartOfWeek(now);
406
+ const endOfWeek = new Date(startOfWeek);
407
+ endOfWeek.setDate(endOfWeek.getDate() + 7);
408
+ return cellDate >= startOfWeek && cellDate < endOfWeek;
409
+ }
410
+ case "nextWeek": {
411
+ const startOfThisWeek = getStartOfWeek(now);
412
+ const startOfNextWeek = new Date(startOfThisWeek);
413
+ startOfNextWeek.setDate(startOfNextWeek.getDate() + 7);
414
+ const endOfNextWeek = new Date(startOfNextWeek);
415
+ endOfNextWeek.setDate(endOfNextWeek.getDate() + 7);
416
+ return cellDate >= startOfNextWeek && cellDate < endOfNextWeek;
417
+ }
418
+ case "lastMonth": {
419
+ const firstOfThisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
420
+ const firstOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
421
+ return cellDate >= firstOfLastMonth && cellDate < firstOfThisMonth;
422
+ }
423
+ case "thisMonth": {
424
+ return cellDate.getFullYear() === now.getFullYear() && cellDate.getMonth() === now.getMonth();
425
+ }
426
+ case "nextMonth": {
427
+ const nextMonth = now.getMonth() + 1;
428
+ const nextYear = nextMonth > 11 ? now.getFullYear() + 1 : now.getFullYear();
429
+ return cellDate.getFullYear() === nextYear && cellDate.getMonth() === (nextMonth % 12);
430
+ }
431
+ default:
432
+ return false;
433
+ }
434
+ }
435
+
436
+ function sameDay(a: Date, b: Date): boolean {
437
+ return a.getFullYear() === b.getFullYear() &&
438
+ a.getMonth() === b.getMonth() &&
439
+ a.getDate() === b.getDate();
440
+ }
441
+
442
+ function getStartOfWeek(d: Date): Date {
443
+ const day = d.getDay();
444
+ const diff = d.getDate() - day;
445
+ return new Date(d.getFullYear(), d.getMonth(), diff);
446
+ }
447
+
448
+ function computeTop10Set(
449
+ rule: ParsedStyleRule,
450
+ targetCells: CellRef[],
451
+ cellValues: Map<string, number>,
452
+ ): Set<string> {
453
+ const result = new Set<string>();
454
+ const numericCells = targetCells.filter((c) => cellValues.has(c.key));
455
+ if (numericCells.length === 0) return result;
456
+
457
+ const rank = rule.rank ?? 10;
458
+ let count = rule.percent ? Math.ceil((rank / 100) * numericCells.length) : rank;
459
+ count = Math.max(1, Math.min(count, numericCells.length));
460
+
461
+ const sorted = [...numericCells].sort((a, b) => {
462
+ const va = cellValues.get(a.key)!;
463
+ const vb = cellValues.get(b.key)!;
464
+ return rule.bottom ? va - vb : vb - va;
465
+ });
466
+
467
+ const threshold = cellValues.get(sorted[count - 1].key)!;
468
+ for (const cell of numericCells) {
469
+ const v = cellValues.get(cell.key)!;
470
+ if (rule.bottom ? v <= threshold : v >= threshold) {
471
+ result.add(cell.key);
472
+ }
473
+ }
474
+ return result;
475
+ }
476
+
477
+ function computeAboveAverageSet(
478
+ rule: ParsedStyleRule,
479
+ targetCells: CellRef[],
480
+ cellValues: Map<string, number>,
481
+ ): Set<string> {
482
+ const result = new Set<string>();
483
+ const numericCells = targetCells.filter((c) => cellValues.has(c.key));
484
+ if (numericCells.length === 0) return result;
485
+
486
+ let sum = 0;
487
+ for (const cell of numericCells) {
488
+ sum += cellValues.get(cell.key)!;
489
+ }
490
+ const average = sum / numericCells.length;
491
+
492
+ for (const cell of numericCells) {
493
+ const v = cellValues.get(cell.key)!;
494
+ if (rule.aboveAverage !== false ? v > average : v < average) {
495
+ result.add(cell.key);
496
+ }
497
+ }
498
+ return result;
499
+ }
500
+
501
+ function computeDuplicateSet(targetCells: CellRef[]): Set<string> {
502
+ const result = new Set<string>();
503
+ const valueCounts = new Map<string, string[]>();
504
+ for (const cell of targetCells) {
505
+ const val = cell.text.toLowerCase();
506
+ if (val === "") continue;
507
+ const keys = valueCounts.get(val) ?? [];
508
+ keys.push(cell.key);
509
+ valueCounts.set(val, keys);
510
+ }
511
+ for (const keys of valueCounts.values()) {
512
+ if (keys.length > 1) {
513
+ for (const k of keys) result.add(k);
514
+ }
515
+ }
516
+ return result;
517
+ }
518
+
519
+ function computeUniqueSet(targetCells: CellRef[]): Set<string> {
520
+ const result = new Set<string>();
521
+ const valueCounts = new Map<string, string[]>();
522
+ for (const cell of targetCells) {
523
+ const val = cell.text.toLowerCase();
524
+ if (val === "") continue;
525
+ const keys = valueCounts.get(val) ?? [];
526
+ keys.push(cell.key);
527
+ valueCounts.set(val, keys);
528
+ }
529
+ for (const keys of valueCounts.values()) {
530
+ if (keys.length === 1) result.add(keys[0]);
531
+ }
532
+ return result;
533
+ }
534
+
535
+ // =============================================================================
536
+ // Helpers
537
+ // =============================================================================
538
+
539
+ interface CellRef {
540
+ key: string;
541
+ row: number;
542
+ col: number;
543
+ text: string;
544
+ }
545
+
546
+ /** Extract the top-left cell of a CF ref range (e.g., "C5:C7" → { row: 5, col: 3 }). */
547
+ function parseRefOrigin(ref: string): { row: number; col: number } | undefined {
548
+ const first = ref.split(/\s+/)[0];
549
+ const m = first.match(/^([A-Z]+)(\d+)/);
550
+ if (!m) return undefined;
551
+ return { row: parseInt(m[2], 10), col: colLettersToNum(m[1]) };
552
+ }
553
+
554
+ /**
555
+ * Shift relative cell references in a formula by dRow/dCol.
556
+ * $-prefixed components (absolute) are not shifted.
557
+ */
558
+ function shiftFormulaRefs(formula: string, dRow: number, dCol: number): string {
559
+ if (dRow === 0 && dCol === 0) return formula;
560
+ return formula.replace(/(\$?)([A-Z]+)(\$?)(\d+)/g, (_, dollarCol, colLetters, dollarRow, rowStr) => {
561
+ const newRow = dollarRow ? rowStr : String(parseInt(rowStr, 10) + dRow);
562
+ const newCol = dollarCol ? colLetters : shiftColumnLetters(colLetters, dCol);
563
+ return `${dollarCol}${newCol}${dollarRow}${newRow}`;
564
+ });
565
+ }
566
+
567
+ function shiftColumnLetters(letters: string, dCol: number): string {
568
+ if (dCol === 0) return letters;
569
+ let colNum = colLettersToNum(letters);
570
+ colNum += dCol;
571
+ if (colNum < 1) colNum = 1;
572
+ let result = "";
573
+ let n = colNum;
574
+ while (n > 0) {
575
+ n--;
576
+ result = String.fromCharCode(65 + (n % 26)) + result;
577
+ n = Math.floor(n / 26);
578
+ }
579
+ return result;
580
+ }
581
+
582
+ function parseRef(ref: string, rows: ParsedRow[]): CellRef[] {
583
+ const cells: CellRef[] = [];
584
+
585
+ // ref can be a range like "A1:C10" or multiple ranges separated by spaces
586
+ const ranges = ref.split(/\s+/);
587
+ for (const range of ranges) {
588
+ const match = range.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/);
589
+ if (!match) continue;
590
+
591
+ const startCol = colLettersToNum(match[1]);
592
+ const startRow = parseInt(match[2], 10);
593
+ const endCol = colLettersToNum(match[3]);
594
+ const endRow = parseInt(match[4], 10);
595
+
596
+ for (const row of rows) {
597
+ if (row.index < startRow || row.index > endRow) continue;
598
+ for (const cell of row.cells) {
599
+ if (cell.column < startCol || cell.column > endCol) continue;
600
+ cells.push({
601
+ key: `${row.index}:${cell.column}`,
602
+ row: row.index,
603
+ col: cell.column,
604
+ text: cell.value,
605
+ });
606
+ }
607
+ }
608
+ }
609
+
610
+ return cells;
611
+ }
612
+
613
+ function buildCellValueIndex(rows: ParsedRow[]): Map<string, number> {
614
+ const index = new Map<string, number>();
615
+ for (const row of rows) {
616
+ for (const cell of row.cells) {
617
+ if (cell.rawValue !== undefined) {
618
+ index.set(`${row.index}:${cell.column}`, cell.rawValue);
619
+ } else {
620
+ const num = parseFloat(cell.value);
621
+ if (!isNaN(num) && isFinite(num)) {
622
+ index.set(`${row.index}:${cell.column}`, num);
623
+ }
624
+ }
625
+ }
626
+ }
627
+ return index;
628
+ }
629
+
630
+ function getIconIndex(
631
+ percentile: number,
632
+ thresholds: CfThreshold[],
633
+ iconCount: number,
634
+ ): number {
635
+ // Default thresholds for common icon counts
636
+ if (thresholds.length === 0) {
637
+ if (iconCount === 3) {
638
+ if (percentile >= 67) return 2;
639
+ if (percentile >= 33) return 1;
640
+ return 0;
641
+ }
642
+ if (iconCount === 4) {
643
+ if (percentile >= 75) return 3;
644
+ if (percentile >= 50) return 2;
645
+ if (percentile >= 25) return 1;
646
+ return 0;
647
+ }
648
+ if (iconCount === 5) {
649
+ if (percentile >= 80) return 4;
650
+ if (percentile >= 60) return 3;
651
+ if (percentile >= 40) return 2;
652
+ if (percentile >= 20) return 1;
653
+ return 0;
654
+ }
655
+ return 0;
656
+ }
657
+
658
+ // Use explicit thresholds
659
+ for (let i = thresholds.length - 1; i >= 0; i--) {
660
+ const t = thresholds[i];
661
+ const value = t.value ?? 0;
662
+ if (t.type === "percent" || t.type === "percentile") {
663
+ if (percentile >= value) return i;
664
+ } else if (t.type === "num") {
665
+ if (percentile >= value) return i;
666
+ }
667
+ }
668
+
669
+ return 0;
670
+ }
671
+
672
+ function interpolateColors(colors: string[], ratio: number): string {
673
+ if (colors.length < 2) return colors[0] ?? "#FFFFFF";
674
+
675
+ const segments = colors.length - 1;
676
+ const segmentRatio = ratio * segments;
677
+ const segmentIndex = Math.min(Math.floor(segmentRatio), segments - 1);
678
+ const localRatio = segmentRatio - segmentIndex;
679
+
680
+ const color1 = parseRgb(colors[segmentIndex]);
681
+ const color2 = parseRgb(colors[segmentIndex + 1]);
682
+
683
+ const r = Math.round(color1.r + (color2.r - color1.r) * localRatio);
684
+ const g = Math.round(color1.g + (color2.g - color1.g) * localRatio);
685
+ const b = Math.round(color1.b + (color2.b - color1.b) * localRatio);
686
+
687
+ return `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`;
688
+ }
689
+
690
+ function toHex2(n: number): string {
691
+ return Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0").toUpperCase();
692
+ }
693
+
694
+ function parseRgb(color: string): { r: number; g: number; b: number } {
695
+ const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
696
+ if (rgbMatch) {
697
+ return {
698
+ r: parseInt(rgbMatch[1], 10),
699
+ g: parseInt(rgbMatch[2], 10),
700
+ b: parseInt(rgbMatch[3], 10),
701
+ };
702
+ }
703
+ const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})/);
704
+ if (hexMatch) {
705
+ return {
706
+ r: parseInt(hexMatch[1], 16),
707
+ g: parseInt(hexMatch[2], 16),
708
+ b: parseInt(hexMatch[3], 16),
709
+ };
710
+ }
711
+ return { r: 0, g: 0, b: 0 };
712
+ }
713
+
714
+ function applyCfStyle(overlay: CfOverlay, style: ParsedCfStyle): void {
715
+ if (style.backgroundColor) overlay.backgroundColor = style.backgroundColor;
716
+ if (style.fontColor) overlay.fontColor = style.fontColor;
717
+ if (style.fontBold) overlay.fontBold = true;
718
+ if (style.fontItalic) overlay.fontItalic = true;
719
+ }