@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.
- package/package.json +30 -0
- package/src/auto_sum.test.ts +71 -0
- package/src/auto_sum.ts +83 -0
- package/src/canvas_cf.ts +135 -0
- package/src/canvas_chart.ts +687 -0
- package/src/canvas_fill.ts +156 -0
- package/src/canvas_images.ts +99 -0
- package/src/canvas_layout.test.ts +159 -0
- package/src/canvas_layout.ts +239 -0
- package/src/canvas_renderer.ts +1010 -0
- package/src/canvas_shape.ts +242 -0
- package/src/canvas_sparkline.ts +163 -0
- package/src/canvas_text.ts +454 -0
- package/src/cell_editor.ts +558 -0
- package/src/clipboard.test.ts +145 -0
- package/src/clipboard.ts +341 -0
- package/src/command_history.ts +102 -0
- package/src/csv_parser.test.ts +130 -0
- package/src/csv_parser.ts +172 -0
- package/src/data_validation.test.ts +95 -0
- package/src/data_validation.ts +114 -0
- package/src/editing_layer.test.ts +309 -0
- package/src/excel_cf_evaluate.test.ts +293 -0
- package/src/excel_cf_evaluate.ts +719 -0
- package/src/excel_cf_icons.ts +108 -0
- package/src/excel_cf_types.ts +67 -0
- package/src/excel_numfmt.test.ts +607 -0
- package/src/excel_numfmt.ts +1033 -0
- package/src/excel_parser.test.ts +1061 -0
- package/src/excel_parser.ts +393 -0
- package/src/excel_pattern_fills.ts +64 -0
- package/src/excel_table_styles.ts +160 -0
- package/src/excel_utils.test.ts +108 -0
- package/src/excel_utils.ts +162 -0
- package/src/fast-formula-parser.d.ts +53 -0
- package/src/fill_logic.ts +257 -0
- package/src/fill_patterns.test.ts +90 -0
- package/src/fill_patterns.ts +71 -0
- package/src/flash_fill.test.ts +75 -0
- package/src/flash_fill.ts +221 -0
- package/src/formula_deps.ts +189 -0
- package/src/formula_engine.test.ts +348 -0
- package/src/formula_engine.ts +401 -0
- package/src/formula_highlight.test.ts +81 -0
- package/src/formula_highlight.ts +139 -0
- package/src/formula_suggest.ts +100 -0
- package/src/hidden_sheets.test.ts +49 -0
- package/src/index.ts +149 -0
- package/src/keyboard_nav.test.ts +394 -0
- package/src/keyboard_nav.ts +891 -0
- package/src/move_logic.ts +63 -0
- package/src/numfmt_type.test.ts +158 -0
- package/src/numfmt_type.ts +231 -0
- package/src/ooxml_cell_format.ts +70 -0
- package/src/ooxml_chart.test.ts +85 -0
- package/src/ooxml_chart.ts +347 -0
- package/src/ooxml_chart_types.ts +207 -0
- package/src/ooxml_color.ts +339 -0
- package/src/ooxml_drawing.test.ts +87 -0
- package/src/ooxml_drawing.ts +287 -0
- package/src/ooxml_pivot.test.ts +195 -0
- package/src/ooxml_pivot.ts +468 -0
- package/src/ooxml_pivot_types.ts +165 -0
- package/src/ooxml_shape.ts +355 -0
- package/src/ooxml_sheet.ts +1271 -0
- package/src/ooxml_styles.ts +556 -0
- package/src/ooxml_table.test.ts +70 -0
- package/src/ooxml_table.ts +131 -0
- package/src/ooxml_workbook.test.ts +40 -0
- package/src/ooxml_workbook.ts +259 -0
- package/src/ooxml_zip.ts +33 -0
- package/src/pivot_model.ts +237 -0
- package/src/pivot_recompute.test.ts +210 -0
- package/src/pivot_recompute.ts +413 -0
- package/src/selection_model.ts +364 -0
- package/src/spreadsheet_commands.test.ts +772 -0
- package/src/spreadsheet_commands.ts +1805 -0
- package/src/structured_refs.test.ts +128 -0
- package/src/structured_refs.ts +160 -0
- package/src/style_helpers.test.ts +33 -0
- package/src/style_helpers.ts +30 -0
- package/src/template_builder.test.ts +139 -0
- package/src/template_builder.ts +36 -0
- package/src/types.ts +239 -0
- package/src/workbook_model.test.ts +536 -0
- package/src/workbook_model.ts +911 -0
- package/src/xlsx_round_trip.test.ts +279 -0
- package/src/xlsx_writer.test.ts +488 -0
- package/src/xlsx_writer.ts +1549 -0
- 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
|
+
}
|