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