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.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/dist/esm/modules/excel/core/cell.js +5 -6
  4. package/dist/esm/modules/excel/core/pivot-table.js +17 -9
  5. package/dist/esm/modules/excel/stream/xlsb-worksheet-reader.js +4 -6
  6. package/dist/esm/modules/excel/utils/cell-format-parse.js +112 -174
  7. package/dist/esm/modules/excel/utils/cell-format.js +42 -946
  8. package/dist/esm/modules/formula/functions/text.js +23 -631
  9. package/dist/esm/modules/pdf/excel-bridge.js +5 -5
  10. package/dist/esm/utils/number-format-render.js +814 -0
  11. package/dist/esm/utils/number-format.js +477 -0
  12. package/dist/esm/utils/utils.base.js +3 -70
  13. package/dist/iife/documonster.archive.iife.min.js +1 -1
  14. package/dist/iife/documonster.csv.iife.min.js +1 -1
  15. package/dist/iife/documonster.draw.iife.min.js +1 -1
  16. package/dist/iife/documonster.excel.iife.min.js +50 -50
  17. package/dist/iife/documonster.formula.iife.min.js +1 -1
  18. package/dist/iife/documonster.markdown.iife.min.js +1 -1
  19. package/dist/iife/documonster.mermaid.iife.min.js +1 -1
  20. package/dist/iife/documonster.pdf.iife.min.js +18 -18
  21. package/dist/iife/documonster.stream.iife.min.js +1 -1
  22. package/dist/iife/documonster.word.iife.min.js +60 -60
  23. package/dist/iife/documonster.xml.iife.min.js +1 -1
  24. package/dist/types/modules/excel/core/cell.d.ts +11 -0
  25. package/dist/types/modules/excel/core/pivot-table.d.ts +7 -0
  26. package/dist/types/modules/excel/utils/cell-format-parse.d.ts +13 -9
  27. package/dist/types/modules/excel/utils/cell-format.d.ts +21 -33
  28. package/dist/types/modules/excel/utils/text-metrics.d.ts +1 -0
  29. package/dist/types/utils/number-format-render.d.ts +47 -0
  30. package/dist/types/utils/number-format.d.ts +215 -0
  31. package/dist/types/utils/utils.base.d.ts +1 -9
  32. package/package.json +1 -1
@@ -1,969 +1,61 @@
1
- // oxlint-disable no-control-regex
2
1
  /**
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, 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
- * Threading `date1904` through would therefore change no output while widening a public signature, so it is
275
- * deliberately not done. Recorded because this reads like a bug on inspection, and has been reported as one.
276
- */
277
- function formatDate(serial, fmt) {
278
- // Extract time components directly from serial number (timezone-agnostic)
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
- function formatNumberPattern(val, fmt) {
551
- const absVal = Math.abs(val);
552
- const sign = val < 0 ? "-" : "";
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
- * Main format function - formats a value according to Excel numFmt
806
- * @param fmt The Excel number format string (e.g., "0.00%", "#,##0", "yyyy-mm-dd")
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
- // Handle General format
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
- * Read a format's date/time facets.
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, hasTime, hasYearOrDay, monthM } = formatFacets(fmt);
940
- return !elapsed && !hasYearOrDay && !monthM && hasTime;
29
+ const { elapsed, date, time, text } = numberFormatFacets(fmt);
30
+ return !text && !elapsed && !date && time;
941
31
  }
942
32
  /**
943
- * Check if format is a date format (contains y, d, or month-m).
944
- * More precise than the internal isDateFormat — correctly handles elapsed time
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, hasYearOrDay, monthM } = formatFacets(fmt);
949
- return !elapsed && (hasYearOrDay || monthM);
37
+ const { elapsed, date, text } = numberFormatFacets(fmt);
38
+ return !text && !elapsed && date;
950
39
  }
951
40
  export function dateFormatKind(fmt) {
952
- if (fmt === undefined || fmt === "" || isGeneral(fmt)) {
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
- const hasDate = hasYearOrDay || monthM;
960
- if (hasDate && hasTime) {
52
+ if (date && time) {
961
53
  return "dateTime";
962
54
  }
963
- if (hasDate) {
55
+ if (date) {
964
56
  return "date";
965
57
  }
966
- return hasTime ? "time" : "unknown";
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 format(fmt, serial);
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 (!fmt || isGeneral(fmt)) {
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 format(effectiveFmt, serial);
103
+ return renderNumberFormat(effectiveFmt, serial, { date1904 });
1008
104
  }
1009
- return format(fmt, value);
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.)