documonster 0.20.1 → 0.20.2
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/README.md +1 -1
- package/README_zh.md +1 -1
- package/dist/esm/modules/excel/core/cell.js +5 -6
- package/dist/esm/modules/excel/core/pivot-table.js +17 -9
- package/dist/esm/modules/excel/stream/xlsb-worksheet-reader.js +4 -6
- package/dist/esm/modules/excel/utils/cell-format-parse.js +112 -174
- package/dist/esm/modules/excel/utils/cell-format.js +42 -946
- package/dist/esm/modules/formula/functions/text.js +23 -631
- package/dist/esm/modules/pdf/excel-bridge.js +5 -5
- package/dist/esm/utils/number-format-render.js +814 -0
- package/dist/esm/utils/number-format.js +477 -0
- package/dist/esm/utils/utils.base.js +3 -70
- package/dist/iife/documonster.archive.iife.min.js +1 -1
- package/dist/iife/documonster.csv.iife.min.js +1 -1
- package/dist/iife/documonster.draw.iife.min.js +1 -1
- package/dist/iife/documonster.excel.iife.min.js +50 -50
- package/dist/iife/documonster.formula.iife.min.js +1 -1
- package/dist/iife/documonster.markdown.iife.min.js +1 -1
- package/dist/iife/documonster.mermaid.iife.min.js +1 -1
- package/dist/iife/documonster.pdf.iife.min.js +18 -18
- package/dist/iife/documonster.stream.iife.min.js +1 -1
- package/dist/iife/documonster.word.iife.min.js +60 -60
- package/dist/iife/documonster.xml.iife.min.js +1 -1
- package/dist/types/modules/excel/core/cell.d.ts +11 -0
- package/dist/types/modules/excel/core/pivot-table.d.ts +7 -0
- package/dist/types/modules/excel/utils/cell-format-parse.d.ts +13 -9
- package/dist/types/modules/excel/utils/cell-format.d.ts +21 -33
- package/dist/types/modules/excel/utils/text-metrics.d.ts +1 -0
- package/dist/types/utils/number-format-render.d.ts +47 -0
- package/dist/types/utils/number-format.d.ts +215 -0
- package/dist/types/utils/utils.base.d.ts +1 -9
- package/package.json +1 -1
|
@@ -1,969 +1,61 @@
|
|
|
1
|
-
// oxlint-disable no-control-regex
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
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, splitFormatSections, dateToExcel } from "#platform/utils/utils";
|
|
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: "mm-dd-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
|
-
* Disambiguate each `mm` occurrence in a format string that has already been
|
|
215
|
-
* placeholder-substituted for the other date/time tokens.
|
|
216
|
-
*
|
|
217
|
-
* Excel's rule: `mm` is minutes when it's adjacent to an hour or seconds
|
|
218
|
-
* token (with no intervening date tokens); otherwise it's a zero-padded
|
|
219
|
-
* month. This must be decided per occurrence — a single format string can
|
|
220
|
-
* contain both roles (e.g. `"yyyy-mm-dd hh:mm:ss"`).
|
|
221
|
-
*
|
|
222
|
-
* The caller has already replaced `yyyy`/`yy` → `Y4/Y2`, month-name tokens
|
|
223
|
-
* `mmmmm/mmmm/mmm` → `MN5/MN4/MN3`, `dd`/`d` → `D2/D1`, `hh`/`h` → `H2/H1`,
|
|
224
|
-
* `ss`/`s` → `S2/S1`. So any remaining literal `mm` substrings here are
|
|
225
|
-
* ambiguous between minute and month.
|
|
226
|
-
*
|
|
227
|
-
* Returns the input with each `mm` replaced by either `\x00MI2\x00` (minutes)
|
|
228
|
-
* or `\x00M2\x00` (month, zero-padded).
|
|
229
|
-
*/
|
|
230
|
-
function resolveMonthOrMinute(s) {
|
|
231
|
-
// Tokens that, when present between an `mm` and a time anchor, break the
|
|
232
|
-
// "adjacent time context" chain and push the `mm` back into month-land.
|
|
233
|
-
const DATE_TOKEN = /\x00(?:Y[24]|D[12]|MN[345])\x00/;
|
|
234
|
-
const HOUR_TOKEN = /\x00H[12]\x00/g;
|
|
235
|
-
const SEC_TOKEN = /\x00S[12]\x00/g;
|
|
236
|
-
let out = "";
|
|
237
|
-
let work = s;
|
|
238
|
-
let idx = work.search(/mm/i);
|
|
239
|
-
while (idx !== -1) {
|
|
240
|
-
const before = work.slice(0, idx);
|
|
241
|
-
const after = work.slice(idx + 2);
|
|
242
|
-
// Find the *nearest* hour token preceding this `mm` (scan from the right).
|
|
243
|
-
let nearestHourIdx = -1;
|
|
244
|
-
let m;
|
|
245
|
-
HOUR_TOKEN.lastIndex = 0;
|
|
246
|
-
while ((m = HOUR_TOKEN.exec(before)) !== null) {
|
|
247
|
-
nearestHourIdx = m.index;
|
|
248
|
-
}
|
|
249
|
-
// Find the *nearest* seconds token following this `mm`.
|
|
250
|
-
SEC_TOKEN.lastIndex = 0;
|
|
251
|
-
const secMatch = SEC_TOKEN.exec(after);
|
|
252
|
-
const nearestSecIdx = secMatch ? secMatch.index : -1;
|
|
253
|
-
const hourInRange = nearestHourIdx !== -1 && !DATE_TOKEN.test(before.slice(nearestHourIdx));
|
|
254
|
-
const secInRange = nearestSecIdx !== -1 && !DATE_TOKEN.test(after.slice(0, nearestSecIdx));
|
|
255
|
-
const isMinutes = hourInRange || secInRange;
|
|
256
|
-
out += before + (isMinutes ? "\x00MI2\x00" : "\x00M2\x00");
|
|
257
|
-
work = after;
|
|
258
|
-
idx = work.search(/mm/i);
|
|
259
|
-
}
|
|
260
|
-
out += work;
|
|
261
|
-
return out;
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Format a date value using Excel date format
|
|
265
|
-
* @param serial Excel serial number (days since 1900-01-01)
|
|
266
|
-
* @param fmt Format string
|
|
267
|
-
*
|
|
268
|
-
* **The hard-coded 1900 epoch is correct here, and is not a missing `date1904`.** This is reached from
|
|
269
|
-
* `format(fmt, value)`, whose contract is a bare serial with no workbook attached — a serial with no epoch has
|
|
270
|
-
* to assume one, and 1900 is the only defensible choice. On the path that actually carries a workbook's dates,
|
|
271
|
-
* `formatCellValue` produces the serial with `dateToExcel(value)` — also 1900 — so the epoch cancels exactly and
|
|
272
|
-
* a 1904 workbook's display text is right. Verified: the same cell renders identically under both settings.
|
|
2
|
+
* Display text for a cell value under its number format.
|
|
273
3
|
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const totalSeconds = Math.round(serial * 86400);
|
|
280
|
-
const timeOfDay = totalSeconds % 86400;
|
|
281
|
-
const hours = Math.floor(timeOfDay / 3600);
|
|
282
|
-
const minutes = Math.floor((timeOfDay % 3600) / 60);
|
|
283
|
-
const seconds = timeOfDay % 60;
|
|
284
|
-
// For date components, use excelToDate but only for date parts
|
|
285
|
-
const date = excelToDate(serial, false);
|
|
286
|
-
const year = date.getUTCFullYear();
|
|
287
|
-
const month = date.getUTCMonth(); // 0-indexed
|
|
288
|
-
const day = date.getUTCDate();
|
|
289
|
-
const dayOfWeek = date.getUTCDay();
|
|
290
|
-
// Calculate fractional seconds from serial
|
|
291
|
-
const fractionalSeconds = serial * 86400 - Math.floor(serial * 86400);
|
|
292
|
-
// Check for AM/PM
|
|
293
|
-
const hasAmPm = /AM\/PM|A\/P/i.test(fmt);
|
|
294
|
-
const isPm = hours >= 12;
|
|
295
|
-
// Standard 12-hour format: 0 and 12 both display as 12
|
|
296
|
-
const hours12 = hours % 12 || 12;
|
|
297
|
-
// Remove color codes like [Red], [Green], etc. but keep elapsed time brackets
|
|
298
|
-
let result = fmt.replace(/\[(Red|Green|Blue|Yellow|Magenta|Cyan|White|Black|Color\d+)\]/gi, "");
|
|
299
|
-
// Process _ and * placeholders
|
|
300
|
-
result = processPlaceholders(result);
|
|
301
|
-
// Handle fractional seconds (ss.0, ss.00, ss.000)
|
|
302
|
-
const fracSecMatch = result.match(/ss\.(0+)/i);
|
|
303
|
-
let fracSecStr = "";
|
|
304
|
-
if (fracSecMatch) {
|
|
305
|
-
const decPlaces = fracSecMatch[1].length;
|
|
306
|
-
const fracPart = Math.round(fractionalSeconds * Math.pow(10, decPlaces));
|
|
307
|
-
fracSecStr = fracPart.toString().padStart(decPlaces, "0");
|
|
308
|
-
result = result.replace(/ss\.0+/gi, "\x00SF\x00");
|
|
309
|
-
}
|
|
310
|
-
// Process tokens - order matters! Longer patterns first.
|
|
311
|
-
// Use placeholder tokens to avoid re-matching
|
|
312
|
-
// Important: Use unique markers that don't contain the original pattern letters
|
|
313
|
-
// Year
|
|
314
|
-
result = result.replace(/yyyy/gi, "\x00Y4\x00");
|
|
315
|
-
result = result.replace(/yy/gi, "\x00Y2\x00");
|
|
316
|
-
// Month names (before numeric month) - order matters: longer patterns first
|
|
317
|
-
result = result.replace(/mmmmm/gi, "\x00MN5\x00"); // Single letter month
|
|
318
|
-
result = result.replace(/mmmm/gi, "\x00MN4\x00");
|
|
319
|
-
result = result.replace(/mmm/gi, "\x00MN3\x00");
|
|
320
|
-
// Day names (must be before dd and d)
|
|
321
|
-
result = result.replace(/dddd/gi, "\x00DN4\x00");
|
|
322
|
-
result = result.replace(/ddd/gi, "\x00DN3\x00");
|
|
323
|
-
// Day numbers
|
|
324
|
-
result = result.replace(/dd/gi, "\x00D2\x00");
|
|
325
|
-
result = result.replace(/\bd\b/gi, "\x00D1\x00");
|
|
326
|
-
// Hours
|
|
327
|
-
result = result.replace(/hh/gi, "\x00H2\x00");
|
|
328
|
-
result = result.replace(/\bh\b/gi, "\x00H1\x00");
|
|
329
|
-
// Seconds (before mm to avoid confusion)
|
|
330
|
-
result = result.replace(/ss/gi, "\x00S2\x00");
|
|
331
|
-
result = result.replace(/\bs\b/gi, "\x00S1\x00");
|
|
332
|
-
// Minutes/Month `mm` — position-dependent. Excel treats `mm` as minutes
|
|
333
|
-
// when the nearest neighboring time-token is an hour (before) or a
|
|
334
|
-
// seconds token (after); otherwise it's month. This must be decided **per
|
|
335
|
-
// occurrence**, because a single format string can contain both roles —
|
|
336
|
-
// e.g. in `"yyyy-mm-dd hh:mm:ss"` the first `mm` is month and the second
|
|
337
|
-
// is minutes. A single global `hasTimeContext` flag would miscategorise
|
|
338
|
-
// all `mm` as minutes in such mixed formats.
|
|
339
|
-
result = resolveMonthOrMinute(result);
|
|
340
|
-
result = result.replace(/\bm\b/gi, "\x00M1\x00");
|
|
341
|
-
// AM/PM
|
|
342
|
-
result = result.replace(/AM\/PM/gi, "\x00AMPM\x00");
|
|
343
|
-
result = result.replace(/A\/P/gi, "\x00AP\x00");
|
|
344
|
-
// Now replace placeholders with actual values
|
|
345
|
-
const hourVal = hasAmPm ? hours12 : hours;
|
|
346
|
-
result = result
|
|
347
|
-
.replace(/\x00Y4\x00/g, year.toString())
|
|
348
|
-
.replace(/\x00Y2\x00/g, (year % 100).toString().padStart(2, "0"))
|
|
349
|
-
.replace(/\x00MN5\x00/g, MONTHS_LETTER[month])
|
|
350
|
-
.replace(/\x00MN4\x00/g, MONTHS_LONG[month])
|
|
351
|
-
.replace(/\x00MN3\x00/g, MONTHS_SHORT[month])
|
|
352
|
-
.replace(/\x00M2\x00/g, (month + 1).toString().padStart(2, "0"))
|
|
353
|
-
.replace(/\x00M1\x00/g, (month + 1).toString())
|
|
354
|
-
.replace(/\x00DN4\x00/g, DAYS_LONG[dayOfWeek])
|
|
355
|
-
.replace(/\x00DN3\x00/g, DAYS_SHORT[dayOfWeek])
|
|
356
|
-
.replace(/\x00D2\x00/g, day.toString().padStart(2, "0"))
|
|
357
|
-
.replace(/\x00D1\x00/g, day.toString())
|
|
358
|
-
.replace(/\x00H2\x00/g, hourVal.toString().padStart(2, "0"))
|
|
359
|
-
.replace(/\x00H1\x00/g, hourVal.toString())
|
|
360
|
-
.replace(/\x00MI2\x00/g, minutes.toString().padStart(2, "0"))
|
|
361
|
-
.replace(/\x00S2\x00/g, seconds.toString().padStart(2, "0"))
|
|
362
|
-
.replace(/\x00S1\x00/g, seconds.toString())
|
|
363
|
-
.replace(/\x00SF\x00/g, seconds.toString().padStart(2, "0") + "." + fracSecStr)
|
|
364
|
-
.replace(/\x00AMPM\x00/g, isPm ? "PM" : "AM")
|
|
365
|
-
.replace(/\x00AP\x00/g, isPm ? "P" : "A");
|
|
366
|
-
// Clean up escape characters
|
|
367
|
-
result = result.replace(/\\/g, "");
|
|
368
|
-
return result;
|
|
369
|
-
}
|
|
370
|
-
// =============================================================================
|
|
371
|
-
// Number Formatting
|
|
372
|
-
// =============================================================================
|
|
373
|
-
/**
|
|
374
|
-
* Format a number using "General" format
|
|
375
|
-
*/
|
|
376
|
-
function formatGeneral(val) {
|
|
377
|
-
if (typeof val === "boolean") {
|
|
378
|
-
return val ? "TRUE" : "FALSE";
|
|
379
|
-
}
|
|
380
|
-
if (typeof val === "string") {
|
|
381
|
-
return val;
|
|
382
|
-
}
|
|
383
|
-
// Number formatting - up to 11 significant digits
|
|
384
|
-
if (Number.isInteger(val)) {
|
|
385
|
-
return val.toString();
|
|
386
|
-
}
|
|
387
|
-
// For decimals, show up to 11 significant figures
|
|
388
|
-
const str = val.toPrecision(11);
|
|
389
|
-
// Remove trailing zeros after decimal point
|
|
390
|
-
return str.replace(/\.?0+$/, "").replace(/\.?0+e/, "e");
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
393
|
-
* Format a percentage value
|
|
394
|
-
* @param val The decimal value (e.g., 0.25 for 25%)
|
|
395
|
-
* @param fmt The format string containing %
|
|
396
|
-
*/
|
|
397
|
-
function formatPercentage(val, fmt) {
|
|
398
|
-
// Count % signs
|
|
399
|
-
const percentCount = (fmt.match(/%/g) ?? []).length;
|
|
400
|
-
// Multiply value by 100 for each %
|
|
401
|
-
const scaledVal = val * Math.pow(100, percentCount);
|
|
402
|
-
// Remove % from format to process the number part
|
|
403
|
-
const numFmt = fmt.replace(/%/g, "");
|
|
404
|
-
// Format the number part
|
|
405
|
-
const numStr = formatNumberPattern(scaledVal, numFmt || "0");
|
|
406
|
-
// Add back the % signs
|
|
407
|
-
return numStr + "%".repeat(percentCount);
|
|
408
|
-
}
|
|
409
|
-
/**
|
|
410
|
-
* Format a number in scientific notation
|
|
411
|
-
* @param val The number to format
|
|
412
|
-
* @param fmt The format string (e.g., "0.00E+00")
|
|
413
|
-
*/
|
|
414
|
-
function formatScientific(val, fmt) {
|
|
415
|
-
const sign = val < 0 ? "-" : "";
|
|
416
|
-
const absVal = Math.abs(val);
|
|
417
|
-
if (absVal === 0) {
|
|
418
|
-
// Handle zero
|
|
419
|
-
const decMatch = fmt.match(/\.([0#]+)E/i);
|
|
420
|
-
const decPlaces = decMatch ? decMatch[1].length : 2;
|
|
421
|
-
return "0." + "0".repeat(decPlaces) + "E+00";
|
|
422
|
-
}
|
|
423
|
-
// Find decimal places from format
|
|
424
|
-
const decMatch = fmt.match(/\.([0#]+)E/i);
|
|
425
|
-
const decPlaces = decMatch ? decMatch[1].length : 2;
|
|
426
|
-
// Check if format has explicit +
|
|
427
|
-
const hasPlus = fmt.includes("E+");
|
|
428
|
-
// Calculate exponent
|
|
429
|
-
const exp = Math.floor(Math.log10(absVal));
|
|
430
|
-
const mantissa = absVal / Math.pow(10, exp);
|
|
431
|
-
// Round mantissa to specified decimal places
|
|
432
|
-
const roundedMantissa = roundTo(mantissa, decPlaces);
|
|
433
|
-
// Format mantissa
|
|
434
|
-
const mantissaStr = roundedMantissa.toFixed(decPlaces);
|
|
435
|
-
// Format exponent
|
|
436
|
-
const expSign = exp >= 0 ? (hasPlus ? "+" : "") : "-";
|
|
437
|
-
const expStr = pad0(Math.abs(exp), 2);
|
|
438
|
-
return sign + mantissaStr + "E" + expSign + expStr;
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* Convert decimal to fraction using continued fraction algorithm
|
|
442
|
-
*/
|
|
443
|
-
function toFraction(val, maxDenom) {
|
|
444
|
-
const sign = val < 0 ? -1 : 1;
|
|
445
|
-
let absVal = Math.abs(val);
|
|
446
|
-
const whole = Math.floor(absVal);
|
|
447
|
-
absVal -= whole;
|
|
448
|
-
if (absVal < 1e-10) {
|
|
449
|
-
return [sign * whole, 0, 1];
|
|
450
|
-
}
|
|
451
|
-
let p0 = 0, p1 = 1;
|
|
452
|
-
let q0 = 1, q1 = 0;
|
|
453
|
-
let a = Math.floor(absVal);
|
|
454
|
-
let p = a;
|
|
455
|
-
let q = 1;
|
|
456
|
-
while (q1 < maxDenom) {
|
|
457
|
-
a = Math.floor(absVal);
|
|
458
|
-
p = a * p1 + p0;
|
|
459
|
-
q = a * q1 + q0;
|
|
460
|
-
if (absVal - a < 1e-10) {
|
|
461
|
-
break;
|
|
462
|
-
}
|
|
463
|
-
absVal = 1 / (absVal - a);
|
|
464
|
-
p0 = p1;
|
|
465
|
-
p1 = p;
|
|
466
|
-
q0 = q1;
|
|
467
|
-
q1 = q;
|
|
468
|
-
}
|
|
469
|
-
if (q > maxDenom) {
|
|
470
|
-
q = q1;
|
|
471
|
-
p = p1;
|
|
472
|
-
}
|
|
473
|
-
return [sign * whole, sign * p, q];
|
|
474
|
-
}
|
|
475
|
-
/**
|
|
476
|
-
* Format a number as a fraction
|
|
477
|
-
* @param val The number to format
|
|
478
|
-
* @param fmt The format string (e.g., "# ?/?", "# ??/??")
|
|
479
|
-
*/
|
|
480
|
-
function formatFraction(val, fmt) {
|
|
481
|
-
const sign = val < 0 ? "-" : "";
|
|
482
|
-
const absVal = Math.abs(val);
|
|
483
|
-
// Check for fixed denominator (e.g., "# ?/8")
|
|
484
|
-
const fixedDenomMatch = fmt.match(/\?+\s*\/\s*(\d+)/);
|
|
485
|
-
if (fixedDenomMatch) {
|
|
486
|
-
const denom = parseInt(fixedDenomMatch[1], 10);
|
|
487
|
-
const whole = Math.floor(absVal);
|
|
488
|
-
const frac = absVal - whole;
|
|
489
|
-
const numer = Math.round(frac * denom);
|
|
490
|
-
if (fmt.includes("#") || fmt.includes("0")) {
|
|
491
|
-
// Mixed fraction
|
|
492
|
-
if (numer === 0) {
|
|
493
|
-
return sign + whole.toString();
|
|
494
|
-
}
|
|
495
|
-
return sign + (whole > 0 ? whole + " " : "") + numer + "/" + denom;
|
|
496
|
-
}
|
|
497
|
-
// Simple fraction
|
|
498
|
-
return sign + (whole * denom + numer) + "/" + denom;
|
|
499
|
-
}
|
|
500
|
-
// Variable denominator - count ? to determine max digits
|
|
501
|
-
const denomMatch = fmt.match(/\/\s*(\?+)/);
|
|
502
|
-
const maxDigits = denomMatch ? denomMatch[1].length : 2;
|
|
503
|
-
const maxDenom = Math.pow(10, maxDigits) - 1;
|
|
504
|
-
const [whole, numer, denom] = toFraction(absVal, maxDenom);
|
|
505
|
-
// Format based on whether we want mixed or improper fraction
|
|
506
|
-
if (fmt.includes("#") && whole !== 0) {
|
|
507
|
-
if (numer === 0) {
|
|
508
|
-
return sign + Math.abs(whole).toString();
|
|
509
|
-
}
|
|
510
|
-
return sign + Math.abs(whole) + " " + Math.abs(numer) + "/" + denom;
|
|
511
|
-
}
|
|
512
|
-
if (numer === 0) {
|
|
513
|
-
return whole === 0 ? "0" : sign + Math.abs(whole).toString();
|
|
514
|
-
}
|
|
515
|
-
// Improper fraction
|
|
516
|
-
const totalNumer = Math.abs(whole) * denom + Math.abs(numer);
|
|
517
|
-
return sign + totalNumer + "/" + denom;
|
|
518
|
-
}
|
|
519
|
-
/**
|
|
520
|
-
* Format elapsed time (e.g., [h]:mm:ss for durations > 24 hours)
|
|
521
|
-
*/
|
|
522
|
-
function formatElapsedTime(serial, fmt) {
|
|
523
|
-
// serial is in days, convert to components
|
|
524
|
-
const totalSeconds = Math.round(serial * 86400);
|
|
525
|
-
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
526
|
-
const totalHours = Math.floor(totalMinutes / 60);
|
|
527
|
-
const seconds = totalSeconds % 60;
|
|
528
|
-
const minutes = totalMinutes % 60;
|
|
529
|
-
const hours = totalHours;
|
|
530
|
-
let result = fmt;
|
|
531
|
-
// Replace elapsed time tokens
|
|
532
|
-
if (/\[h+\]/i.test(result)) {
|
|
533
|
-
result = result.replace(/\[h+\]/gi, hours.toString());
|
|
534
|
-
}
|
|
535
|
-
if (/\[m+\]/i.test(result)) {
|
|
536
|
-
result = result.replace(/\[m+\]/gi, totalMinutes.toString());
|
|
537
|
-
}
|
|
538
|
-
if (/\[s+\]/i.test(result)) {
|
|
539
|
-
result = result.replace(/\[s+\]/gi, totalSeconds.toString());
|
|
540
|
-
}
|
|
541
|
-
// Replace regular time tokens
|
|
542
|
-
result = result.replace(/mm/gi, minutes.toString().padStart(2, "0"));
|
|
543
|
-
result = result.replace(/ss/gi, seconds.toString().padStart(2, "0"));
|
|
544
|
-
return result;
|
|
545
|
-
}
|
|
546
|
-
/**
|
|
547
|
-
* Format a number with the given pattern
|
|
548
|
-
* Handles patterns like "0", "00", "#,##0", "0-0", "000-0000" etc.
|
|
4
|
+
* Reading and rendering a number format is not done here: both live at Layer 0 in
|
|
5
|
+
* `@utils/number-format` and `@utils/number-format-render`, shared with the readers' date
|
|
6
|
+
* detection and the `TEXT` worksheet function, so that a character is a literal or a code for all
|
|
7
|
+
* of them at once. This file adds only what is specific to a cell — a `Date` value, a default
|
|
8
|
+
* date format for an unformatted date, and the date-kind classification `Cell.getDateKind` reports.
|
|
549
9
|
*/
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
// Handle trailing commas (divide by 1000 for each)
|
|
554
|
-
let trailingCommas = 0;
|
|
555
|
-
let workFmt = fmt;
|
|
556
|
-
while (workFmt.endsWith(",")) {
|
|
557
|
-
trailingCommas++;
|
|
558
|
-
workFmt = workFmt.slice(0, -1);
|
|
559
|
-
}
|
|
560
|
-
const scaledVal = absVal / Math.pow(1000, trailingCommas);
|
|
561
|
-
// Check for decimal point
|
|
562
|
-
const decimalIdx = workFmt.indexOf(".");
|
|
563
|
-
let intFmt = workFmt;
|
|
564
|
-
let decFmt = "";
|
|
565
|
-
if (decimalIdx !== -1) {
|
|
566
|
-
intFmt = workFmt.substring(0, decimalIdx);
|
|
567
|
-
decFmt = workFmt.substring(decimalIdx + 1);
|
|
568
|
-
}
|
|
569
|
-
// Count decimal places needed
|
|
570
|
-
const decimalPlaces = decFmt.replace(/[^0#?]/g, "").length;
|
|
571
|
-
// Round the value
|
|
572
|
-
const roundedVal = roundTo(scaledVal, decimalPlaces);
|
|
573
|
-
// When value is zero and the format has no required '0' digit placeholders,
|
|
574
|
-
// '?' placeholders become spaces and '#' placeholders produce nothing.
|
|
575
|
-
// This handles accounting format zero sections like "-"?? → "- " (dash + spaces).
|
|
576
|
-
if (roundedVal === 0 && !intFmt.includes("0") && !decFmt.includes("0")) {
|
|
577
|
-
let result = "";
|
|
578
|
-
for (const ch of intFmt) {
|
|
579
|
-
if (ch === "?") {
|
|
580
|
-
result += " ";
|
|
581
|
-
}
|
|
582
|
-
else if (ch !== "#" && ch !== ",") {
|
|
583
|
-
// Preserve literal characters (already unquoted at this point)
|
|
584
|
-
result += ch;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
if (decimalPlaces > 0) {
|
|
588
|
-
// Only emit the decimal point if the decimal format has '?' or '0' placeholders.
|
|
589
|
-
// Pure '#' decimal digits produce nothing for zero values.
|
|
590
|
-
const hasDecContent = /[0?]/.test(decFmt);
|
|
591
|
-
if (hasDecContent) {
|
|
592
|
-
result += ".";
|
|
593
|
-
for (const ch of decFmt) {
|
|
594
|
-
if (ch === "?") {
|
|
595
|
-
result += " ";
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
return sign + result;
|
|
601
|
-
}
|
|
602
|
-
// Split into integer and decimal parts
|
|
603
|
-
const [intPart, decPart = ""] = roundedVal.toString().split(".");
|
|
604
|
-
// Check if format has literal characters mixed with digit placeholders (like "0-0", "000-0000")
|
|
605
|
-
// This is used for phone numbers, SSN, etc.
|
|
606
|
-
const hasLiteralInFormat = /[0#?][^0#?,.\s][0#?]/.test(intFmt);
|
|
607
|
-
let formattedInt;
|
|
608
|
-
if (hasLiteralInFormat) {
|
|
609
|
-
// Handle pattern with literals like "0-0", "000-0000", "00-00-00"
|
|
610
|
-
// Count total digit placeholders
|
|
611
|
-
const digitPlaceholders = intFmt.replace(/[^0#?]/g, "").length;
|
|
612
|
-
// Pad the number to match the digit placeholder count
|
|
613
|
-
let digits = intPart;
|
|
614
|
-
if (digits.length < digitPlaceholders) {
|
|
615
|
-
digits = "0".repeat(digitPlaceholders - digits.length) + digits;
|
|
616
|
-
}
|
|
617
|
-
// Build result by replacing placeholders with digits
|
|
618
|
-
formattedInt = "";
|
|
619
|
-
let digitIndex = digits.length - digitPlaceholders; // start position in digits string
|
|
620
|
-
for (let i = 0; i < intFmt.length; i++) {
|
|
621
|
-
const char = intFmt[i];
|
|
622
|
-
if (char === "0" || char === "#" || char === "?") {
|
|
623
|
-
if (digitIndex < digits.length) {
|
|
624
|
-
formattedInt += digits[digitIndex];
|
|
625
|
-
digitIndex++;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
else if (char !== ",") {
|
|
629
|
-
// Literal character (like -, /, space, etc.) - but not comma (thousand separator)
|
|
630
|
-
formattedInt += char;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
else {
|
|
635
|
-
// Standard number formatting
|
|
636
|
-
formattedInt = intPart;
|
|
637
|
-
// Add thousand separators if format has them
|
|
638
|
-
if (intFmt.includes(",")) {
|
|
639
|
-
formattedInt = commaify(intPart);
|
|
640
|
-
}
|
|
641
|
-
// Pad integer with leading zeros/spaces if needed
|
|
642
|
-
// '0' placeholder → pad with "0", '?' placeholder → pad with " "
|
|
643
|
-
const minIntDigits = (intFmt.match(/0/g) ?? []).length;
|
|
644
|
-
const totalIntSlots = (intFmt.match(/[0?]/g) ?? []).length;
|
|
645
|
-
if (formattedInt.length < minIntDigits) {
|
|
646
|
-
formattedInt = "0".repeat(minIntDigits - formattedInt.length) + formattedInt;
|
|
647
|
-
}
|
|
648
|
-
if (formattedInt.length < totalIntSlots) {
|
|
649
|
-
formattedInt = " ".repeat(totalIntSlots - formattedInt.length) + formattedInt;
|
|
650
|
-
}
|
|
651
|
-
// '#' integer placeholder: suppress "0" when there are no required '0' or '?' digits
|
|
652
|
-
// and the integer value is zero (e.g. "#" format with value 0 → empty)
|
|
653
|
-
if (formattedInt === "0" && minIntDigits === 0 && totalIntSlots === 0) {
|
|
654
|
-
formattedInt = "";
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
// Format decimal part
|
|
658
|
-
let formattedDec = "";
|
|
659
|
-
if (decimalPlaces > 0) {
|
|
660
|
-
const rawDec = (decPart + "0".repeat(decimalPlaces)).substring(0, decimalPlaces);
|
|
661
|
-
// Process each decimal digit position according to its placeholder:
|
|
662
|
-
// '0' → always show digit, '?' → show digit or space, '#' → show digit or nothing (trim trailing)
|
|
663
|
-
const decChars = rawDec.split("");
|
|
664
|
-
// Walk from the end: '#' trailing zeros are removed, '?' trailing zeros become spaces
|
|
665
|
-
for (let i = decFmt.length - 1; i >= 0; i--) {
|
|
666
|
-
if (i >= decChars.length) {
|
|
667
|
-
continue;
|
|
668
|
-
}
|
|
669
|
-
if (decFmt[i] === "#" && decChars[i] === "0") {
|
|
670
|
-
decChars[i] = "";
|
|
671
|
-
}
|
|
672
|
-
else if (decFmt[i] === "?" && decChars[i] === "0") {
|
|
673
|
-
decChars[i] = " ";
|
|
674
|
-
}
|
|
675
|
-
else {
|
|
676
|
-
break; // stop at first non-zero or '0' placeholder
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
const decStr = decChars.join("");
|
|
680
|
-
// Only emit decimal point if there is content after it
|
|
681
|
-
if (decStr.length > 0) {
|
|
682
|
-
formattedDec = "." + decStr;
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
return sign + formattedInt + formattedDec;
|
|
686
|
-
}
|
|
687
|
-
// =============================================================================
|
|
688
|
-
// Main Format Function
|
|
689
|
-
// =============================================================================
|
|
690
|
-
/**
|
|
691
|
-
* Remove quoted literal text markers and return the literal characters
|
|
692
|
-
* Also handles backslash escape sequences
|
|
693
|
-
*/
|
|
694
|
-
function processQuotedText(fmt) {
|
|
695
|
-
let result = "";
|
|
696
|
-
let i = 0;
|
|
697
|
-
while (i < fmt.length) {
|
|
698
|
-
if (fmt[i] === '"') {
|
|
699
|
-
// Find closing quote
|
|
700
|
-
i++;
|
|
701
|
-
while (i < fmt.length && fmt[i] !== '"') {
|
|
702
|
-
result += fmt[i];
|
|
703
|
-
i++;
|
|
704
|
-
}
|
|
705
|
-
i++; // skip closing quote
|
|
706
|
-
}
|
|
707
|
-
else if (fmt[i] === "\\" && i + 1 < fmt.length) {
|
|
708
|
-
// Backslash escapes the next character
|
|
709
|
-
i++;
|
|
710
|
-
result += fmt[i];
|
|
711
|
-
i++;
|
|
712
|
-
}
|
|
713
|
-
else {
|
|
714
|
-
result += fmt[i];
|
|
715
|
-
i++;
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
return result;
|
|
719
|
-
}
|
|
720
|
-
/**
|
|
721
|
-
* Check if a condition matches (e.g., [>100], [<=50])
|
|
722
|
-
*/
|
|
723
|
-
function checkCondition(val, condition) {
|
|
724
|
-
const match = condition.match(/\[(=|>|<|>=|<=|<>)(-?\d+(?:\.\d*)?)\]/);
|
|
725
|
-
if (!match) {
|
|
726
|
-
return false;
|
|
727
|
-
}
|
|
728
|
-
const op = match[1];
|
|
729
|
-
const threshold = parseFloat(match[2]);
|
|
730
|
-
switch (op) {
|
|
731
|
-
case "=":
|
|
732
|
-
return val === threshold;
|
|
733
|
-
case ">":
|
|
734
|
-
return val > threshold;
|
|
735
|
-
case "<":
|
|
736
|
-
return val < threshold;
|
|
737
|
-
case ">=":
|
|
738
|
-
return val >= threshold;
|
|
739
|
-
case "<=":
|
|
740
|
-
return val <= threshold;
|
|
741
|
-
case "<>":
|
|
742
|
-
return val !== threshold;
|
|
743
|
-
default:
|
|
744
|
-
return false;
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
/**
|
|
748
|
-
* Parse format string and handle positive/negative/zero/text sections
|
|
749
|
-
* Excel format: positive;negative;zero;text
|
|
750
|
-
* Also handles conditional formats like [>100]
|
|
751
|
-
*/
|
|
752
|
-
function chooseFormat(fmt, val) {
|
|
753
|
-
if (typeof val === "string") {
|
|
754
|
-
// For text, use the 4th section if available, or just return as-is
|
|
755
|
-
const sections = splitFormatSections(fmt);
|
|
756
|
-
if (sections.length >= 4 && sections[3]) {
|
|
757
|
-
// Process quoted text and replace @ with the value
|
|
758
|
-
const textFmt = processQuotedText(sections[3]);
|
|
759
|
-
return textFmt.replace(/@/g, val);
|
|
760
|
-
}
|
|
761
|
-
return val;
|
|
762
|
-
}
|
|
763
|
-
if (typeof val === "boolean") {
|
|
764
|
-
return val ? "TRUE" : "FALSE";
|
|
765
|
-
}
|
|
766
|
-
const sections = splitFormatSections(fmt);
|
|
767
|
-
// Check for conditional format in sections
|
|
768
|
-
const condRegex = /\[(=|>|<|>=|<=|<>)-?\d+(?:\.\d*)?\]/;
|
|
769
|
-
const hasCondition = (sections[0] && condRegex.test(sections[0])) || (sections[1] && condRegex.test(sections[1]));
|
|
770
|
-
if (hasCondition && sections.length >= 2) {
|
|
771
|
-
// Conditional format: check each section's condition
|
|
772
|
-
for (let i = 0; i < Math.min(sections.length, 2); i++) {
|
|
773
|
-
const condMatch = sections[i].match(/\[(=|>|<|>=|<=|<>)-?\d+(?:\.\d*)?\]/);
|
|
774
|
-
if (condMatch && checkCondition(val, condMatch[0])) {
|
|
775
|
-
return sections[i];
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
// No condition matched, use last section
|
|
779
|
-
return sections[sections.length > 2 ? 2 : 1];
|
|
780
|
-
}
|
|
781
|
-
if (sections.length === 1) {
|
|
782
|
-
return sections[0];
|
|
783
|
-
}
|
|
784
|
-
if (sections.length === 2) {
|
|
785
|
-
// positive/zero; negative
|
|
786
|
-
return val >= 0 ? sections[0] : sections[1];
|
|
787
|
-
}
|
|
788
|
-
// 3+ sections: positive; negative; zero
|
|
789
|
-
if (val > 0) {
|
|
790
|
-
return sections[0];
|
|
791
|
-
}
|
|
792
|
-
if (val < 0) {
|
|
793
|
-
return sections[1];
|
|
794
|
-
}
|
|
795
|
-
return sections[2] || sections[0];
|
|
796
|
-
}
|
|
797
|
-
/**
|
|
798
|
-
* Check if format section is for negative values (2nd section in multi-section format)
|
|
799
|
-
*/
|
|
800
|
-
function isNegativeSection(fmt, selectedFmt) {
|
|
801
|
-
const sections = splitFormatSections(fmt);
|
|
802
|
-
return sections.length >= 2 && sections[1] === selectedFmt;
|
|
803
|
-
}
|
|
10
|
+
import { isGeneralFormat, numberFormatFacets } from "../../../utils/number-format.js";
|
|
11
|
+
import { renderNumberFormat } from "../../../utils/number-format-render.js";
|
|
12
|
+
import { dateToExcel } from "#platform/utils/utils";
|
|
804
13
|
/**
|
|
805
|
-
*
|
|
806
|
-
* @param fmt The Excel number format string (e.g
|
|
807
|
-
* @param val The value to format
|
|
14
|
+
* Format a value according to an Excel number format, as Excel displays it.
|
|
15
|
+
* @param fmt The Excel number format string (e.g. `"0.00%"`, `"#,##0"`, `"yyyy-mm-dd"`)
|
|
16
|
+
* @param val The value to format; a number is an Excel serial when the format names a date
|
|
808
17
|
*/
|
|
809
18
|
export function format(fmt, val) {
|
|
810
|
-
// Handle null/undefined
|
|
811
19
|
if (val == null) {
|
|
812
20
|
return "";
|
|
813
21
|
}
|
|
814
|
-
|
|
815
|
-
if (isGeneral(fmt)) {
|
|
816
|
-
return formatGeneral(val);
|
|
817
|
-
}
|
|
818
|
-
// Handle string values
|
|
819
|
-
if (typeof val === "string") {
|
|
820
|
-
return chooseFormat(fmt, val);
|
|
821
|
-
}
|
|
822
|
-
// Handle boolean values
|
|
823
|
-
if (typeof val === "boolean") {
|
|
824
|
-
return val ? "TRUE" : "FALSE";
|
|
825
|
-
}
|
|
826
|
-
// Now val is a number
|
|
827
|
-
let numVal = val;
|
|
828
|
-
// Choose the right format section based on value
|
|
829
|
-
const selectedFmt = chooseFormat(fmt, numVal);
|
|
830
|
-
// If negative section is selected, use absolute value (format handles display)
|
|
831
|
-
if (numVal < 0 && isNegativeSection(fmt, selectedFmt)) {
|
|
832
|
-
numVal = Math.abs(numVal);
|
|
833
|
-
}
|
|
834
|
-
// Remove color codes like [Red], [Green], [Blue], etc.
|
|
835
|
-
let cleanFmt = selectedFmt.replace(/\[(Red|Green|Blue|Yellow|Magenta|Cyan|White|Black|Color\d+)\]/gi, "");
|
|
836
|
-
// Remove condition codes like [>100], [<=50], etc.
|
|
837
|
-
cleanFmt = cleanFmt.replace(/\[(>|<|>=|<=|=|<>)-?\d+(\.\d+)?\]/g, "");
|
|
838
|
-
// Remove locale codes like [$-804], [$€-407], etc.
|
|
839
|
-
cleanFmt = cleanFmt.replace(/\[\$[^\]]*\]/g, "");
|
|
840
|
-
// Process _ and * placeholders
|
|
841
|
-
cleanFmt = processPlaceholders(cleanFmt);
|
|
842
|
-
// Process quoted text
|
|
843
|
-
cleanFmt = processQuotedText(cleanFmt);
|
|
844
|
-
// Check for elapsed time format [h]:mm:ss, [m]:ss, [s]
|
|
845
|
-
if (/\[[hms]+\]/i.test(cleanFmt)) {
|
|
846
|
-
return formatElapsedTime(numVal, cleanFmt);
|
|
847
|
-
}
|
|
848
|
-
// Check if this is a date format
|
|
849
|
-
if (isDateFormat(cleanFmt)) {
|
|
850
|
-
return formatDate(numVal, cleanFmt);
|
|
851
|
-
}
|
|
852
|
-
// Check for percentage
|
|
853
|
-
if (cleanFmt.includes("%")) {
|
|
854
|
-
return formatPercentage(numVal, cleanFmt);
|
|
855
|
-
}
|
|
856
|
-
// Check for scientific notation
|
|
857
|
-
if (/E[+-]?/i.test(cleanFmt)) {
|
|
858
|
-
return formatScientific(numVal, cleanFmt);
|
|
859
|
-
}
|
|
860
|
-
// Check for fraction format
|
|
861
|
-
if (/\?+\s*\/\s*[\d?]+/.test(cleanFmt)) {
|
|
862
|
-
return formatFraction(numVal, cleanFmt);
|
|
863
|
-
}
|
|
864
|
-
// Handle negative numbers in parentheses format
|
|
865
|
-
if (cleanFmt.includes("(") && cleanFmt.includes(")") && numVal < 0) {
|
|
866
|
-
const innerFmt = cleanFmt.replace(/\(|\)/g, "");
|
|
867
|
-
return "(" + formatNumberPattern(-numVal, innerFmt) + ")";
|
|
868
|
-
}
|
|
869
|
-
// Handle text placeholder @
|
|
870
|
-
if (cleanFmt === "@") {
|
|
871
|
-
return numVal.toString();
|
|
872
|
-
}
|
|
873
|
-
// Handle currency symbol and literal text prefix/suffix
|
|
874
|
-
let prefix = "";
|
|
875
|
-
let suffix = "";
|
|
876
|
-
// Extract currency/text prefix (includes $, ¥, €, etc. and quoted text)
|
|
877
|
-
const prefixMatch = cleanFmt.match(/^([^#0?.,]+)/);
|
|
878
|
-
if (prefixMatch) {
|
|
879
|
-
prefix = prefixMatch[1];
|
|
880
|
-
cleanFmt = cleanFmt.substring(prefixMatch[0].length);
|
|
881
|
-
}
|
|
882
|
-
// Extract suffix
|
|
883
|
-
const suffixMatch = cleanFmt.match(/([^#0?.,]+)$/);
|
|
884
|
-
if (suffixMatch && !suffixMatch[1].includes("%")) {
|
|
885
|
-
suffix = suffixMatch[1];
|
|
886
|
-
cleanFmt = cleanFmt.substring(0, cleanFmt.length - suffixMatch[0].length);
|
|
887
|
-
}
|
|
888
|
-
// Format the number
|
|
889
|
-
const formattedNum = formatNumberPattern(numVal, cleanFmt);
|
|
890
|
-
return prefix + formattedNum + suffix;
|
|
22
|
+
return renderNumberFormat(fmt, val);
|
|
891
23
|
}
|
|
892
|
-
// =============================================================================
|
|
893
|
-
// Export
|
|
894
|
-
// =============================================================================
|
|
895
|
-
export const cellFormat = {
|
|
896
|
-
format,
|
|
897
|
-
getFormat,
|
|
898
|
-
isDateFormat,
|
|
899
|
-
isGeneral
|
|
900
|
-
};
|
|
901
24
|
/**
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
* Three things are removed before any code is looked for, and each was a way of reading a literal as a
|
|
905
|
-
* directive:
|
|
906
|
-
*
|
|
907
|
-
* - **Everything after the first `;`.** A format's sections are positive, negative, zero and text, and only
|
|
908
|
-
* the first applies to a date cell's value. Judging `";;;dd"` by its fourth section called a hidden number
|
|
909
|
-
* format a date. Split with `splitFormatSections`, which knows that a `;` inside quotes or brackets is not
|
|
910
|
-
* a separator — the same function the readers' `isDateFmt` uses, so the two cannot drift.
|
|
911
|
-
* - **Quoted literals**, so `#,##0" days"` is a number and not a day.
|
|
912
|
-
* - **Backslash escapes**, so `\d0` is a literal `d` and not a day code. This was missed here, and `format()`
|
|
913
|
-
* a few hundred lines below had already learned it.
|
|
914
|
-
*
|
|
915
|
-
* `elapsed` matches `[h]`, `[hh]`, `[mm]`, `[ss]` and the rest, not just the single-character forms.
|
|
916
|
-
* Requiring exactly one character meant `[hh]:mm` fell through to the date tests, where — its bracketed part
|
|
917
|
-
* stripped and a bare `:mm` left behind — it was read as a **month** and reported as a date format.
|
|
918
|
-
* `format()` had the right pattern already; this had a narrower copy of it.
|
|
919
|
-
*/
|
|
920
|
-
function formatFacets(fmt) {
|
|
921
|
-
const section = splitFormatSections(fmt)[0] ?? "";
|
|
922
|
-
const cleaned = section.replace(/"[^"]*"/g, "").replace(/\\./g, "");
|
|
923
|
-
const elapsed = /\[[hms]+\]/i.test(cleaned);
|
|
924
|
-
const bare = cleaned.replace(/\[[^\]]*\]/g, "");
|
|
925
|
-
const hasTime = /[hs]/i.test(bare) || /AM\/PM|A\/P/i.test(bare);
|
|
926
|
-
return {
|
|
927
|
-
elapsed,
|
|
928
|
-
hasTime,
|
|
929
|
-
hasYearOrDay: /[yd]/i.test(bare),
|
|
930
|
-
monthM: /m/i.test(bare) && !hasTime
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
/**
|
|
934
|
-
* Check if format is a pure time format (no date components like y, m for month, d).
|
|
935
|
-
* Time formats only contain: h, m (minutes in time context), s, AM/PM.
|
|
936
|
-
* Excludes elapsed time formats like [h]:mm:ss which need the full serial number.
|
|
25
|
+
* Check if format is a pure time format: a clock reading with no date part.
|
|
26
|
+
* Elapsed-time formats such as `[h]:mm:ss` are excluded — they need the whole serial.
|
|
937
27
|
*/
|
|
938
28
|
export function isTimeOnlyFormat(fmt) {
|
|
939
|
-
const { elapsed,
|
|
940
|
-
return !
|
|
29
|
+
const { elapsed, date, time, text } = numberFormatFacets(fmt);
|
|
30
|
+
return !text && !elapsed && !date && time;
|
|
941
31
|
}
|
|
942
32
|
/**
|
|
943
|
-
* Check if format
|
|
944
|
-
*
|
|
945
|
-
* formats like [h]:mm:ss (not a date format) and distinguishes month-m from minute-m.
|
|
33
|
+
* Check if format names a calendar date: a year, a day, or an `m` that reads as a month.
|
|
34
|
+
* Elapsed-time formats are not dates.
|
|
946
35
|
*/
|
|
947
36
|
export function isDateDisplayFormat(fmt) {
|
|
948
|
-
const { elapsed,
|
|
949
|
-
return !
|
|
37
|
+
const { elapsed, date, text } = numberFormatFacets(fmt);
|
|
38
|
+
return !text && !elapsed && date;
|
|
950
39
|
}
|
|
951
40
|
export function dateFormatKind(fmt) {
|
|
952
|
-
if (fmt === undefined ||
|
|
41
|
+
if (fmt === undefined || isGeneralFormat(fmt)) {
|
|
42
|
+
return "unknown";
|
|
43
|
+
}
|
|
44
|
+
const { elapsed, date, time, text } = numberFormatFacets(fmt);
|
|
45
|
+
// A text format (`yyyy@`) shows a number as text, so a reader keeps it a number: no date kind either.
|
|
46
|
+
if (text) {
|
|
953
47
|
return "unknown";
|
|
954
48
|
}
|
|
955
|
-
const { elapsed, hasTime, hasYearOrDay, monthM } = formatFacets(fmt);
|
|
956
49
|
if (elapsed) {
|
|
957
50
|
return "duration";
|
|
958
51
|
}
|
|
959
|
-
|
|
960
|
-
if (hasDate && hasTime) {
|
|
52
|
+
if (date && time) {
|
|
961
53
|
return "dateTime";
|
|
962
54
|
}
|
|
963
|
-
if (
|
|
55
|
+
if (date) {
|
|
964
56
|
return "date";
|
|
965
57
|
}
|
|
966
|
-
return
|
|
58
|
+
return time ? "time" : "unknown";
|
|
967
59
|
}
|
|
968
60
|
/**
|
|
969
61
|
* Default format applied to Date values whose numFmt is `General` or empty.
|
|
@@ -978,16 +70,20 @@ const DEFAULT_DATETIME_FORMAT = "yyyy-mm-dd hh:mm:ss";
|
|
|
978
70
|
/**
|
|
979
71
|
* Format a value according to the given format string.
|
|
980
72
|
* Handles Date objects with timezone-independent Excel serial conversion.
|
|
73
|
+
*
|
|
74
|
+
* `date1904` is the workbook's date system. A `Date` read from a 1904 workbook has to become the same serial
|
|
75
|
+
* again: a calendar date survives either epoch, because its fields come back out unchanged, but an elapsed
|
|
76
|
+
* format reads the whole serial — and `[h]:mm` showed 36 hours as 35124 when the epoch was dropped here.
|
|
981
77
|
*/
|
|
982
|
-
export function formatCellValue(value, fmt, dateFormat) {
|
|
78
|
+
export function formatCellValue(value, fmt, dateFormat, date1904 = false) {
|
|
983
79
|
if (value instanceof Date) {
|
|
984
|
-
let serial = dateToExcel(value);
|
|
80
|
+
let serial = dateToExcel(value, date1904);
|
|
985
81
|
if (isTimeOnlyFormat(fmt)) {
|
|
986
82
|
serial = serial % 1;
|
|
987
83
|
if (serial < 0) {
|
|
988
84
|
serial += 1;
|
|
989
85
|
}
|
|
990
|
-
return
|
|
86
|
+
return renderNumberFormat(fmt, serial, { date1904 });
|
|
991
87
|
}
|
|
992
88
|
// For Date values whose numFmt is missing or General, Excel substitutes a
|
|
993
89
|
// default short-date format. Without this, `format("General", serial)`
|
|
@@ -998,15 +94,15 @@ export function formatCellValue(value, fmt, dateFormat) {
|
|
|
998
94
|
if (dateFormat && isDateDisplayFormat(fmt)) {
|
|
999
95
|
effectiveFmt = dateFormat;
|
|
1000
96
|
}
|
|
1001
|
-
else if (
|
|
97
|
+
else if (isGeneralFormat(fmt)) {
|
|
1002
98
|
effectiveFmt = serial % 1 === 0 ? DEFAULT_DATE_FORMAT : DEFAULT_DATETIME_FORMAT;
|
|
1003
99
|
}
|
|
1004
100
|
else {
|
|
1005
101
|
effectiveFmt = fmt;
|
|
1006
102
|
}
|
|
1007
|
-
return
|
|
103
|
+
return renderNumberFormat(effectiveFmt, serial, { date1904 });
|
|
1008
104
|
}
|
|
1009
|
-
return
|
|
105
|
+
return renderNumberFormat(fmt, value, { date1904 });
|
|
1010
106
|
}
|
|
1011
107
|
/**
|
|
1012
108
|
* Get the formatted display text for a cell value.
|
|
@@ -1028,7 +124,7 @@ export function getCellDisplayText(cell, dateFormat) {
|
|
|
1028
124
|
typeof value === "number" ||
|
|
1029
125
|
typeof value === "boolean" ||
|
|
1030
126
|
typeof value === "string") {
|
|
1031
|
-
return formatCellValue(value, fmt, dateFormat);
|
|
127
|
+
return formatCellValue(value, fmt, dateFormat, cell.date1904);
|
|
1032
128
|
}
|
|
1033
129
|
// Formula type — use the result value. A shared-formula clone carries `sharedFormula` and no `formula`,
|
|
1034
130
|
// and is formatted exactly like the cell that defines it.
|
|
@@ -1041,7 +137,7 @@ export function getCellDisplayText(cell, dateFormat) {
|
|
|
1041
137
|
typeof result === "number" ||
|
|
1042
138
|
typeof result === "boolean" ||
|
|
1043
139
|
typeof result === "string") {
|
|
1044
|
-
return formatCellValue(result, fmt, dateFormat);
|
|
140
|
+
return formatCellValue(result, fmt, dateFormat, cell.date1904);
|
|
1045
141
|
}
|
|
1046
142
|
}
|
|
1047
143
|
// Fallback to cell.text for other types (rich text, hyperlink, error, etc.)
|