@cj-tech-master/excelts 1.1.0 → 1.4.1

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 (41) hide show
  1. package/dist/browser/excelts.iife.js +1089 -568
  2. package/dist/browser/excelts.iife.js.map +1 -1
  3. package/dist/browser/excelts.iife.min.js +18 -18
  4. package/dist/cjs/doc/data-validations.js +29 -1
  5. package/dist/cjs/index.js +1 -1
  6. package/dist/cjs/utils/cell-format.js +815 -0
  7. package/dist/cjs/utils/parse-sax.js +2 -2
  8. package/dist/cjs/utils/{extra-utils.js → sheet-utils.js} +114 -89
  9. package/dist/cjs/utils/stream-buf.js +15 -4
  10. package/dist/cjs/utils/unzip/parse.js +82 -1
  11. package/dist/cjs/utils/utils.js +13 -17
  12. package/dist/cjs/utils/zip-stream.js +20 -32
  13. package/dist/cjs/xlsx/xform/sheet/data-validations-xform.js +46 -7
  14. package/dist/cjs/xlsx/xlsx.js +1 -2
  15. package/dist/esm/doc/data-validations.js +29 -1
  16. package/dist/esm/index.browser.js +1 -1
  17. package/dist/esm/index.js +1 -1
  18. package/dist/esm/utils/cell-format.js +810 -0
  19. package/dist/esm/utils/parse-sax.js +1 -1
  20. package/dist/esm/utils/{extra-utils.js → sheet-utils.js} +97 -72
  21. package/dist/esm/utils/stream-buf.js +15 -4
  22. package/dist/esm/utils/unzip/parse.js +83 -2
  23. package/dist/esm/utils/utils.js +12 -16
  24. package/dist/esm/utils/zip-stream.js +20 -32
  25. package/dist/esm/xlsx/xform/sheet/data-validations-xform.js +46 -7
  26. package/dist/esm/xlsx/xlsx.js +1 -2
  27. package/dist/types/index.browser.d.ts +1 -1
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/utils/cell-format.d.ts +32 -0
  30. package/dist/types/utils/{extra-utils.d.ts → sheet-utils.d.ts} +51 -52
  31. package/dist/types/utils/utils.d.ts +5 -2
  32. package/package.json +5 -5
  33. package/dist/cjs/utils/browser-buffer-decode.js +0 -13
  34. package/dist/cjs/utils/browser-buffer-encode.js +0 -13
  35. package/dist/cjs/utils/browser.js +0 -6
  36. package/dist/esm/utils/browser-buffer-decode.js +0 -11
  37. package/dist/esm/utils/browser-buffer-encode.js +0 -11
  38. package/dist/esm/utils/browser.js +0 -3
  39. package/dist/types/utils/browser-buffer-decode.d.ts +0 -2
  40. package/dist/types/utils/browser-buffer-encode.d.ts +0 -2
  41. package/dist/types/utils/browser.d.ts +0 -1
