@lotics/xlsx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,1033 @@
1
+ // =============================================================================
2
+ // Excel Number Format Engine
3
+ // =============================================================================
4
+ //
5
+ // Full implementation of Excel custom number format codes:
6
+ // - 4-section format codes (positive; negative; zero; text)
7
+ // - Conditional colors ([Red], [Blue], [Color1]-[Color56])
8
+ // - Conditional sections ([>100], [<=50])
9
+ // - Date/time tokens (y, m, d, h, s, AM/PM)
10
+ // - Elapsed time ([h]:mm:ss, [m]:ss, [s])
11
+ // - Number formatting (0, #, ?, decimal, thousands, percentage)
12
+ // - Scientific notation (0.00E+00)
13
+ // - Fractions (# ?/?, # ??/??)
14
+ // - Currency/locale codes ([$EUR-407])
15
+ // - Fill characters (*x), underscore spacing (_x)
16
+ // - Text format (@)
17
+ // =============================================================================
18
+
19
+ export interface FormattedValue {
20
+ text: string;
21
+ color?: string;
22
+ }
23
+
24
+ // =============================================================================
25
+ // Color constants
26
+ // =============================================================================
27
+
28
+ const NAMED_COLORS: Record<string, string> = {
29
+ red: "#FF0000",
30
+ blue: "#0000FF",
31
+ green: "#008000",
32
+ yellow: "#FFFF00",
33
+ magenta: "#FF00FF",
34
+ cyan: "#00FFFF",
35
+ white: "#FFFFFF",
36
+ black: "#000000",
37
+ };
38
+
39
+ // =============================================================================
40
+ // Date/time constants
41
+ // =============================================================================
42
+
43
+ const MONTH_NAMES = [
44
+ "January", "February", "March", "April", "May", "June",
45
+ "July", "August", "September", "October", "November", "December",
46
+ ];
47
+ const MONTH_ABBREVS = [
48
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
49
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
50
+ ];
51
+ const DAY_NAMES = [
52
+ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
53
+ ];
54
+ const DAY_ABBREVS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
55
+
56
+ // =============================================================================
57
+ // Excel epoch
58
+ // =============================================================================
59
+
60
+ // Days from Jan 1, 1900 (serial 1) to Jan 1, 1970 (Unix epoch) = 25567 real days.
61
+ // After the Lotus bug correction (subtract 1 for serials > 60), the offset is 25568.
62
+ const EXCEL_EPOCH_OFFSET = 25568;
63
+ const MS_PER_DAY = 86400000;
64
+
65
+ interface DateParts {
66
+ year: number;
67
+ month: number;
68
+ day: number;
69
+ dayOfWeek: number;
70
+ hours: number;
71
+ minutes: number;
72
+ seconds: number;
73
+ milliseconds: number;
74
+ }
75
+
76
+ export function serialToDateParts(serial: number, date1904: boolean): DateParts {
77
+ const adjusted = date1904 ? serial + 1462 : serial;
78
+ const corrected = adjusted > 60 ? adjusted - 1 : adjusted;
79
+
80
+ const dayPart = Math.floor(corrected);
81
+ const timeFraction = corrected - dayPart;
82
+
83
+ const ms = (dayPart - EXCEL_EPOCH_OFFSET) * MS_PER_DAY;
84
+ const date = new Date(ms);
85
+
86
+ const totalMs = Math.round(timeFraction * MS_PER_DAY);
87
+ const hours = Math.floor(totalMs / 3600000);
88
+ const minutes = Math.floor((totalMs % 3600000) / 60000);
89
+ const seconds = Math.floor((totalMs % 60000) / 1000);
90
+ const milliseconds = totalMs % 1000;
91
+
92
+ return {
93
+ year: date.getUTCFullYear(),
94
+ month: date.getUTCMonth() + 1,
95
+ day: date.getUTCDate(),
96
+ dayOfWeek: date.getUTCDay(),
97
+ hours,
98
+ minutes,
99
+ seconds,
100
+ milliseconds,
101
+ };
102
+ }
103
+
104
+ // =============================================================================
105
+ // Regex patterns
106
+ // =============================================================================
107
+
108
+ const COLOR_RE = /\[(Red|Blue|Green|Yellow|Magenta|Cyan|White|Black|Color\d{1,2})\]/i;
109
+ const CONDITION_RE = /\[(>=?|<=?|=|<>)\s*(-?[\d.]+)\]/;
110
+ const LOCALE_RE = /\[\$([^\]]*)\]/g;
111
+ const ELAPSED_RE = /\[([hms])\]/i;
112
+
113
+ // =============================================================================
114
+ // Main entry point
115
+ // =============================================================================
116
+
117
+ export function formatNumFmt(
118
+ value: number | string,
119
+ formatCode: string,
120
+ date1904 = false,
121
+ ): FormattedValue {
122
+ if (!formatCode || formatCode === "General") {
123
+ return { text: typeof value === "string" ? value : formatGeneral(value) };
124
+ }
125
+
126
+ if (typeof value === "string") {
127
+ const sections = splitSections(formatCode);
128
+ const textSection = sections.length === 4 ? sections[3] : formatCode;
129
+ const { color, rest } = extractColor(textSection);
130
+ return { text: formatTextSection(value, rest), color };
131
+ }
132
+
133
+ const sections = splitSections(formatCode);
134
+ const { section, absValue, negative } = selectSection(sections, value);
135
+ const { color, rest } = extractColor(section);
136
+ const cleaned = stripBracketExpressions(rest);
137
+ const text = formatSection(absValue, cleaned, date1904);
138
+ return { text: negative ? `-${text}` : text, color };
139
+ }
140
+
141
+ // =============================================================================
142
+ // Section splitting — respects quoted strings and brackets
143
+ // =============================================================================
144
+
145
+ function splitSections(code: string): string[] {
146
+ const sections: string[] = [];
147
+ let current = "";
148
+ let inQuote = false;
149
+ let inBracket = false;
150
+
151
+ for (let i = 0; i < code.length; i++) {
152
+ const ch = code[i];
153
+ if (ch === '"' && !inBracket) {
154
+ inQuote = !inQuote;
155
+ current += ch;
156
+ } else if (inQuote) {
157
+ current += ch;
158
+ } else if (ch === "[") {
159
+ inBracket = true;
160
+ current += ch;
161
+ } else if (ch === "]" && inBracket) {
162
+ inBracket = false;
163
+ current += ch;
164
+ } else if (ch === ";" && !inBracket) {
165
+ sections.push(current);
166
+ current = "";
167
+ } else {
168
+ current += ch;
169
+ }
170
+ }
171
+ sections.push(current);
172
+ return sections;
173
+ }
174
+
175
+ // =============================================================================
176
+ // Section selection — picks correct section based on value sign
177
+ // =============================================================================
178
+
179
+ function selectSection(
180
+ sections: string[],
181
+ value: number,
182
+ ): { section: string; absValue: number; negative: boolean } {
183
+ // Check for conditional sections
184
+ if (sections.length === 2) {
185
+ const cond0 = extractCondition(sections[0]);
186
+ if (cond0.condition) {
187
+ if (evalCondition(value, cond0.condition)) {
188
+ return { section: sections[0], absValue: value, negative: false };
189
+ }
190
+ return { section: sections[1], absValue: value, negative: false };
191
+ }
192
+ }
193
+
194
+ switch (sections.length) {
195
+ case 1:
196
+ return {
197
+ section: sections[0],
198
+ absValue: Math.abs(value),
199
+ negative: value < 0,
200
+ };
201
+ case 2:
202
+ if (value >= 0) return { section: sections[0], absValue: value, negative: false };
203
+ return { section: sections[1], absValue: Math.abs(value), negative: false };
204
+ default: {
205
+ // 3 or 4 sections: positive; negative; zero [; text]
206
+ if (value > 0) return { section: sections[0], absValue: value, negative: false };
207
+ if (value < 0) return { section: sections[1], absValue: Math.abs(value), negative: false };
208
+ return { section: sections[2], absValue: 0, negative: false };
209
+ }
210
+ }
211
+ }
212
+
213
+ // =============================================================================
214
+ // Bracket expression parsing
215
+ // =============================================================================
216
+
217
+ function extractColor(section: string): { color?: string; rest: string } {
218
+ const match = section.match(COLOR_RE);
219
+ if (!match || match.index === undefined) return { rest: section };
220
+ const name = match[1].toLowerCase();
221
+ const color = NAMED_COLORS[name];
222
+ const rest = section.slice(0, match.index) + section.slice(match.index + match[0].length);
223
+ return { color, rest };
224
+ }
225
+
226
+ function extractCondition(section: string): {
227
+ condition?: { op: string; val: number };
228
+ rest: string;
229
+ } {
230
+ const match = section.match(CONDITION_RE);
231
+ if (!match || match.index === undefined) return { rest: section };
232
+ return {
233
+ condition: { op: match[1], val: parseFloat(match[2]) },
234
+ rest: section.slice(0, match.index) + section.slice(match.index + match[0].length),
235
+ };
236
+ }
237
+
238
+ function evalCondition(value: number, cond: { op: string; val: number }): boolean {
239
+ switch (cond.op) {
240
+ case ">": return value > cond.val;
241
+ case ">=": return value >= cond.val;
242
+ case "<": return value < cond.val;
243
+ case "<=": return value <= cond.val;
244
+ case "=": return value === cond.val;
245
+ case "<>": return value !== cond.val;
246
+ default: return false;
247
+ }
248
+ }
249
+
250
+ function stripBracketExpressions(section: string): string {
251
+ // Remove condition brackets and locale codes, but keep elapsed time brackets
252
+ let result = section.replace(CONDITION_RE, "");
253
+ // Replace locale codes: [$symbol-locale] → "symbol" as literal, [$-locale] → nothing
254
+ result = result.replace(LOCALE_RE, (_match, inner: string) => {
255
+ const dashIdx = inner.indexOf("-");
256
+ const symbol = dashIdx >= 0 ? inner.slice(0, dashIdx) : inner;
257
+ return symbol ? `"${symbol}"` : "";
258
+ });
259
+ return result;
260
+ }
261
+
262
+ // =============================================================================
263
+ // Section type detection
264
+ // =============================================================================
265
+
266
+ function isDateSection(section: string): boolean {
267
+ let cleaned = section.replace(/"[^"]*"/g, "");
268
+ cleaned = cleaned.replace(/\[\$[^\]]*\]/g, "");
269
+ cleaned = cleaned.replace(COLOR_RE, "");
270
+ if (ELAPSED_RE.test(cleaned)) return true;
271
+ if (/[#0?]/.test(cleaned) && !ELAPSED_RE.test(cleaned)) return false;
272
+ if (cleaned.includes("%")) return false;
273
+ if (/E[+-]/i.test(cleaned)) return false;
274
+ return /[ymdhs]/i.test(cleaned);
275
+ }
276
+
277
+ // =============================================================================
278
+ // Format dispatcher
279
+ // =============================================================================
280
+
281
+ function formatSection(value: number, section: string, date1904: boolean): string {
282
+ if (!section || section === "General") return formatGeneral(value);
283
+ if (section === "@") return String(value);
284
+ if (isDateSection(section)) return formatDateSection(value, section, date1904);
285
+ return formatNumberSection(value, section);
286
+ }
287
+
288
+ // =============================================================================
289
+ // General format — Excel's default formatting
290
+ // =============================================================================
291
+
292
+ function formatGeneral(value: number): string {
293
+ if (value === 0) return "0";
294
+ const abs = Math.abs(value);
295
+ // Excel uses scientific notation for very large numbers
296
+ if (abs >= 1e11) {
297
+ let s = value.toPrecision(6);
298
+ s = s.replace(/\.?0+(e)/, "$1").replace("e+", "E+").replace("e-", "E-");
299
+ return s;
300
+ }
301
+ // Up to 11 significant digits, JS handles decimal/scientific notation switch
302
+ const s = parseFloat(value.toPrecision(11)).toString();
303
+ return s.replace("e+", "E+").replace("e-", "E-");
304
+ }
305
+
306
+ // =============================================================================
307
+ // Text section formatter
308
+ // =============================================================================
309
+
310
+ function formatTextSection(value: string, section: string): string {
311
+ let result = "";
312
+ let i = 0;
313
+ while (i < section.length) {
314
+ const ch = section[i];
315
+ if (ch === "@") {
316
+ result += value;
317
+ i++;
318
+ } else if (ch === '"') {
319
+ i++;
320
+ while (i < section.length && section[i] !== '"') { result += section[i]; i++; }
321
+ i++;
322
+ } else if (ch === "\\") {
323
+ i++;
324
+ if (i < section.length) { result += section[i]; i++; }
325
+ } else if (ch === "_") {
326
+ result += " ";
327
+ i += 2;
328
+ } else if (ch === "*") {
329
+ i += 2;
330
+ } else if (ch === "[") {
331
+ while (i < section.length && section[i] !== "]") i++;
332
+ i++;
333
+ } else {
334
+ result += ch;
335
+ i++;
336
+ }
337
+ }
338
+ return result;
339
+ }
340
+
341
+ // =============================================================================
342
+ // Date/time formatter
343
+ // =============================================================================
344
+
345
+ interface DateToken {
346
+ type: "literal" | "year" | "month" | "day" | "hour" | "minute" | "second" | "subsecond" | "ampm";
347
+ length: number;
348
+ value: string;
349
+ elapsed?: boolean;
350
+ }
351
+
352
+ function formatDateSection(serial: number, section: string, date1904: boolean): string {
353
+ const parts = serialToDateParts(serial, date1904);
354
+ const tokens = tokenizeDateFormat(section);
355
+
356
+ const hasElapsedH = tokens.some((t) => t.type === "hour" && t.elapsed);
357
+ const hasElapsedM = tokens.some((t) => t.type === "minute" && t.elapsed);
358
+ const hasElapsedS = tokens.some((t) => t.type === "second" && t.elapsed);
359
+ const use12h = tokens.some((t) => t.type === "ampm");
360
+
361
+ let displayHours = parts.hours;
362
+ let ampm = "";
363
+ if (use12h) {
364
+ ampm = parts.hours >= 12 ? "PM" : "AM";
365
+ displayHours = parts.hours % 12 || 12;
366
+ }
367
+
368
+ const totalSeconds = Math.floor(serial * 86400);
369
+ const totalMinutes = Math.floor(serial * 1440);
370
+ const totalHours = Math.floor(serial * 24);
371
+
372
+ let result = "";
373
+ for (const token of tokens) {
374
+ switch (token.type) {
375
+ case "literal":
376
+ result += token.value;
377
+ break;
378
+ case "year":
379
+ result += token.length <= 2
380
+ ? String(parts.year % 100).padStart(2, "0")
381
+ : String(parts.year);
382
+ break;
383
+ case "month":
384
+ switch (token.length) {
385
+ case 1: result += String(parts.month); break;
386
+ case 2: result += String(parts.month).padStart(2, "0"); break;
387
+ case 3: result += MONTH_ABBREVS[parts.month - 1]; break;
388
+ case 4: result += MONTH_NAMES[parts.month - 1]; break;
389
+ default: result += MONTH_NAMES[parts.month - 1][0]; break;
390
+ }
391
+ break;
392
+ case "day":
393
+ switch (token.length) {
394
+ case 1: result += String(parts.day); break;
395
+ case 2: result += String(parts.day).padStart(2, "0"); break;
396
+ case 3: result += DAY_ABBREVS[parts.dayOfWeek]; break;
397
+ default: result += DAY_NAMES[parts.dayOfWeek]; break;
398
+ }
399
+ break;
400
+ case "hour":
401
+ if (hasElapsedH && token.elapsed) {
402
+ result += String(totalHours);
403
+ } else {
404
+ result += token.length === 1 ? String(displayHours) : String(displayHours).padStart(2, "0");
405
+ }
406
+ break;
407
+ case "minute":
408
+ if (hasElapsedM && token.elapsed) {
409
+ result += String(totalMinutes);
410
+ } else {
411
+ result += token.length === 1 ? String(parts.minutes) : String(parts.minutes).padStart(2, "0");
412
+ }
413
+ break;
414
+ case "second":
415
+ if (hasElapsedS && token.elapsed) {
416
+ result += String(totalSeconds);
417
+ } else {
418
+ result += token.length === 1 ? String(parts.seconds) : String(parts.seconds).padStart(2, "0");
419
+ }
420
+ break;
421
+ case "subsecond": {
422
+ const frac = parts.milliseconds / 1000;
423
+ result += frac.toFixed(token.length).slice(1);
424
+ break;
425
+ }
426
+ case "ampm": {
427
+ const short = token.length === 3;
428
+ result += short ? ampm[0] : ampm;
429
+ break;
430
+ }
431
+ }
432
+ }
433
+ return result;
434
+ }
435
+
436
+ function tokenizeDateFormat(section: string): DateToken[] {
437
+ const tokens: DateToken[] = [];
438
+ let i = 0;
439
+
440
+ while (i < section.length) {
441
+ const ch = section[i];
442
+ const lo = ch.toLowerCase();
443
+
444
+ // Quoted string
445
+ if (ch === '"') {
446
+ i++;
447
+ let lit = "";
448
+ while (i < section.length && section[i] !== '"') { lit += section[i]; i++; }
449
+ i++;
450
+ tokens.push({ type: "literal", length: lit.length, value: lit });
451
+ continue;
452
+ }
453
+
454
+ // Escaped char
455
+ if (ch === "\\") {
456
+ i++;
457
+ tokens.push({ type: "literal", length: 1, value: i < section.length ? section[i] : "" });
458
+ i++;
459
+ continue;
460
+ }
461
+
462
+ // Underscore spacing
463
+ if (ch === "_") {
464
+ tokens.push({ type: "literal", length: 1, value: " " });
465
+ i += Math.min(i + 2, section.length) - i;
466
+ continue;
467
+ }
468
+
469
+ // Fill char
470
+ if (ch === "*") {
471
+ i += Math.min(i + 2, section.length) - i;
472
+ continue;
473
+ }
474
+
475
+ // Bracket expression
476
+ if (ch === "[") {
477
+ const closeIdx = section.indexOf("]", i);
478
+ if (closeIdx === -1) { i++; continue; }
479
+ const inner = section.slice(i + 1, closeIdx).toLowerCase();
480
+ if (inner === "h" || inner === "m" || inner === "s") {
481
+ const typeMap: Record<string, "hour" | "minute" | "second"> = {
482
+ h: "hour", m: "minute", s: "second",
483
+ };
484
+ tokens.push({ type: typeMap[inner], length: 1, value: inner, elapsed: true });
485
+ }
486
+ i = closeIdx + 1;
487
+ continue;
488
+ }
489
+
490
+ // AM/PM or A/P
491
+ if (section.slice(i, i + 5).toUpperCase() === "AM/PM") {
492
+ tokens.push({ type: "ampm", length: 5, value: "AM/PM" });
493
+ i += 5;
494
+ continue;
495
+ }
496
+ if (section.slice(i, i + 3).toUpperCase() === "A/P") {
497
+ tokens.push({ type: "ampm", length: 3, value: "A/P" });
498
+ i += 3;
499
+ continue;
500
+ }
501
+
502
+ // Date/time letters
503
+ if (lo === "y" || lo === "d" || lo === "h" || lo === "s" || lo === "m") {
504
+ let len = 0;
505
+ while (i + len < section.length && section[i + len].toLowerCase() === lo) len++;
506
+ const typeMap: Record<string, DateToken["type"]> = {
507
+ y: "year", d: "day", h: "hour", s: "second", m: "month",
508
+ };
509
+ tokens.push({ type: typeMap[lo], length: len, value: section.slice(i, i + len) });
510
+ i += len;
511
+ continue;
512
+ }
513
+
514
+ // Subsecond: .0, .00, .000
515
+ if (ch === "." && i + 1 < section.length && section[i + 1] === "0") {
516
+ let len = 0;
517
+ let j = i + 1;
518
+ while (j < section.length && section[j] === "0") { len++; j++; }
519
+ tokens.push({ type: "subsecond", length: len, value: section.slice(i, j) });
520
+ i = j;
521
+ continue;
522
+ }
523
+
524
+ // Literal
525
+ tokens.push({ type: "literal", length: 1, value: ch });
526
+ i++;
527
+ }
528
+
529
+ // Resolve m/mm ambiguity: if preceded by h/[h] or followed by s/[s], treat as minutes
530
+ for (let t = 0; t < tokens.length; t++) {
531
+ if (tokens[t].type !== "month") continue;
532
+ let prev: DateToken | undefined;
533
+ for (let p = t - 1; p >= 0; p--) {
534
+ if (tokens[p].type !== "literal") { prev = tokens[p]; break; }
535
+ }
536
+ let next: DateToken | undefined;
537
+ for (let n = t + 1; n < tokens.length; n++) {
538
+ if (tokens[n].type !== "literal") { next = tokens[n]; break; }
539
+ }
540
+ if (prev?.type === "hour" || next?.type === "second" || next?.type === "subsecond") {
541
+ tokens[t] = { ...tokens[t], type: "minute" };
542
+ }
543
+ }
544
+
545
+ return tokens;
546
+ }
547
+
548
+ // =============================================================================
549
+ // Number section formatter
550
+ // =============================================================================
551
+
552
+ function formatNumberSection(value: number, section: string): string {
553
+ const isPercent = containsUnquoted(section, "%");
554
+ let v = isPercent ? value * 100 : value;
555
+
556
+ const { scaled, cleaned: scaledSection } = applyTrailingCommaScaling(v, section);
557
+ v = scaled;
558
+
559
+ if (hasScientific(scaledSection)) return formatScientific(v, scaledSection);
560
+ if (hasFraction(scaledSection)) return formatFraction(v, scaledSection);
561
+ return formatDigitPattern(v, scaledSection);
562
+ }
563
+
564
+ function containsUnquoted(section: string, ch: string): boolean {
565
+ let inQuote = false;
566
+ for (let i = 0; i < section.length; i++) {
567
+ if (section[i] === '"') { inQuote = !inQuote; continue; }
568
+ if (!inQuote && section[i] === ch) return true;
569
+ }
570
+ return false;
571
+ }
572
+
573
+ function hasScientific(section: string): boolean {
574
+ let inQuote = false;
575
+ for (let i = 0; i < section.length - 1; i++) {
576
+ if (section[i] === '"') { inQuote = !inQuote; continue; }
577
+ if (!inQuote && section[i].toUpperCase() === "E" && (section[i + 1] === "+" || section[i + 1] === "-")) {
578
+ return true;
579
+ }
580
+ }
581
+ return false;
582
+ }
583
+
584
+ function hasFraction(section: string): boolean {
585
+ let inQuote = false;
586
+ for (let i = 1; i < section.length - 1; i++) {
587
+ if (section[i] === '"') { inQuote = !inQuote; continue; }
588
+ if (!inQuote && section[i] === "/") {
589
+ const before = section.slice(0, i);
590
+ const after = section.slice(i + 1);
591
+ if (/[0#?]/.test(before) && /[0#?\d]/.test(after)) return true;
592
+ }
593
+ }
594
+ return false;
595
+ }
596
+
597
+ function applyTrailingCommaScaling(
598
+ value: number,
599
+ section: string,
600
+ ): { scaled: number; cleaned: string } {
601
+ // Trailing commas after the last digit placeholder scale down by 1000 each
602
+ const lastDigit = findLastDigitPlaceholder(section);
603
+ if (lastDigit < 0) return { scaled: value, cleaned: section };
604
+
605
+ let count = 0;
606
+ let i = lastDigit + 1;
607
+ while (i < section.length && section[i] === ",") { count++; i++; }
608
+ if (count === 0) return { scaled: value, cleaned: section };
609
+
610
+ const cleaned = section.slice(0, lastDigit + 1) + section.slice(lastDigit + 1 + count);
611
+ return { scaled: value / Math.pow(1000, count), cleaned };
612
+ }
613
+
614
+ function findLastDigitPlaceholder(section: string): number {
615
+ let inQuote = false;
616
+ let last = -1;
617
+ for (let i = 0; i < section.length; i++) {
618
+ if (section[i] === '"') { inQuote = !inQuote; continue; }
619
+ if (!inQuote && /[0#?]/.test(section[i])) last = i;
620
+ }
621
+ return last;
622
+ }
623
+
624
+ // =============================================================================
625
+ // Digit pattern formatter — handles #,##0.00 style formats
626
+ // =============================================================================
627
+
628
+ function formatDigitPattern(value: number, section: string): string {
629
+ const { intZeros, decSpec, hasThousands, hasDecimal } = analyzePattern(section);
630
+
631
+ const decPlaces = decSpec.length;
632
+ const rounded = roundTo(Math.abs(value), decPlaces);
633
+ const intPart = Math.trunc(rounded);
634
+ const decPart = rounded - intPart;
635
+
636
+ // Build integer string
637
+ let intStr = String(intPart);
638
+ while (intStr.length < intZeros) intStr = "0" + intStr;
639
+ // Suppress zero integer when format is all # (e.g., #.## for 0.5 → .5)
640
+ if (intPart === 0 && intZeros === 0) intStr = "";
641
+ if (hasThousands && intStr.length > 0) {
642
+ intStr = addThousandsSep(intStr);
643
+ }
644
+
645
+ // Build decimal string
646
+ let decStr = "";
647
+ if (hasDecimal && decPlaces > 0) {
648
+ const raw = decPart.toFixed(decPlaces).slice(2); // skip "0."
649
+ decStr = applyDecimalSpec(raw, decSpec);
650
+ }
651
+
652
+ // Walk the format template and substitute
653
+ return walkTemplate(section, intStr, decStr, hasDecimal);
654
+ }
655
+
656
+ interface PatternInfo {
657
+ intZeros: number;
658
+ decSpec: string;
659
+ hasThousands: boolean;
660
+ hasDecimal: boolean;
661
+ }
662
+
663
+ function analyzePattern(section: string): PatternInfo {
664
+ let inQuote = false;
665
+ let intZeros = 0;
666
+ let decSpec = "";
667
+ let hasThousands = false;
668
+ let hasDecimal = false;
669
+ let afterDecimal = false;
670
+ let seenDigit = false;
671
+
672
+ for (let i = 0; i < section.length; i++) {
673
+ const ch = section[i];
674
+ if (ch === '"') { inQuote = !inQuote; continue; }
675
+ if (inQuote) continue;
676
+ if (ch === "\\") { i++; continue; }
677
+ if (ch === "_" || ch === "*") { i++; continue; }
678
+ if (ch === "[") { while (i < section.length && section[i] !== "]") i++; continue; }
679
+
680
+ if (ch === ".") {
681
+ hasDecimal = true;
682
+ afterDecimal = true;
683
+ continue;
684
+ }
685
+
686
+ if (afterDecimal && (ch === "0" || ch === "#" || ch === "?")) {
687
+ decSpec += ch;
688
+ continue;
689
+ }
690
+
691
+ if (!afterDecimal && ch === "0") {
692
+ intZeros++;
693
+ seenDigit = true;
694
+ continue;
695
+ }
696
+ if (!afterDecimal && (ch === "#" || ch === "?")) {
697
+ seenDigit = true;
698
+ continue;
699
+ }
700
+ if (!afterDecimal && ch === "," && seenDigit) {
701
+ hasThousands = true;
702
+ }
703
+ }
704
+
705
+ return { intZeros, decSpec, hasThousands, hasDecimal };
706
+ }
707
+
708
+ function applyDecimalSpec(raw: string, spec: string): string {
709
+ let result = "";
710
+ for (let i = 0; i < spec.length; i++) {
711
+ const ch = spec[i];
712
+ const digit = raw[i] ?? "0";
713
+ if (ch === "0") {
714
+ result += digit;
715
+ } else if (ch === "#") {
716
+ // Trim trailing zeros for # positions
717
+ if (/^0*$/.test(raw.slice(i))) break;
718
+ result += digit;
719
+ } else if (ch === "?") {
720
+ result += digit === "0" && /^0*$/.test(raw.slice(i)) ? " " : digit;
721
+ }
722
+ }
723
+ return result;
724
+ }
725
+
726
+ function addThousandsSep(s: string): string {
727
+ const parts: string[] = [];
728
+ for (let i = s.length; i > 0; i -= 3) {
729
+ parts.unshift(s.slice(Math.max(0, i - 3), i));
730
+ }
731
+ return parts.join(",");
732
+ }
733
+
734
+ function roundTo(value: number, places: number): number {
735
+ if (places <= 0) return Math.round(value);
736
+ const f = Math.pow(10, places);
737
+ return Math.round(value * f) / f;
738
+ }
739
+
740
+ // Walk the format template, replacing digit placeholders with formatted values
741
+ function walkTemplate(
742
+ section: string,
743
+ intStr: string,
744
+ decStr: string,
745
+ hasDecimal: boolean,
746
+ ): string {
747
+ let result = "";
748
+ let i = 0;
749
+ let intEmitted = false;
750
+ let decEmitted = false;
751
+
752
+ while (i < section.length) {
753
+ const ch = section[i];
754
+
755
+ // Quoted string
756
+ if (ch === '"') {
757
+ i++;
758
+ while (i < section.length && section[i] !== '"') { result += section[i]; i++; }
759
+ i++;
760
+ continue;
761
+ }
762
+
763
+ // Escaped char
764
+ if (ch === "\\") {
765
+ i++;
766
+ if (i < section.length) { result += section[i]; i++; }
767
+ continue;
768
+ }
769
+
770
+ // Underscore spacing
771
+ if (ch === "_") {
772
+ result += " ";
773
+ i += 2;
774
+ continue;
775
+ }
776
+
777
+ // Fill char — skip
778
+ if (ch === "*") {
779
+ i += 2;
780
+ continue;
781
+ }
782
+
783
+ // Bracket expression — skip
784
+ if (ch === "[") {
785
+ while (i < section.length && section[i] !== "]") i++;
786
+ i++;
787
+ continue;
788
+ }
789
+
790
+ // Digit placeholder in integer zone (before decimal)
791
+ if (!decEmitted && (ch === "0" || ch === "#" || ch === "?")) {
792
+ if (!intEmitted) {
793
+ result += intStr;
794
+ intEmitted = true;
795
+ }
796
+ // Skip all integer digit placeholders and commas
797
+ while (i < section.length && /[0#?,]/.test(section[i])) i++;
798
+ continue;
799
+ }
800
+
801
+ // Comma in integer zone before any digit is emitted
802
+ if (!intEmitted && ch === ",") {
803
+ i++;
804
+ continue;
805
+ }
806
+
807
+ // Decimal point
808
+ if (ch === "." && intEmitted && !decEmitted) {
809
+ if (hasDecimal && decStr.length > 0) {
810
+ result += "." + decStr;
811
+ } else if (hasDecimal) {
812
+ // Decimal with no decimal digits — just show the dot if format has 0s
813
+ // Actually if decStr is empty, we might still want to show the dot
814
+ }
815
+ decEmitted = true;
816
+ i++;
817
+ // Skip decimal digit placeholders
818
+ while (i < section.length && /[0#?]/.test(section[i])) i++;
819
+ continue;
820
+ }
821
+
822
+ // Percent
823
+ if (ch === "%") {
824
+ result += "%";
825
+ i++;
826
+ continue;
827
+ }
828
+
829
+ // Currency and other literal chars
830
+ result += ch;
831
+ i++;
832
+ }
833
+
834
+ return result;
835
+ }
836
+
837
+ // =============================================================================
838
+ // Scientific notation formatter
839
+ // =============================================================================
840
+
841
+ function formatScientific(value: number, section: string): string {
842
+ // Find E+/E- position (outside quotes)
843
+ let ePos = -1;
844
+ let eSign = "+";
845
+ let inQuote = false;
846
+ for (let i = 0; i < section.length - 1; i++) {
847
+ if (section[i] === '"') { inQuote = !inQuote; continue; }
848
+ if (!inQuote && section[i].toUpperCase() === "E" && (section[i + 1] === "+" || section[i + 1] === "-")) {
849
+ ePos = i;
850
+ eSign = section[i + 1];
851
+ break;
852
+ }
853
+ }
854
+ if (ePos < 0) return String(value);
855
+
856
+ const coeffFmt = section.slice(0, ePos);
857
+ const expFmt = section.slice(ePos + 2);
858
+
859
+ // Count coefficient integer and decimal digits
860
+ const coeffInfo = analyzePattern(coeffFmt);
861
+ const intDigitCount = Math.max(coeffInfo.intZeros, 1);
862
+ const decDigits = coeffInfo.decSpec.length;
863
+ const expMinDigits = (expFmt.match(/[0#?]/g) ?? []).length || 1;
864
+
865
+ if (value === 0) {
866
+ const coeffStr = (0).toFixed(decDigits);
867
+ const expStr = "0".padStart(expMinDigits, "0");
868
+ return `${coeffStr}E${eSign}${expStr}`;
869
+ }
870
+
871
+ const abs = Math.abs(value);
872
+ const rawExp = Math.floor(Math.log10(abs));
873
+ // Exponent is a multiple of intDigitCount (engineering notation when > 1)
874
+ const exp = Math.floor(rawExp / intDigitCount) * intDigitCount;
875
+ const coeff = value / Math.pow(10, exp);
876
+ const coeffStr = roundTo(Math.abs(coeff), decDigits).toFixed(decDigits);
877
+
878
+ const absExp = Math.abs(exp);
879
+ const expStr = String(absExp).padStart(expMinDigits, "0");
880
+ const sign = exp >= 0 ? (eSign === "+" ? "+" : "") : "-";
881
+
882
+ return `${value < 0 ? "-" : ""}${coeffStr}E${sign}${expStr}`;
883
+ }
884
+
885
+ // =============================================================================
886
+ // Fraction formatter
887
+ // =============================================================================
888
+
889
+ function formatFraction(value: number, section: string): string {
890
+ const abs = Math.abs(value);
891
+ const intPart = Math.floor(abs);
892
+ const fracPart = abs - intPart;
893
+
894
+ // Find the slash position (outside quotes)
895
+ let slashPos = -1;
896
+ let inQuote = false;
897
+ for (let i = 0; i < section.length; i++) {
898
+ if (section[i] === '"') { inQuote = !inQuote; continue; }
899
+ if (!inQuote && section[i] === "/") { slashPos = i; break; }
900
+ }
901
+ if (slashPos < 0) return String(value);
902
+
903
+ // Parse denominator: fixed number or variable digits
904
+ const afterSlash = section.slice(slashPos + 1);
905
+ const fixedDenomMatch = afterSlash.match(/^(\d+)/);
906
+ const varDenomMatch = afterSlash.match(/^([?#0]+)/);
907
+ let denom: number;
908
+ let numer: number;
909
+ let denomWidth: number;
910
+
911
+ if (fixedDenomMatch) {
912
+ denom = parseInt(fixedDenomMatch[1]);
913
+ denomWidth = fixedDenomMatch[1].length;
914
+ numer = Math.round(fracPart * denom);
915
+ if (numer >= denom) { numer -= denom; /* integer would be intPart+1 but we keep simple */ }
916
+ } else if (varDenomMatch) {
917
+ denomWidth = varDenomMatch[1].length;
918
+ const maxDenom = Math.pow(10, denomWidth) - 1;
919
+ const best = bestFraction(fracPart, maxDenom);
920
+ numer = best.num;
921
+ denom = best.den;
922
+ } else {
923
+ return String(value);
924
+ }
925
+
926
+ // Parse numerator width
927
+ const beforeSlash = section.slice(0, slashPos);
928
+ const numMatch = beforeSlash.match(/([?#0]+)\s*$/);
929
+ const numWidth = numMatch ? numMatch[1].length : 1;
930
+
931
+ // Check if format has an integer part (digit placeholder before the fraction pattern)
932
+ const beforeNum = numMatch ? beforeSlash.slice(0, numMatch.index!) : beforeSlash;
933
+ const hasIntPart = /[#0?]/.test(beforeNum.replace(/"[^"]*"/g, ""));
934
+
935
+ // Prefix: everything before the integer/fraction pattern
936
+ let prefix = "";
937
+ {
938
+ const stripped = beforeNum.replace(/"[^"]*"/g, "");
939
+ // Walk to extract prefix literals
940
+ const firstDigit = stripped.search(/[#0?]/);
941
+ if (firstDigit < 0) {
942
+ prefix = extractLiterals(beforeNum);
943
+ } else {
944
+ prefix = extractLiterals(beforeNum.slice(0, findRawIndex(beforeNum, firstDigit)));
945
+ }
946
+ }
947
+
948
+ // Build fraction string
949
+ const numStr = numer === 0 && hasIntPart
950
+ ? " ".repeat(numWidth)
951
+ : String(numer).padStart(numWidth, " ");
952
+ const denStr = numer === 0 && hasIntPart
953
+ ? " ".repeat(denomWidth)
954
+ : String(denom).padStart(denomWidth, " ");
955
+ const divider = numer === 0 && hasIntPart ? " " : "/";
956
+
957
+ const intDisplay = hasIntPart ? (intPart > 0 || numer === 0 ? String(intPart) : "") : "";
958
+ const space = hasIntPart && intDisplay ? " " : "";
959
+
960
+ if (!hasIntPart && numer === 0) return `${prefix}0`;
961
+ if (!hasIntPart) {
962
+ // Improper fraction
963
+ const totalNum = intPart * denom + numer;
964
+ return `${prefix}${String(totalNum).padStart(numWidth, " ")}/${denStr}`;
965
+ }
966
+
967
+ return `${prefix}${intDisplay}${space}${numStr}${divider}${denStr}`;
968
+ }
969
+
970
+ function bestFraction(value: number, maxDenom: number): { num: number; den: number } {
971
+ if (value < 1e-12) return { num: 0, den: 1 };
972
+ let bestNum = 0;
973
+ let bestDen = 1;
974
+ let bestErr = value;
975
+ for (let d = 1; d <= maxDenom; d++) {
976
+ const n = Math.round(value * d);
977
+ if (n > maxDenom) continue;
978
+ const err = Math.abs(value - n / d);
979
+ if (err < bestErr - 1e-12) {
980
+ bestErr = err;
981
+ bestNum = n;
982
+ bestDen = d;
983
+ if (err < 1e-12) break;
984
+ }
985
+ }
986
+ return { num: bestNum, den: bestDen };
987
+ }
988
+
989
+ function extractLiterals(section: string): string {
990
+ let result = "";
991
+ let i = 0;
992
+ while (i < section.length) {
993
+ if (section[i] === '"') {
994
+ i++;
995
+ while (i < section.length && section[i] !== '"') { result += section[i]; i++; }
996
+ i++;
997
+ } else if (section[i] === "\\") {
998
+ i++;
999
+ if (i < section.length) { result += section[i]; i++; }
1000
+ } else if (section[i] === "_") {
1001
+ result += " ";
1002
+ i += 2;
1003
+ } else if (section[i] === "*") {
1004
+ i += 2;
1005
+ } else if (section[i] === "[") {
1006
+ while (i < section.length && section[i] !== "]") i++;
1007
+ i++;
1008
+ } else if (/[0#?,%.]/.test(section[i])) {
1009
+ // Skip format chars
1010
+ i++;
1011
+ } else {
1012
+ result += section[i];
1013
+ i++;
1014
+ }
1015
+ }
1016
+ return result;
1017
+ }
1018
+
1019
+ function findRawIndex(s: string, targetCleanIdx: number): number {
1020
+ let clean = 0;
1021
+ let i = 0;
1022
+ while (i < s.length && clean < targetCleanIdx) {
1023
+ if (s[i] === '"') {
1024
+ i++;
1025
+ while (i < s.length && s[i] !== '"') i++;
1026
+ i++;
1027
+ } else {
1028
+ clean++;
1029
+ i++;
1030
+ }
1031
+ }
1032
+ return i;
1033
+ }