@@ -0,0 +1,810 @@
1
+ // oxlint-disable no-control-regex
2
+ /**
3
+ * Excel Cell Format Parser
4
+ * A simplified implementation for formatting cell values according to Excel numFmt patterns
5
+ * Supports: General, percentages, decimals, thousands separators, dates, currencies,
6
+ * scientific notation, fractions, elapsed time, and more
7
+ */
8
+ import { excelToDate } from "./utils.js";
9
+ // =============================================================================
10
+ // Built-in Format Table (Excel numFmtId to format string mapping)
11
+ // =============================================================================
12
+ const TABLE_FMT = {
13
+ 0: "General",
14
+ 1: "0",
15
+ 2: "0.00",
16
+ 3: "#,##0",
17
+ 4: "#,##0.00",
18
+ 9: "0%",
19
+ 10: "0.00%",
20
+ 11: "0.00E+00",
21
+ 12: "# ?/?",
22
+ 13: "# ??/??",
23
+ 14: "m/d/yy",
24
+ 15: "d-mmm-yy",
25
+ 16: "d-mmm",
26
+ 17: "mmm-yy",
27
+ 18: "h:mm AM/PM",
28
+ 19: "h:mm:ss AM/PM",
29
+ 20: "h:mm",
30
+ 21: "h:mm:ss",
31
+ 22: "m/d/yy h:mm",
32
+ 37: "#,##0 ;(#,##0)",
33
+ 38: "#,##0 ;[Red](#,##0)",
34
+ 39: "#,##0.00;(#,##0.00)",
35
+ 40: "#,##0.00;[Red](#,##0.00)",
36
+ 41: '_(* #,##0_);_(* (#,##0);_(* "-"_);_(@_)',
37
+ 42: '_($* #,##0_);_($* (#,##0);_($* "-"_);_(@_)',
38
+ 43: '_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)',
39
+ 44: '_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)',
40
+ 45: "mm:ss",
41
+ 46: "[h]:mm:ss",
42
+ 47: "mmss.0",
43
+ 48: "##0.0E+0",
44
+ 49: "@"
45
+ };
46
+ /**
47
+ * Default mapping for numFmtId that should map to other formats
48
+ * Based on Excel's behavior for certain format IDs
49
+ */
50
+ const DEFAULT_MAP = {
51
+ // 5 -> 37 ... 8 -> 40
52
+ 5: 37,
53
+ 6: 38,
54
+ 7: 39,
55
+ 8: 40,
56
+ // 23-26 -> 0
57
+ 23: 0,
58
+ 24: 0,
59
+ 25: 0,
60
+ 26: 0,
61
+ // 27-31 -> 14
62
+ 27: 14,
63
+ 28: 14,
64
+ 29: 14,
65
+ 30: 14,
66
+ 31: 14,
67
+ // 50-58 -> 14
68
+ 50: 14,
69
+ 51: 14,
70
+ 52: 14,
71
+ 53: 14,
72
+ 54: 14,
73
+ 55: 14,
74
+ 56: 14,
75
+ 57: 14,
76
+ 58: 14,
77
+ // 59-62 -> 1-4
78
+ 59: 1,
79
+ 60: 2,
80
+ 61: 3,
81
+ 62: 4,
82
+ // 67-68 -> 9-10
83
+ 67: 9,
84
+ 68: 10,
85
+ // 72-75 -> 14-17
86
+ 72: 14,
87
+ 73: 15,
88
+ 74: 16,
89
+ 75: 17,
90
+ // 76-78 -> 20-22
91
+ 76: 20,
92
+ 77: 21,
93
+ 78: 22,
94
+ // 79-81 -> 45-47
95
+ 79: 45,
96
+ 80: 46,
97
+ 81: 47
98
+ };
99
+ /**
100
+ * Get format string from numFmtId
101
+ * Handles default mappings for certain format IDs
102
+ */
103
+ export function getFormat(numFmtId) {
104
+ // Direct lookup first
105
+ if (TABLE_FMT[numFmtId]) {
106
+ return TABLE_FMT[numFmtId];
107
+ }
108
+ // Check default map
109
+ if (DEFAULT_MAP[numFmtId] !== undefined) {
110
+ return TABLE_FMT[DEFAULT_MAP[numFmtId]] || "General";
111
+ }
112
+ return "General";
113
+ }
114
+ // =============================================================================
115
+ // Helper Functions
116
+ // =============================================================================
117
+ /**
118
+ * Pad number with leading zeros
119
+ */
120
+ function pad0(num, len) {
121
+ let s = Math.round(num).toString();
122
+ while (s.length < len) {
123
+ s = "0" + s;
124
+ }
125
+ return s;
126
+ }
127
+ /**
128
+ * Add thousand separators to a number string
129
+ */
130
+ function commaify(s) {
131
+ const w = 3;
132
+ if (s.length <= w) {
133
+ return s;
134
+ }
135
+ const j = s.length % w;
136
+ let o = s.substring(0, j);
137
+ for (let i = j; i < s.length; i += w) {
138
+ o += (o.length > 0 ? "," : "") + s.substring(i, i + w);
139
+ }
140
+ return o;
141
+ }
142
+ /**
143
+ * Round a number to specified decimal places
144
+ */
145
+ function roundTo(val, decimals) {
146
+ const factor = Math.pow(10, decimals);
147
+ return Math.round(val * factor) / factor;
148
+ }
149
+ /**
150
+ * Process _ (underscore) placeholder - adds space with width of next character
151
+ * Process * (asterisk) placeholder - repeats next character to fill width (simplified to single char)
152
+ */
153
+ function processPlaceholders(fmt) {
154
+ // Replace _X with a space (skip next character, add space)
155
+ let result = fmt.replace(/_./g, " ");
156
+ // Replace *X with empty string (fill character, simplified)
157
+ result = result.replace(/\*./g, "");
158
+ return result;
159
+ }
160
+ // =============================================================================
161
+ // Format Detection
162
+ // =============================================================================
163
+ /**
164
+ * Check if format is "General"
165
+ */
166
+ function isGeneral(fmt) {
167
+ return /^General$/i.test(fmt.trim());
168
+ }
169
+ /**
170
+ * Check if format is a date format
171
+ */
172
+ function isDateFormat(fmt) {
173
+ // Remove color codes and conditions
174
+ const cleaned = fmt.replace(/\[[^\]]*\]/g, "");
175
+ // Check for date/time tokens (but not if it's just a number format with brackets)
176
+ return /[ymdhs]/i.test(cleaned) && !/^[#0.,E%$\s()\-+]+$/i.test(cleaned);
177
+ }
178
+ // =============================================================================
179
+ // Date Formatting
180
+ // =============================================================================
181
+ const MONTHS_SHORT = [
182
+ "Jan",
183
+ "Feb",
184
+ "Mar",
185
+ "Apr",
186
+ "May",
187
+ "Jun",
188
+ "Jul",
189
+ "Aug",
190
+ "Sep",
191
+ "Oct",
192
+ "Nov",
193
+ "Dec"
194
+ ];
195
+ const MONTHS_LONG = [
196
+ "January",
197
+ "February",
198
+ "March",
199
+ "April",
200
+ "May",
201
+ "June",
202
+ "July",
203
+ "August",
204
+ "September",
205
+ "October",
206
+ "November",
207
+ "December"
208
+ ];
209
+ // Single letter month abbreviation (J, F, M, A, M, J, J, A, S, O, N, D)
210
+ const MONTHS_LETTER = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
211
+ const DAYS_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
212
+ const DAYS_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
213
+ /**
214
+ * Format a date value using Excel date format
215
+ * @param serial Excel serial number (days since 1900-01-01)
216
+ * @param fmt Format string
217
+ */
218
+ function formatDate(serial, fmt) {
219
+ const date = excelToDate(serial, false);
220
+ const year = date.getFullYear();
221
+ const month = date.getMonth(); // 0-indexed
222
+ const day = date.getDate();
223
+ const hours = date.getHours();
224
+ const minutes = date.getMinutes();
225
+ const seconds = date.getSeconds();
226
+ const dayOfWeek = date.getDay();
227
+ // Calculate fractional seconds from serial
228
+ const totalSeconds = serial * 86400;
229
+ const fractionalSeconds = totalSeconds - Math.floor(totalSeconds);
230
+ // Check for AM/PM
231
+ const hasAmPm = /AM\/PM|A\/P/i.test(fmt);
232
+ const isPm = hours >= 12;
233
+ const hours12 = hours % 12 || 12;
234
+ // Remove color codes like [Red], [Green], etc. but keep elapsed time brackets
235
+ let result = fmt.replace(/\[(Red|Green|Blue|Yellow|Magenta|Cyan|White|Black|Color\d+)\]/gi, "");
236
+ // Process _ and * placeholders
237
+ result = processPlaceholders(result);
238
+ // Handle fractional seconds (ss.0, ss.00, ss.000)
239
+ const fracSecMatch = result.match(/ss\.(0+)/i);
240
+ let fracSecStr = "";
241
+ if (fracSecMatch) {
242
+ const decPlaces = fracSecMatch[1].length;
243
+ const fracPart = Math.round(fractionalSeconds * Math.pow(10, decPlaces));
244
+ fracSecStr = fracPart.toString().padStart(decPlaces, "0");
245
+ result = result.replace(/ss\.0+/gi, "\x00SF\x00");
246
+ }
247
+ // Process tokens - order matters! Longer patterns first.
248
+ // Use placeholder tokens to avoid re-matching
249
+ // Important: Use unique markers that don't contain the original pattern letters
250
+ // Year
251
+ result = result.replace(/yyyy/gi, "\x00Y4\x00");
252
+ result = result.replace(/yy/gi, "\x00Y2\x00");
253
+ // Month names (before numeric month) - order matters: longer patterns first
254
+ result = result.replace(/mmmmm/gi, "\x00MN5\x00"); // Single letter month
255
+ result = result.replace(/mmmm/gi, "\x00MN4\x00");
256
+ result = result.replace(/mmm/gi, "\x00MN3\x00");
257
+ // Day names (must be before dd and d)
258
+ result = result.replace(/dddd/gi, "\x00DN4\x00");
259
+ result = result.replace(/ddd/gi, "\x00DN3\x00");
260
+ // Day numbers
261
+ result = result.replace(/dd/gi, "\x00D2\x00");
262
+ result = result.replace(/\bd\b/gi, "\x00D1\x00");
263
+ // Hours
264
+ result = result.replace(/hh/gi, "\x00H2\x00");
265
+ result = result.replace(/\bh\b/gi, "\x00H1\x00");
266
+ // Seconds (before mm to avoid confusion)
267
+ result = result.replace(/ss/gi, "\x00S2\x00");
268
+ result = result.replace(/\bs\b/gi, "\x00S1\x00");
269
+ // Minutes/Month mm - context dependent
270
+ // If near h or s, it's minutes; otherwise month
271
+ // For simplicity, check if we already have hour tokens nearby
272
+ const hasTimeContext = /\x00H[12]\x00.*mm|mm.*\x00S[12]\x00/i.test(result);
273
+ if (hasTimeContext) {
274
+ result = result.replace(/mm/gi, "\x00MI2\x00");
275
+ }
276
+ else {
277
+ result = result.replace(/mm/gi, "\x00M2\x00");
278
+ }
279
+ result = result.replace(/\bm\b/gi, "\x00M1\x00");
280
+ // AM/PM
281
+ result = result.replace(/AM\/PM/gi, "\x00AMPM\x00");
282
+ result = result.replace(/A\/P/gi, "\x00AP\x00");
283
+ // Now replace placeholders with actual values
284
+ const hourVal = hasAmPm ? hours12 : hours;
285
+ result = result
286
+ .replace(/\x00Y4\x00/g, year.toString())
287
+ .replace(/\x00Y2\x00/g, (year % 100).toString().padStart(2, "0"))
288
+ .replace(/\x00MN5\x00/g, MONTHS_LETTER[month])
289
+ .replace(/\x00MN4\x00/g, MONTHS_LONG[month])
290
+ .replace(/\x00MN3\x00/g, MONTHS_SHORT[month])
291
+ .replace(/\x00M2\x00/g, (month + 1).toString().padStart(2, "0"))
292
+ .replace(/\x00M1\x00/g, (month + 1).toString())
293
+ .replace(/\x00DN4\x00/g, DAYS_LONG[dayOfWeek])
294
+ .replace(/\x00DN3\x00/g, DAYS_SHORT[dayOfWeek])
295
+ .replace(/\x00D2\x00/g, day.toString().padStart(2, "0"))
296
+ .replace(/\x00D1\x00/g, day.toString())
297
+ .replace(/\x00H2\x00/g, hourVal.toString().padStart(2, "0"))
298
+ .replace(/\x00H1\x00/g, hourVal.toString())
299
+ .replace(/\x00MI2\x00/g, minutes.toString().padStart(2, "0"))
300
+ .replace(/\x00S2\x00/g, seconds.toString().padStart(2, "0"))
301
+ .replace(/\x00S1\x00/g, seconds.toString())
302
+ .replace(/\x00SF\x00/g, seconds.toString().padStart(2, "0") + "." + fracSecStr)
303
+ .replace(/\x00AMPM\x00/g, isPm ? "PM" : "AM")
304
+ .replace(/\x00AP\x00/g, isPm ? "P" : "A");
305
+ // Clean up escape characters
306
+ result = result.replace(/\\/g, "");
307
+ return result;
308
+ }
309
+ // =============================================================================
310
+ // Number Formatting
311
+ // =============================================================================
312
+ /**
313
+ * Format a number using "General" format
314
+ */
315
+ function formatGeneral(val) {
316
+ if (typeof val === "boolean") {
317
+ return val ? "TRUE" : "FALSE";
318
+ }
319
+ if (typeof val === "string") {
320
+ return val;
321
+ }
322
+ // Number formatting - up to 11 significant digits
323
+ if (Number.isInteger(val)) {
324
+ return val.toString();
325
+ }
326
+ // For decimals, show up to 11 significant figures
327
+ const str = val.toPrecision(11);
328
+ // Remove trailing zeros after decimal point
329
+ return str.replace(/\.?0+$/, "").replace(/\.?0+e/, "e");
330
+ }
331
+ /**
332
+ * Format a percentage value
333
+ * @param val The decimal value (e.g., 0.25 for 25%)
334
+ * @param fmt The format string containing %
335
+ */
336
+ function formatPercentage(val, fmt) {
337
+ // Count % signs
338
+ const percentCount = (fmt.match(/%/g) || []).length;
339
+ // Multiply value by 100 for each %
340
+ const scaledVal = val * Math.pow(100, percentCount);
341
+ // Remove % from format to process the number part
342
+ const numFmt = fmt.replace(/%/g, "");
343
+ // Format the number part
344
+ const numStr = formatNumberPattern(scaledVal, numFmt || "0");
345
+ // Add back the % signs
346
+ return numStr + "%".repeat(percentCount);
347
+ }
348
+ /**
349
+ * Format a number in scientific notation
350
+ * @param val The number to format
351
+ * @param fmt The format string (e.g., "0.00E+00")
352
+ */
353
+ function formatScientific(val, fmt) {
354
+ const sign = val < 0 ? "-" : "";
355
+ const absVal = Math.abs(val);
356
+ if (absVal === 0) {
357
+ // Handle zero
358
+ const decMatch = fmt.match(/\.([0#]+)E/i);
359
+ const decPlaces = decMatch ? decMatch[1].length : 2;
360
+ return "0." + "0".repeat(decPlaces) + "E+00";
361
+ }
362
+ // Find decimal places from format
363
+ const decMatch = fmt.match(/\.([0#]+)E/i);
364
+ const decPlaces = decMatch ? decMatch[1].length : 2;
365
+ // Check if format has explicit +
366
+ const hasPlus = fmt.includes("E+");
367
+ // Calculate exponent
368
+ const exp = Math.floor(Math.log10(absVal));
369
+ const mantissa = absVal / Math.pow(10, exp);
370
+ // Round mantissa to specified decimal places
371
+ const roundedMantissa = roundTo(mantissa, decPlaces);
372
+ // Format mantissa
373
+ const mantissaStr = roundedMantissa.toFixed(decPlaces);
374
+ // Format exponent
375
+ const expSign = exp >= 0 ? (hasPlus ? "+" : "") : "-";
376
+ const expStr = pad0(Math.abs(exp), 2);
377
+ return sign + mantissaStr + "E" + expSign + expStr;
378
+ }
379
+ /**
380
+ * Convert decimal to fraction using continued fraction algorithm
381
+ */
382
+ function toFraction(val, maxDenom) {
383
+ const sign = val < 0 ? -1 : 1;
384
+ let absVal = Math.abs(val);
385
+ const whole = Math.floor(absVal);
386
+ absVal -= whole;
387
+ if (absVal < 1e-10) {
388
+ return [sign * whole, 0, 1];
389
+ }
390
+ let p0 = 0, p1 = 1;
391
+ let q0 = 1, q1 = 0;
392
+ let a = Math.floor(absVal);
393
+ let p = a;
394
+ let q = 1;
395
+ while (q1 < maxDenom) {
396
+ a = Math.floor(absVal);
397
+ p = a * p1 + p0;
398
+ q = a * q1 + q0;
399
+ if (absVal - a < 1e-10) {
400
+ break;
401
+ }
402
+ absVal = 1 / (absVal - a);
403
+ p0 = p1;
404
+ p1 = p;
405
+ q0 = q1;
406
+ q1 = q;
407
+ }
408
+ if (q > maxDenom) {
409
+ q = q1;
410
+ p = p1;
411
+ }
412
+ return [sign * whole, sign * p, q];
413
+ }
414
+ /**
415
+ * Format a number as a fraction
416
+ * @param val The number to format
417
+ * @param fmt The format string (e.g., "# ?/?", "# ??/??")
418
+ */
419
+ function formatFraction(val, fmt) {
420
+ const sign = val < 0 ? "-" : "";
421
+ const absVal = Math.abs(val);
422
+ // Check for fixed denominator (e.g., "# ?/8")
423
+ const fixedDenomMatch = fmt.match(/\?+\s*\/\s*(\d+)/);
424
+ if (fixedDenomMatch) {
425
+ const denom = parseInt(fixedDenomMatch[1], 10);
426
+ const whole = Math.floor(absVal);
427
+ const frac = absVal - whole;
428
+ const numer = Math.round(frac * denom);
429
+ if (fmt.includes("#") || fmt.includes("0")) {
430
+ // Mixed fraction
431
+ if (numer === 0) {
432
+ return sign + whole.toString();
433
+ }
434
+ return sign + (whole > 0 ? whole + " " : "") + numer + "/" + denom;
435
+ }
436
+ // Simple fraction
437
+ return sign + (whole * denom + numer) + "/" + denom;
438
+ }
439
+ // Variable denominator - count ? to determine max digits
440
+ const denomMatch = fmt.match(/\/\s*(\?+)/);
441
+ const maxDigits = denomMatch ? denomMatch[1].length : 2;
442
+ const maxDenom = Math.pow(10, maxDigits) - 1;
443
+ const [whole, numer, denom] = toFraction(absVal, maxDenom);
444
+ // Format based on whether we want mixed or improper fraction
445
+ if (fmt.includes("#") && whole !== 0) {
446
+ if (numer === 0) {
447
+ return sign + Math.abs(whole).toString();
448
+ }
449
+ return sign + Math.abs(whole) + " " + Math.abs(numer) + "/" + denom;
450
+ }
451
+ if (numer === 0) {
452
+ return whole === 0 ? "0" : sign + Math.abs(whole).toString();
453
+ }
454
+ // Improper fraction
455
+ const totalNumer = Math.abs(whole) * denom + Math.abs(numer);
456
+ return sign + totalNumer + "/" + denom;
457
+ }
458
+ /**
459
+ * Format elapsed time (e.g., [h]:mm:ss for durations > 24 hours)
460
+ */
461
+ function formatElapsedTime(serial, fmt) {
462
+ // serial is in days, convert to components
463
+ const totalSeconds = Math.round(serial * 86400);
464
+ const totalMinutes = Math.floor(totalSeconds / 60);
465
+ const totalHours = Math.floor(totalMinutes / 60);
466
+ const seconds = totalSeconds % 60;
467
+ const minutes = totalMinutes % 60;
468
+ const hours = totalHours;
469
+ let result = fmt;
470
+ // Replace elapsed time tokens
471
+ if (/\[h+\]/i.test(result)) {
472
+ result = result.replace(/\[h+\]/gi, hours.toString());
473
+ }
474
+ if (/\[m+\]/i.test(result)) {
475
+ result = result.replace(/\[m+\]/gi, totalMinutes.toString());
476
+ }
477
+ if (/\[s+\]/i.test(result)) {
478
+ result = result.replace(/\[s+\]/gi, totalSeconds.toString());
479
+ }
480
+ // Replace regular time tokens
481
+ result = result.replace(/mm/gi, minutes.toString().padStart(2, "0"));
482
+ result = result.replace(/ss/gi, seconds.toString().padStart(2, "0"));
483
+ return result;
484
+ }
485
+ /**
486
+ * Format a number with the given pattern
487
+ * Handles patterns like "0", "00", "#,##0", "0-0", "000-0000" etc.
488
+ */
489
+ function formatNumberPattern(val, fmt) {
490
+ const absVal = Math.abs(val);
491
+ const sign = val < 0 ? "-" : "";
492
+ // Handle trailing commas (divide by 1000 for each)
493
+ let trailingCommas = 0;
494
+ let workFmt = fmt;
495
+ while (workFmt.endsWith(",")) {
496
+ trailingCommas++;
497
+ workFmt = workFmt.slice(0, -1);
498
+ }
499
+ const scaledVal = absVal / Math.pow(1000, trailingCommas);
500
+ // Check for decimal point
501
+ const decimalIdx = workFmt.indexOf(".");
502
+ let intFmt = workFmt;
503
+ let decFmt = "";
504
+ if (decimalIdx !== -1) {
505
+ intFmt = workFmt.substring(0, decimalIdx);
506
+ decFmt = workFmt.substring(decimalIdx + 1);
507
+ }
508
+ // Count decimal places needed
509
+ const decimalPlaces = decFmt.replace(/[^0#?]/g, "").length;
510
+ // Round the value
511
+ const roundedVal = roundTo(scaledVal, decimalPlaces);
512
+ // Split into integer and decimal parts
513
+ const [intPart, decPart = ""] = roundedVal.toString().split(".");
514
+ // Check if format has literal characters mixed with digit placeholders (like "0-0", "000-0000")
515
+ // This is used for phone numbers, SSN, etc.
516
+ const hasLiteralInFormat = /[0#?][^0#?,.\s][0#?]/.test(intFmt);
517
+ let formattedInt;
518
+ if (hasLiteralInFormat) {
519
+ // Handle pattern with literals like "0-0", "000-0000", "00-00-00"
520
+ // Count total digit placeholders
521
+ const digitPlaceholders = intFmt.replace(/[^0#?]/g, "").length;
522
+ // Pad the number to match the digit placeholder count
523
+ let digits = intPart;
524
+ if (digits.length < digitPlaceholders) {
525
+ digits = "0".repeat(digitPlaceholders - digits.length) + digits;
526
+ }
527
+ // Build result by replacing placeholders with digits
528
+ formattedInt = "";
529
+ let digitIndex = digits.length - digitPlaceholders; // start position in digits string
530
+ for (let i = 0; i < intFmt.length; i++) {
531
+ const char = intFmt[i];
532
+ if (char === "0" || char === "#" || char === "?") {
533
+ if (digitIndex < digits.length) {
534
+ formattedInt += digits[digitIndex];
535
+ digitIndex++;
536
+ }
537
+ }
538
+ else if (char !== ",") {
539
+ // Literal character (like -, /, space, etc.) - but not comma (thousand separator)
540
+ formattedInt += char;
541
+ }
542
+ }
543
+ }
544
+ else {
545
+ // Standard number formatting
546
+ formattedInt = intPart;
547
+ // Add thousand separators if format has them
548
+ if (intFmt.includes(",")) {
549
+ formattedInt = commaify(intPart);
550
+ }
551
+ // Pad integer with leading zeros if needed
552
+ const minIntDigits = (intFmt.match(/0/g) || []).length;
553
+ if (formattedInt.length < minIntDigits) {
554
+ formattedInt = "0".repeat(minIntDigits - formattedInt.length) + formattedInt;
555
+ }
556
+ }
557
+ // Format decimal part
558
+ let formattedDec = "";
559
+ if (decimalPlaces > 0) {
560
+ formattedDec = "." + (decPart + "0".repeat(decimalPlaces)).substring(0, decimalPlaces);
561
+ }
562
+ return sign + formattedInt + formattedDec;
563
+ }
564
+ // =============================================================================
565
+ // Main Format Function
566
+ // =============================================================================
567
+ /**
568
+ * Remove quoted literal text markers and return the literal characters
569
+ * Also handles backslash escape sequences
570
+ */
571
+ function processQuotedText(fmt) {
572
+ let result = "";
573
+ let i = 0;
574
+ while (i < fmt.length) {
575
+ if (fmt[i] === '"') {
576
+ // Find closing quote
577
+ i++;
578
+ while (i < fmt.length && fmt[i] !== '"') {
579
+ result += fmt[i];
580
+ i++;
581
+ }
582
+ i++; // skip closing quote
583
+ }
584
+ else if (fmt[i] === "\\" && i + 1 < fmt.length) {
585
+ // Backslash escapes the next character
586
+ i++;
587
+ result += fmt[i];
588
+ i++;
589
+ }
590
+ else {
591
+ result += fmt[i];
592
+ i++;
593
+ }
594
+ }
595
+ return result;
596
+ }
597
+ /**
598
+ * Check if a condition matches (e.g., [>100], [<=50])
599
+ */
600
+ function checkCondition(val, condition) {
601
+ const match = condition.match(/\[(=|>|<|>=|<=|<>)(-?\d+(?:\.\d*)?)\]/);
602
+ if (!match) {
603
+ return false;
604
+ }
605
+ const op = match[1];
606
+ const threshold = parseFloat(match[2]);
607
+ switch (op) {
608
+ case "=":
609
+ return val === threshold;
610
+ case ">":
611
+ return val > threshold;
612
+ case "<":
613
+ return val < threshold;
614
+ case ">=":
615
+ return val >= threshold;
616
+ case "<=":
617
+ return val <= threshold;
618
+ case "<>":
619
+ return val !== threshold;
620
+ default:
621
+ return false;
622
+ }
623
+ }
624
+ /**
625
+ * Parse format string and handle positive/negative/zero/text sections
626
+ * Excel format: positive;negative;zero;text
627
+ * Also handles conditional formats like [>100]
628
+ */
629
+ function chooseFormat(fmt, val) {
630
+ if (typeof val === "string") {
631
+ // For text, use the 4th section if available, or just return as-is
632
+ const sections = splitFormat(fmt);
633
+ if (sections.length >= 4 && sections[3]) {
634
+ // Process quoted text and replace @ with the value
635
+ const textFmt = processQuotedText(sections[3]);
636
+ return textFmt.replace(/@/g, val);
637
+ }
638
+ return val;
639
+ }
640
+ if (typeof val === "boolean") {
641
+ return val ? "TRUE" : "FALSE";
642
+ }
643
+ const sections = splitFormat(fmt);
644
+ // Check for conditional format in sections
645
+ const condRegex = /\[(=|>|<|>=|<=|<>)-?\d+(?:\.\d*)?\]/;
646
+ const hasCondition = (sections[0] && condRegex.test(sections[0])) || (sections[1] && condRegex.test(sections[1]));
647
+ if (hasCondition && sections.length >= 2) {
648
+ // Conditional format: check each section's condition
649
+ for (let i = 0; i < Math.min(sections.length, 2); i++) {
650
+ const condMatch = sections[i].match(/\[(=|>|<|>=|<=|<>)-?\d+(?:\.\d*)?\]/);
651
+ if (condMatch && checkCondition(val, condMatch[0])) {
652
+ return sections[i];
653
+ }
654
+ }
655
+ // No condition matched, use last section
656
+ return sections[sections.length > 2 ? 2 : 1];
657
+ }
658
+ if (sections.length === 1) {
659
+ return sections[0];
660
+ }
661
+ if (sections.length === 2) {
662
+ // positive/zero; negative
663
+ return val >= 0 ? sections[0] : sections[1];
664
+ }
665
+ // 3+ sections: positive; negative; zero
666
+ if (val > 0) {
667
+ return sections[0];
668
+ }
669
+ if (val < 0) {
670
+ return sections[1];
671
+ }
672
+ return sections[2] || sections[0];
673
+ }
674
+ /**
675
+ * Check if format section is for negative values (2nd section in multi-section format)
676
+ */
677
+ function isNegativeSection(fmt, selectedFmt) {
678
+ const sections = splitFormat(fmt);
679
+ return sections.length >= 2 && sections[1] === selectedFmt;
680
+ }
681
+ /**
682
+ * Split format string by semicolons, respecting quoted strings and brackets
683
+ */
684
+ function splitFormat(fmt) {
685
+ const sections = [];
686
+ let current = "";
687
+ let inQuote = false;
688
+ let inBracket = false;
689
+ for (let i = 0; i < fmt.length; i++) {
690
+ const char = fmt[i];
691
+ if (char === '"' && !inBracket) {
692
+ inQuote = !inQuote;
693
+ current += char;
694
+ }
695
+ else if (char === "[" && !inQuote) {
696
+ inBracket = true;
697
+ current += char;
698
+ }
699
+ else if (char === "]" && !inQuote) {
700
+ inBracket = false;
701
+ current += char;
702
+ }
703
+ else if (char === ";" && !inQuote && !inBracket) {
704
+ sections.push(current);
705
+ current = "";
706
+ }
707
+ else {
708
+ current += char;
709
+ }
710
+ }
711
+ sections.push(current);
712
+ return sections;
713
+ }
714
+ /**
715
+ * Main format function - formats a value according to Excel numFmt
716
+ * @param fmt The Excel number format string (e.g., "0.00%", "#,##0", "yyyy-mm-dd")
717
+ * @param val The value to format
718
+ */
719
+ export function format(fmt, val) {
720
+ // Handle null/undefined
721
+ if (val == null) {
722
+ return "";
723
+ }
724
+ // Handle General format
725
+ if (isGeneral(fmt)) {
726
+ return formatGeneral(val);
727
+ }
728
+ // Handle string values
729
+ if (typeof val === "string") {
730
+ return chooseFormat(fmt, val);
731
+ }
732
+ // Handle boolean values
733
+ if (typeof val === "boolean") {
734
+ return val ? "TRUE" : "FALSE";
735
+ }
736
+ // Now val is a number
737
+ let numVal = val;
738
+ // Choose the right format section based on value
739
+ const selectedFmt = chooseFormat(fmt, numVal);
740
+ // If negative section is selected, use absolute value (format handles display)
741
+ if (numVal < 0 && isNegativeSection(fmt, selectedFmt)) {
742
+ numVal = Math.abs(numVal);
743
+ }
744
+ // Remove color codes like [Red], [Green], [Blue], etc.
745
+ let cleanFmt = selectedFmt.replace(/\[(Red|Green|Blue|Yellow|Magenta|Cyan|White|Black|Color\d+)\]/gi, "");
746
+ // Remove condition codes like [>100], [<=50], etc.
747
+ cleanFmt = cleanFmt.replace(/\[(>|<|>=|<=|=|<>)-?\d+(\.\d+)?\]/g, "");
748
+ // Remove locale codes like [$-804], [$€-407], etc.
749
+ cleanFmt = cleanFmt.replace(/\[\$[^\]]*\]/g, "");
750
+ // Process _ and * placeholders
751
+ cleanFmt = processPlaceholders(cleanFmt);
752
+ // Process quoted text
753
+ cleanFmt = processQuotedText(cleanFmt);
754
+ // Check for elapsed time format [h]:mm:ss, [m]:ss, [s]
755
+ if (/\[[hms]+\]/i.test(cleanFmt)) {
756
+ return formatElapsedTime(numVal, cleanFmt);
757
+ }
758
+ // Check if this is a date format
759
+ if (isDateFormat(cleanFmt)) {
760
+ return formatDate(numVal, cleanFmt);
761
+ }
762
+ // Check for percentage
763
+ if (cleanFmt.includes("%")) {
764
+ return formatPercentage(numVal, cleanFmt);
765
+ }
766
+ // Check for scientific notation
767
+ if (/E[+-]?/i.test(cleanFmt)) {
768
+ return formatScientific(numVal, cleanFmt);
769
+ }
770
+ // Check for fraction format
771
+ if (/\?+\s*\/\s*[\d?]+/.test(cleanFmt)) {
772
+ return formatFraction(numVal, cleanFmt);
773
+ }
774
+ // Handle negative numbers in parentheses format
775
+ if (cleanFmt.includes("(") && cleanFmt.includes(")") && numVal < 0) {
776
+ const innerFmt = cleanFmt.replace(/\(|\)/g, "");
777
+ return "(" + formatNumberPattern(-numVal, innerFmt) + ")";
778
+ }
779
+ // Handle text placeholder @
780
+ if (cleanFmt === "@") {
781
+ return numVal.toString();
782
+ }
783
+ // Handle currency symbol and literal text prefix/suffix
784
+ let prefix = "";
785
+ let suffix = "";
786
+ // Extract currency/text prefix (includes $, ¥, €, etc. and quoted text)
787
+ const prefixMatch = cleanFmt.match(/^([^#0?.,]+)/);
788
+ if (prefixMatch) {
789
+ prefix = prefixMatch[1];
790
+ cleanFmt = cleanFmt.substring(prefixMatch[0].length);
791
+ }
792
+ // Extract suffix
793
+ const suffixMatch = cleanFmt.match(/([^#0?.,]+)$/);
794
+ if (suffixMatch && !suffixMatch[1].includes("%")) {
795
+ suffix = suffixMatch[1];
796
+ cleanFmt = cleanFmt.substring(0, cleanFmt.length - suffixMatch[0].length);
797
+ }
798
+ // Format the number
799
+ const formattedNum = formatNumberPattern(numVal, cleanFmt);
800
+ return prefix + formattedNum + suffix;
801
+ }
802
+ // =============================================================================
803
+ // Export
804
+ // =============================================================================
805
+ export const cellFormat = {
806
+ format,
807
+ getFormat,
808
+ isDateFormat,
809
+ isGeneral
810
+ };