@pbkware/dot-net-date-number-formatting 0.2.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/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/code/datetime-formatter.js +604 -0
- package/dist/code/datetime-formatter.js.map +1 -0
- package/dist/code/datetime-style.js +101 -0
- package/dist/code/datetime-style.js.map +1 -0
- package/dist/code/index.js +6 -0
- package/dist/code/index.js.map +1 -0
- package/dist/code/locale-settings.js +404 -0
- package/dist/code/locale-settings.js.map +1 -0
- package/dist/code/number-formatter.js +916 -0
- package/dist/code/number-formatter.js.map +1 -0
- package/dist/code/number-style.js +211 -0
- package/dist/code/number-style.js.map +1 -0
- package/dist/types/dot-net-date-number-formatting-untrimmed.d.ts +795 -0
- package/dist/types/public-api.d.ts +714 -0
- package/dist/types/tsdoc-metadata.json +11 -0
- package/package.json +63 -0
- package/src/code/datetime-formatter.ts +718 -0
- package/src/code/datetime-style.ts +128 -0
- package/src/code/index.ts +5 -0
- package/src/code/locale-settings.ts +453 -0
- package/src/code/number-formatter.ts +1108 -0
- package/src/code/number-style.ts +247 -0
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
import { Err, Ok, Result } from "@pbkware/js-utils";
|
|
2
|
+
import { DotNetLocaleSettings } from "./locale-settings.js";
|
|
3
|
+
import {
|
|
4
|
+
DotNetNumberStyleId,
|
|
5
|
+
DotNetNumberStyleSet,
|
|
6
|
+
DotNetNumberStyles,
|
|
7
|
+
} from "./number-style.js";
|
|
8
|
+
|
|
9
|
+
type ElementType =
|
|
10
|
+
| "standard"
|
|
11
|
+
| "zero"
|
|
12
|
+
| "digit"
|
|
13
|
+
| "decimal"
|
|
14
|
+
| "thousands"
|
|
15
|
+
| "percent"
|
|
16
|
+
| "permille"
|
|
17
|
+
| "exponent"
|
|
18
|
+
| "literal"
|
|
19
|
+
| "section";
|
|
20
|
+
|
|
21
|
+
interface Element {
|
|
22
|
+
type: ElementType;
|
|
23
|
+
text?: string;
|
|
24
|
+
count?: number;
|
|
25
|
+
exponentCase?: "upper" | "lower";
|
|
26
|
+
exponentSign?: "+" | "-" | "none";
|
|
27
|
+
exponentDigits?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface FormatSection {
|
|
31
|
+
elements: Element[];
|
|
32
|
+
hasDecimal: boolean;
|
|
33
|
+
integerDigits: number;
|
|
34
|
+
decimalDigits: number;
|
|
35
|
+
scale: number; // Number of ,, dividers
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Base class for formatting and parsing numbers using .NET-compatible format strings.
|
|
40
|
+
*
|
|
41
|
+
* Supports both standard format strings (C, D, E, F, G, N, P, R, X, B) and custom format strings
|
|
42
|
+
* with fine-grained control over digit placeholders, separators, and sections.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* This is a base class - use {@link DotNetIntegerFormatter}, {@link DotNetFloatFormatter},
|
|
46
|
+
* or {@link DotNetDecimalFormatter} for specific number types.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* // Use derived classes instead
|
|
51
|
+
* const formatter = new DotNetFloatFormatter();
|
|
52
|
+
* formatter.localeSettings = DotNetLocaleSettings.createInvariant();
|
|
53
|
+
* formatter.trySetFormat('C2');
|
|
54
|
+
* console.log(formatter.toString(1234.56)); // "$1,234.56"
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @public
|
|
58
|
+
* @category Numeric Formatting
|
|
59
|
+
*/
|
|
60
|
+
export class DotNetNumberFormatter {
|
|
61
|
+
protected format = "";
|
|
62
|
+
private formatIsStandard = false;
|
|
63
|
+
private precision = 0;
|
|
64
|
+
private sections: FormatSection[] = [];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The set of {@link DotNetNumberStyleId} flags that control which number formats are allowed during parsing.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* // Use predefined styles
|
|
72
|
+
* formatter.styles = DotNetNumberStyles.number;
|
|
73
|
+
*
|
|
74
|
+
* // Or combine individual flags
|
|
75
|
+
* formatter.styles = new Set([
|
|
76
|
+
* DotNetNumberStyleId.AllowLeadingSign,
|
|
77
|
+
* DotNetNumberStyleId.AllowDecimalPoint
|
|
78
|
+
* ]);
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
styles: DotNetNumberStyleSet = new Set(DotNetNumberStyles.number);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The locale settings that determine decimal/thousands separators and other culture-specific formatting.
|
|
85
|
+
*/
|
|
86
|
+
localeSettings = DotNetLocaleSettings.current;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Contains the error message from the last failed operation.
|
|
90
|
+
* Check this property if {@link DotNetNumberFormatter.trySetFormat} or parsing methods return an error.
|
|
91
|
+
*/
|
|
92
|
+
parseErrorText = "";
|
|
93
|
+
|
|
94
|
+
protected setParseErrorText(value: string): false {
|
|
95
|
+
this.parseErrorText = value;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sets the format string to use for formatting numbers.
|
|
101
|
+
*
|
|
102
|
+
* The format string is tokenized to confirm validity and to optimize formatting/parsing operations.
|
|
103
|
+
*
|
|
104
|
+
* @param value - A standard format string (e.g., "C", "N2", "E3") or custom format string (e.g., "#,##0.00").
|
|
105
|
+
* @returns A Result indicating success or containing an error message if the format string is invalid.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```typescript
|
|
109
|
+
* // Standard format
|
|
110
|
+
* formatter.trySetFormat('C2'); // Currency with 2 decimal places
|
|
111
|
+
*
|
|
112
|
+
* // Custom format
|
|
113
|
+
* formatter.trySetFormat('#,##0.00'); // Number with thousands separator
|
|
114
|
+
*
|
|
115
|
+
* // Check for errors
|
|
116
|
+
* const result = formatter.trySetFormat('INVALID');
|
|
117
|
+
* if (result.isErr()) {
|
|
118
|
+
* console.error(result.error);
|
|
119
|
+
* }
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
trySetFormat(value: string): Result<void> {
|
|
123
|
+
if (value.length === 0) {
|
|
124
|
+
return new Err("Format string cannot be empty");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.format = value;
|
|
128
|
+
this.formatIsStandard =
|
|
129
|
+
value.length === 1 ||
|
|
130
|
+
(value.length === 2 && /^\w\d$/.test(value)) ||
|
|
131
|
+
(value.length === 3 && /^\w\d\d$/.test(value));
|
|
132
|
+
|
|
133
|
+
if (this.formatIsStandard) {
|
|
134
|
+
// Parse standard format: C, C2, D, D5, etc.
|
|
135
|
+
const formatChar = value[0]; // Keep original case
|
|
136
|
+
const formatCharUpper = formatChar.toUpperCase();
|
|
137
|
+
const precisionStr = value.length > 1 ? value.slice(1) : "";
|
|
138
|
+
this.precision = precisionStr ? parseInt(precisionStr, 10) || 0 : -1; // -1 means no precision specified
|
|
139
|
+
|
|
140
|
+
// Validate standard format specifiers
|
|
141
|
+
if (!"BCDEFGNPRX".includes(formatCharUpper)) {
|
|
142
|
+
return new Err(`Unknown standard format specifier: ${formatChar}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.sections = [
|
|
146
|
+
{
|
|
147
|
+
elements: [{ type: "standard", text: formatChar }], // Store original case
|
|
148
|
+
hasDecimal: false,
|
|
149
|
+
integerDigits: 0,
|
|
150
|
+
decimalDigits: 0,
|
|
151
|
+
scale: 0,
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
return new Ok(undefined);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Parse custom format
|
|
158
|
+
const parsed = this.parseCustomFormat(value);
|
|
159
|
+
if (parsed.isErr()) {
|
|
160
|
+
return parsed.createType<void>();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.sections = parsed.value;
|
|
164
|
+
return new Ok(undefined);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private parseCustomFormat(format: string): Result<FormatSection[]> {
|
|
168
|
+
const sections: FormatSection[] = [];
|
|
169
|
+
let currentSection: Element[] = [];
|
|
170
|
+
let i = 0;
|
|
171
|
+
|
|
172
|
+
while (i < format.length) {
|
|
173
|
+
const char = format[i];
|
|
174
|
+
|
|
175
|
+
if (char === ";") {
|
|
176
|
+
sections.push(this.analyzeSection(currentSection));
|
|
177
|
+
currentSection = [];
|
|
178
|
+
i++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (char === "\\") {
|
|
183
|
+
// Escape character
|
|
184
|
+
if (i + 1 < format.length) {
|
|
185
|
+
currentSection.push({ type: "literal", text: format[i + 1] });
|
|
186
|
+
i += 2;
|
|
187
|
+
} else {
|
|
188
|
+
return new Err("Trailing escape character");
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (char === '"' || char === "'") {
|
|
194
|
+
// Literal string
|
|
195
|
+
const endQuote = format.indexOf(char, i + 1);
|
|
196
|
+
if (endQuote === -1) {
|
|
197
|
+
return new Err(
|
|
198
|
+
`Unterminated string literal starting at position ${i}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const literal = format.slice(i + 1, endQuote);
|
|
202
|
+
if (literal.length > 0) {
|
|
203
|
+
currentSection.push({ type: "literal", text: literal });
|
|
204
|
+
}
|
|
205
|
+
i = endQuote + 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (char === "0") {
|
|
210
|
+
let count = 1;
|
|
211
|
+
while (i + count < format.length && format[i + count] === "0") {
|
|
212
|
+
count++;
|
|
213
|
+
}
|
|
214
|
+
currentSection.push({ type: "zero", count });
|
|
215
|
+
i += count;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (char === "#") {
|
|
220
|
+
let count = 1;
|
|
221
|
+
while (i + count < format.length && format[i + count] === "#") {
|
|
222
|
+
count++;
|
|
223
|
+
}
|
|
224
|
+
currentSection.push({ type: "digit", count });
|
|
225
|
+
i += count;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (char === ".") {
|
|
230
|
+
currentSection.push({ type: "decimal" });
|
|
231
|
+
i++;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (char === ",") {
|
|
236
|
+
currentSection.push({ type: "thousands" });
|
|
237
|
+
i++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (char === "%") {
|
|
242
|
+
currentSection.push({ type: "percent" });
|
|
243
|
+
i++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (char === "‰") {
|
|
248
|
+
currentSection.push({ type: "permille" });
|
|
249
|
+
i++;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (char === "E" || char === "e") {
|
|
254
|
+
// Exponential notation: E+0, E-0, e+00, etc.
|
|
255
|
+
const exponentCase = char === "E" ? "upper" : "lower";
|
|
256
|
+
let exponentSign: "+" | "-" | "none" = "none";
|
|
257
|
+
let j = i + 1;
|
|
258
|
+
|
|
259
|
+
if (j < format.length && (format[j] === "+" || format[j] === "-")) {
|
|
260
|
+
exponentSign = format[j] as "+" | "-";
|
|
261
|
+
j++;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let exponentDigits = 0;
|
|
265
|
+
while (j < format.length && format[j] === "0") {
|
|
266
|
+
exponentDigits++;
|
|
267
|
+
j++;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (exponentDigits > 0) {
|
|
271
|
+
currentSection.push({
|
|
272
|
+
type: "exponent",
|
|
273
|
+
exponentCase,
|
|
274
|
+
exponentSign,
|
|
275
|
+
exponentDigits,
|
|
276
|
+
});
|
|
277
|
+
i = j;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Any other character is treated as literal
|
|
283
|
+
currentSection.push({ type: "literal", text: char });
|
|
284
|
+
i++;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (currentSection.length > 0) {
|
|
288
|
+
sections.push(this.analyzeSection(currentSection));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (sections.length === 0) {
|
|
292
|
+
return new Err("Empty format string");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return new Ok(sections);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private analyzeSection(elements: Element[]): FormatSection {
|
|
299
|
+
let hasDecimal = false;
|
|
300
|
+
let decimalIndex = -1;
|
|
301
|
+
let integerDigits = 0;
|
|
302
|
+
let decimalDigits = 0;
|
|
303
|
+
let scale = 0;
|
|
304
|
+
|
|
305
|
+
for (let i = 0; i < elements.length; i++) {
|
|
306
|
+
const elem = elements[i];
|
|
307
|
+
if (elem.type === "decimal") {
|
|
308
|
+
hasDecimal = true;
|
|
309
|
+
decimalIndex = i;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Count scale (number of ,, at the end before decimal or at end)
|
|
314
|
+
let scaleCheckIdx =
|
|
315
|
+
decimalIndex >= 0 ? decimalIndex - 1 : elements.length - 1;
|
|
316
|
+
while (scaleCheckIdx >= 0 && elements[scaleCheckIdx].type === "thousands") {
|
|
317
|
+
scale++;
|
|
318
|
+
scaleCheckIdx--;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Count integer and decimal digit placeholders
|
|
322
|
+
for (let i = 0; i < elements.length; i++) {
|
|
323
|
+
const elem = elements[i];
|
|
324
|
+
if (elem.type === "zero" || elem.type === "digit") {
|
|
325
|
+
if (hasDecimal && i > decimalIndex) {
|
|
326
|
+
decimalDigits += elem.count || 1;
|
|
327
|
+
} else if (!hasDecimal || i < decimalIndex) {
|
|
328
|
+
integerDigits += elem.count || 1;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { elements, hasDecimal, integerDigits, decimalDigits, scale };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
protected formatNumber(
|
|
337
|
+
value: number | bigint,
|
|
338
|
+
allowDecimal: boolean = true,
|
|
339
|
+
): string {
|
|
340
|
+
if (this.format === "") {
|
|
341
|
+
return value.toString();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const numValue = typeof value === "bigint" ? Number(value) : value;
|
|
345
|
+
|
|
346
|
+
if (this.formatIsStandard) {
|
|
347
|
+
return this.formatStandard(numValue, allowDecimal);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return this.formatCustom(numValue, allowDecimal);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private formatStandard(value: number, allowDecimal: boolean): string {
|
|
354
|
+
const formatChar = this.sections[0].elements[0].text || "G";
|
|
355
|
+
const precision = this.precision;
|
|
356
|
+
|
|
357
|
+
switch (formatChar.toUpperCase()) {
|
|
358
|
+
case "C": // Currency
|
|
359
|
+
return this.formatCurrency(value, precision);
|
|
360
|
+
case "D": // Decimal (integers only)
|
|
361
|
+
return this.formatDecimal(value, precision);
|
|
362
|
+
case "E": // Exponential
|
|
363
|
+
return this.formatExponential(value, precision, formatChar === "E");
|
|
364
|
+
case "F": // Fixed-point
|
|
365
|
+
return this.formatFixedPoint(value, precision);
|
|
366
|
+
case "G": // General
|
|
367
|
+
return this.formatGeneral(value, precision, formatChar === "G");
|
|
368
|
+
case "N": // Number with group separators
|
|
369
|
+
return this.formatNumber_(value, precision);
|
|
370
|
+
case "P": // Percent
|
|
371
|
+
return this.formatPercent(value, precision);
|
|
372
|
+
case "R": // Round-trip
|
|
373
|
+
return value.toString();
|
|
374
|
+
case "X": // Hexadecimal
|
|
375
|
+
return this.formatHex(value, precision, formatChar === "X");
|
|
376
|
+
case "B": // Binary
|
|
377
|
+
return this.formatBinary(value, precision, formatChar === "B");
|
|
378
|
+
default:
|
|
379
|
+
return value.toString();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private formatCurrency(value: number, precision: number): string {
|
|
384
|
+
const p = precision > 0 ? precision : 2;
|
|
385
|
+
const formatted = new Intl.NumberFormat(this.localeSettings.name, {
|
|
386
|
+
style: "currency",
|
|
387
|
+
currency: "USD",
|
|
388
|
+
minimumFractionDigits: p,
|
|
389
|
+
maximumFractionDigits: p,
|
|
390
|
+
}).format(value);
|
|
391
|
+
return formatted.replace("$", this.localeSettings.currencyString);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private formatDecimal(value: number, precision: number): string {
|
|
395
|
+
const intValue = Math.trunc(value);
|
|
396
|
+
const str = Math.abs(intValue).toString();
|
|
397
|
+
const p = this.precision >= 0 ? this.precision : 0;
|
|
398
|
+
const padded = p > 0 ? str.padStart(p, "0") : str;
|
|
399
|
+
return intValue < 0 ? "-" + padded : padded;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private formatExponential(
|
|
403
|
+
value: number,
|
|
404
|
+
precision: number,
|
|
405
|
+
uppercase: boolean,
|
|
406
|
+
): string {
|
|
407
|
+
const p = this.precision >= 0 ? this.precision : 6;
|
|
408
|
+
const formatted = value.toExponential(p);
|
|
409
|
+
return uppercase ? formatted.toUpperCase() : formatted.toLowerCase();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private formatFixedPoint(value: number, precision: number): string {
|
|
413
|
+
const p = this.precision >= 0 ? this.precision : 2;
|
|
414
|
+
return value.toFixed(p).replace(".", this.localeSettings.decimalSeparator);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private formatGeneral(
|
|
418
|
+
value: number,
|
|
419
|
+
precision: number,
|
|
420
|
+
uppercase: boolean,
|
|
421
|
+
): string {
|
|
422
|
+
// General format uses the more compact of fixed-point or scientific
|
|
423
|
+
const absValue = Math.abs(value);
|
|
424
|
+
if (absValue === 0) {
|
|
425
|
+
return "0";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const exp = Math.floor(Math.log10(absValue));
|
|
429
|
+
const p = precision > 0 ? precision : 15;
|
|
430
|
+
|
|
431
|
+
// Use scientific notation if exponent is < -4 or >= 10
|
|
432
|
+
if (exp < -4 || exp >= 10) {
|
|
433
|
+
const formatted = value.toExponential(Math.max(0, p - 1));
|
|
434
|
+
let result = uppercase
|
|
435
|
+
? formatted.toUpperCase()
|
|
436
|
+
: formatted.toLowerCase();
|
|
437
|
+
// Remove trailing zeros from mantissa
|
|
438
|
+
result = result.replace(/(\..+?)0+([eE])/, "$1$2");
|
|
439
|
+
return result;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Use fixed-point
|
|
443
|
+
let formatted = value.toPrecision(p);
|
|
444
|
+
// Remove trailing zeros after decimal point
|
|
445
|
+
if (formatted.includes(".")) {
|
|
446
|
+
formatted = formatted.replace(/\.?0+$/, "");
|
|
447
|
+
}
|
|
448
|
+
return formatted.replace(".", this.localeSettings.decimalSeparator);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private formatNumber_(value: number, precision: number): string {
|
|
452
|
+
const p = this.precision >= 0 ? this.precision : 2;
|
|
453
|
+
return new Intl.NumberFormat(this.localeSettings.name, {
|
|
454
|
+
minimumFractionDigits: p,
|
|
455
|
+
maximumFractionDigits: p,
|
|
456
|
+
useGrouping: true,
|
|
457
|
+
}).format(value);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private formatPercent(value: number, precision: number): string {
|
|
461
|
+
const p = precision >= 0 ? precision : 2;
|
|
462
|
+
return new Intl.NumberFormat(this.localeSettings.name, {
|
|
463
|
+
style: "percent",
|
|
464
|
+
minimumFractionDigits: p,
|
|
465
|
+
maximumFractionDigits: p,
|
|
466
|
+
}).format(value);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private formatHex(
|
|
470
|
+
value: number,
|
|
471
|
+
precision: number,
|
|
472
|
+
uppercase: boolean,
|
|
473
|
+
): string {
|
|
474
|
+
const intValue = Math.trunc(value);
|
|
475
|
+
let hex = Math.abs(intValue).toString(16);
|
|
476
|
+
if (precision > 0) {
|
|
477
|
+
hex = hex.padStart(precision, "0");
|
|
478
|
+
}
|
|
479
|
+
return uppercase ? hex.toUpperCase() : hex.toLowerCase();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private formatBinary(
|
|
483
|
+
value: number,
|
|
484
|
+
precision: number,
|
|
485
|
+
uppercase: boolean,
|
|
486
|
+
): string {
|
|
487
|
+
const intValue = Math.trunc(value);
|
|
488
|
+
let binary = (intValue >>> 0).toString(2);
|
|
489
|
+
if (precision > 0) {
|
|
490
|
+
binary = binary.padStart(precision, "0");
|
|
491
|
+
}
|
|
492
|
+
return binary;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private formatCustom(value: number, allowDecimal: boolean): string {
|
|
496
|
+
// Determine which section to use based on value
|
|
497
|
+
let section: FormatSection;
|
|
498
|
+
if (this.sections.length === 1) {
|
|
499
|
+
section = this.sections[0];
|
|
500
|
+
} else if (this.sections.length === 2) {
|
|
501
|
+
section = value >= 0 ? this.sections[0] : this.sections[1];
|
|
502
|
+
value = Math.abs(value);
|
|
503
|
+
} else {
|
|
504
|
+
// 3 sections: positive, negative, zero
|
|
505
|
+
if (value > 0) {
|
|
506
|
+
section = this.sections[0];
|
|
507
|
+
} else if (value < 0) {
|
|
508
|
+
section = this.sections[1];
|
|
509
|
+
value = Math.abs(value);
|
|
510
|
+
} else {
|
|
511
|
+
section = this.sections[2];
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return this.formatWithSection(value, section, allowDecimal);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private formatWithSection(
|
|
519
|
+
value: number,
|
|
520
|
+
section: FormatSection,
|
|
521
|
+
allowDecimal: boolean,
|
|
522
|
+
): string {
|
|
523
|
+
let workingValue = value;
|
|
524
|
+
let hasPercent = false;
|
|
525
|
+
let hasPermille = false;
|
|
526
|
+
let hasExponent = false;
|
|
527
|
+
|
|
528
|
+
// Check for percent/permille/exponent
|
|
529
|
+
for (const elem of section.elements) {
|
|
530
|
+
if (elem.type === "percent") {
|
|
531
|
+
hasPercent = true;
|
|
532
|
+
workingValue *= 100;
|
|
533
|
+
} else if (elem.type === "permille") {
|
|
534
|
+
hasPermille = true;
|
|
535
|
+
workingValue *= 1000;
|
|
536
|
+
} else if (elem.type === "exponent") {
|
|
537
|
+
hasExponent = true;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Apply scaling
|
|
542
|
+
if (section.scale > 0) {
|
|
543
|
+
workingValue /= Math.pow(1000, section.scale);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Format based on whether we have exponential notation
|
|
547
|
+
if (hasExponent) {
|
|
548
|
+
return this.formatCustomExponential(workingValue, section);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Round to appropriate decimal places
|
|
552
|
+
const absValue = Math.abs(workingValue);
|
|
553
|
+
const rounded =
|
|
554
|
+
section.hasDecimal && section.decimalDigits > 0
|
|
555
|
+
? absValue.toFixed(section.decimalDigits)
|
|
556
|
+
: Math.round(absValue).toString();
|
|
557
|
+
|
|
558
|
+
const [intPart, fracPart] = rounded.split(".");
|
|
559
|
+
const intStr = intPart || "0";
|
|
560
|
+
const fracStr = fracPart || "";
|
|
561
|
+
|
|
562
|
+
// Build the result by processing elements in order
|
|
563
|
+
let result = "";
|
|
564
|
+
let intPrinted = false;
|
|
565
|
+
let fracPrinted = false;
|
|
566
|
+
|
|
567
|
+
// Find decimal position
|
|
568
|
+
let decimalPos = -1;
|
|
569
|
+
let hasThousands = false;
|
|
570
|
+
for (let i = 0; i < section.elements.length; i++) {
|
|
571
|
+
if (section.elements[i].type === "decimal") {
|
|
572
|
+
decimalPos = i;
|
|
573
|
+
}
|
|
574
|
+
if (section.elements[i].type === "thousands") {
|
|
575
|
+
hasThousands = true;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
for (let i = 0; i < section.elements.length; i++) {
|
|
580
|
+
const elem = section.elements[i];
|
|
581
|
+
|
|
582
|
+
if (elem.type === "literal") {
|
|
583
|
+
result += elem.text || "";
|
|
584
|
+
} else if (elem.type === "decimal") {
|
|
585
|
+
if (allowDecimal && (fracStr.length > 0 || section.decimalDigits > 0)) {
|
|
586
|
+
result += this.localeSettings.decimalSeparator;
|
|
587
|
+
}
|
|
588
|
+
} else if (elem.type === "percent") {
|
|
589
|
+
result += "%";
|
|
590
|
+
} else if (elem.type === "permille") {
|
|
591
|
+
result += "‰";
|
|
592
|
+
} else if (
|
|
593
|
+
(elem.type === "zero" || elem.type === "digit") &&
|
|
594
|
+
!intPrinted &&
|
|
595
|
+
(decimalPos === -1 || i < decimalPos)
|
|
596
|
+
) {
|
|
597
|
+
// This is the first integer placeholder - format entire integer part
|
|
598
|
+
let formattedInt = intStr;
|
|
599
|
+
|
|
600
|
+
// Add thousands separators if needed
|
|
601
|
+
if (hasThousands && formattedInt.length > 3) {
|
|
602
|
+
const parts: string[] = [];
|
|
603
|
+
for (let j = formattedInt.length; j > 0; j -= 3) {
|
|
604
|
+
const start = Math.max(0, j - 3);
|
|
605
|
+
parts.unshift(formattedInt.slice(start, j));
|
|
606
|
+
}
|
|
607
|
+
formattedInt = parts.join(this.localeSettings.thousandSeparator);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
result += formattedInt;
|
|
611
|
+
intPrinted = true;
|
|
612
|
+
} else if (
|
|
613
|
+
(elem.type === "zero" || elem.type === "digit") &&
|
|
614
|
+
!fracPrinted &&
|
|
615
|
+
decimalPos >= 0 &&
|
|
616
|
+
i > decimalPos
|
|
617
|
+
) {
|
|
618
|
+
// This is the first fractional placeholder - format entire fractional part
|
|
619
|
+
result += fracStr;
|
|
620
|
+
fracPrinted = true;
|
|
621
|
+
}
|
|
622
|
+
// Skip thousands separators as they're handled above
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private formatCustomExponential(
|
|
629
|
+
value: number,
|
|
630
|
+
section: FormatSection,
|
|
631
|
+
): string {
|
|
632
|
+
// Find exponent element
|
|
633
|
+
let exponentElem: Element | undefined;
|
|
634
|
+
for (const elem of section.elements) {
|
|
635
|
+
if (elem.type === "exponent") {
|
|
636
|
+
exponentElem = elem;
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (!exponentElem) {
|
|
642
|
+
return value.toString();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)));
|
|
646
|
+
const mantissa = value / Math.pow(10, exp);
|
|
647
|
+
|
|
648
|
+
// Format mantissa using the format elements before 'E'
|
|
649
|
+
const mantissaSection = { ...section };
|
|
650
|
+
mantissaSection.elements = section.elements.filter(
|
|
651
|
+
(e) => e.type !== "exponent",
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
let result = this.formatWithSection(mantissa, mantissaSection, true);
|
|
655
|
+
|
|
656
|
+
// Add exponent
|
|
657
|
+
const expChar = exponentElem.exponentCase === "upper" ? "E" : "e";
|
|
658
|
+
const expSign = exp >= 0 ? "+" : "-";
|
|
659
|
+
const absExp = Math.abs(exp);
|
|
660
|
+
const expStr = absExp
|
|
661
|
+
.toString()
|
|
662
|
+
.padStart(exponentElem.exponentDigits || 1, "0");
|
|
663
|
+
|
|
664
|
+
if (exponentElem.exponentSign === "+") {
|
|
665
|
+
result += expChar + expSign + expStr;
|
|
666
|
+
} else if (exponentElem.exponentSign === "-") {
|
|
667
|
+
result += expChar + (exp < 0 ? "-" : "") + expStr;
|
|
668
|
+
} else {
|
|
669
|
+
result += expChar + expStr;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return result;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
protected trimTrailingPadZeros(value: string): string {
|
|
676
|
+
const decimal = this.localeSettings.decimalSeparator;
|
|
677
|
+
const decimalIdx = value.indexOf(decimal);
|
|
678
|
+
if (decimalIdx < 0) {
|
|
679
|
+
return value;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let firstPadZeroIdx = -1;
|
|
683
|
+
for (let idx = decimalIdx + 1; idx < value.length; idx += 1) {
|
|
684
|
+
if (value[idx] !== "0") {
|
|
685
|
+
firstPadZeroIdx = -1;
|
|
686
|
+
} else if (firstPadZeroIdx < 0) {
|
|
687
|
+
firstPadZeroIdx = idx;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (firstPadZeroIdx < 0) {
|
|
692
|
+
return value;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (firstPadZeroIdx === decimalIdx + 1) {
|
|
696
|
+
return value.slice(0, decimalIdx);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return value.slice(0, firstPadZeroIdx);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
hasExponentChar(value: string): boolean {
|
|
703
|
+
return value.includes("E") || value.includes("e");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
hasDecimalChar(value: string): boolean {
|
|
707
|
+
return (
|
|
708
|
+
value.includes(this.localeSettings.decimalSeparator) ||
|
|
709
|
+
value.includes(".")
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
hasDigitChar(value: string): boolean {
|
|
714
|
+
return /\d/.test(value);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
static tryHexToInt64(hex: string): Result<bigint> {
|
|
718
|
+
const trimmed = hex.trim();
|
|
719
|
+
if (
|
|
720
|
+
trimmed.length === 0 ||
|
|
721
|
+
trimmed.length > 16 ||
|
|
722
|
+
!/^[0-9a-fA-F]+$/.test(trimmed)
|
|
723
|
+
) {
|
|
724
|
+
return new Err("Invalid hex format");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
return new Ok(BigInt(`0x${trimmed}`));
|
|
729
|
+
} catch {
|
|
730
|
+
return new Err("Invalid hex format");
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
protected unstyleNumberString(
|
|
735
|
+
value: string,
|
|
736
|
+
): Result<{ unstyled: string; negated: boolean }> {
|
|
737
|
+
let unstyled = value;
|
|
738
|
+
|
|
739
|
+
if (
|
|
740
|
+
this.styles.has(DotNetNumberStyleId.AllowLeadingWhite) &&
|
|
741
|
+
this.styles.has(DotNetNumberStyleId.AllowTrailingWhite)
|
|
742
|
+
) {
|
|
743
|
+
unstyled = unstyled.trim();
|
|
744
|
+
} else if (this.styles.has(DotNetNumberStyleId.AllowLeadingWhite)) {
|
|
745
|
+
unstyled = unstyled.trimStart();
|
|
746
|
+
} else if (this.styles.has(DotNetNumberStyleId.AllowTrailingWhite)) {
|
|
747
|
+
unstyled = unstyled.trimEnd();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (this.styles.has(DotNetNumberStyleId.AllowThousands)) {
|
|
751
|
+
unstyled = unstyled.split(this.localeSettings.thousandSeparator).join("");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (unstyled.length === 0) {
|
|
755
|
+
return new Err("No digit character");
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (/^\s/.test(unstyled)) {
|
|
759
|
+
return new Err("Unallowed leading whitespace characters");
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (/\s$/.test(unstyled)) {
|
|
763
|
+
return new Err("Unallowed trailing whitespace characters");
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (
|
|
767
|
+
this.styles.has(DotNetNumberStyleId.AllowCurrencySymbol) &&
|
|
768
|
+
unstyled.startsWith(this.localeSettings.currencyString)
|
|
769
|
+
) {
|
|
770
|
+
unstyled = unstyled.slice(this.localeSettings.currencyString.length);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (unstyled.length === 0) {
|
|
774
|
+
return new Err("No digit character");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
let negated = false;
|
|
778
|
+
if (
|
|
779
|
+
this.styles.has(DotNetNumberStyleId.AllowParentheses) &&
|
|
780
|
+
unstyled.startsWith("(") &&
|
|
781
|
+
unstyled.endsWith(")")
|
|
782
|
+
) {
|
|
783
|
+
unstyled = unstyled.slice(1, -1);
|
|
784
|
+
negated = true;
|
|
785
|
+
} else if (this.styles.has(DotNetNumberStyleId.AllowTrailingSign)) {
|
|
786
|
+
if (unstyled.endsWith("+")) {
|
|
787
|
+
unstyled = unstyled.slice(0, -1);
|
|
788
|
+
negated = false;
|
|
789
|
+
} else if (unstyled.endsWith("-")) {
|
|
790
|
+
unstyled = unstyled.slice(0, -1);
|
|
791
|
+
negated = true;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (unstyled.length === 0) {
|
|
796
|
+
return new Err("No digit character");
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return new Ok({ unstyled, negated });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Formatter for integer values (bigint) using .NET-compatible format strings.
|
|
805
|
+
*
|
|
806
|
+
* Supports formatting integers with standard format strings like D (decimal), X (hexadecimal),
|
|
807
|
+
* B (binary), and custom format strings.
|
|
808
|
+
*
|
|
809
|
+
* @example
|
|
810
|
+
* ```typescript
|
|
811
|
+
* const formatter = new DotNetIntegerFormatter();
|
|
812
|
+
* formatter.localeSettings = DotNetLocaleSettings.createInvariant();
|
|
813
|
+
*
|
|
814
|
+
* // Decimal with padding
|
|
815
|
+
* formatter.trySetFormat('D8');
|
|
816
|
+
* console.log(formatter.toString(123n)); // "00000123"
|
|
817
|
+
*
|
|
818
|
+
* // Hexadecimal
|
|
819
|
+
* formatter.trySetFormat('X');
|
|
820
|
+
* console.log(formatter.toString(255n)); // "FF"
|
|
821
|
+
*
|
|
822
|
+
* // Parsing
|
|
823
|
+
* formatter.styles = DotNetNumberStyles.integer;
|
|
824
|
+
* const result = formatter.tryFromString('-12345');
|
|
825
|
+
* if (result.isOk()) {
|
|
826
|
+
* console.log(result.value); // -12345n
|
|
827
|
+
* }
|
|
828
|
+
* ```
|
|
829
|
+
*
|
|
830
|
+
* @public
|
|
831
|
+
* @category Numeric Formatting
|
|
832
|
+
*/
|
|
833
|
+
export class DotNetIntegerFormatter extends DotNetNumberFormatter {
|
|
834
|
+
/**
|
|
835
|
+
* Formats a bigint value using the current format string.
|
|
836
|
+
*
|
|
837
|
+
* @param value - The bigint value to format.
|
|
838
|
+
* @returns The formatted number string.
|
|
839
|
+
*/
|
|
840
|
+
toString(value: bigint): string {
|
|
841
|
+
if (this.format === "") {
|
|
842
|
+
return value.toString();
|
|
843
|
+
}
|
|
844
|
+
return this.formatNumber(value, false);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Attempts to parse a bigint value from a string using the current parsing styles.
|
|
849
|
+
*
|
|
850
|
+
* @param strValue - The string to parse.
|
|
851
|
+
* @returns A Result containing the parsed bigint if successful, or an error if parsing fails.
|
|
852
|
+
*
|
|
853
|
+
* @example
|
|
854
|
+
* ```typescript
|
|
855
|
+
* formatter.styles = DotNetNumberStyles.integer;
|
|
856
|
+
* const result = formatter.tryFromString(' -123 ');
|
|
857
|
+
* if (result.isOk()) {
|
|
858
|
+
* console.log(result.value); // -123n
|
|
859
|
+
* }
|
|
860
|
+
* ```
|
|
861
|
+
*/
|
|
862
|
+
tryFromString(strValue: string): Result<bigint> {
|
|
863
|
+
const unstyled = this.unstyleNumberString(strValue);
|
|
864
|
+
if (unstyled.isErr()) {
|
|
865
|
+
return unstyled.createOuter("Invalid number format");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
let { unstyled: text, negated } = unstyled.value;
|
|
869
|
+
|
|
870
|
+
if (this.styles.has(DotNetNumberStyleId.AllowHexSpecifier)) {
|
|
871
|
+
if (this.styles.has(DotNetNumberStyleId.AllowLeadingSign)) {
|
|
872
|
+
if (text.startsWith("+")) {
|
|
873
|
+
text = text.slice(1);
|
|
874
|
+
} else if (text.startsWith("-")) {
|
|
875
|
+
text = text.slice(1);
|
|
876
|
+
negated = true;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const hex = DotNetNumberFormatter.tryHexToInt64(text);
|
|
881
|
+
if (hex.isErr()) {
|
|
882
|
+
return hex.createOuter("Invalid hex format");
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return new Ok(negated ? -hex.value : hex.value);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const hasLeadingSign = text.startsWith("+") || text.startsWith("-");
|
|
889
|
+
if (
|
|
890
|
+
!this.styles.has(DotNetNumberStyleId.AllowLeadingSign) &&
|
|
891
|
+
hasLeadingSign
|
|
892
|
+
) {
|
|
893
|
+
return new Err("Unallowed leading sign character");
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const parseAsFloat =
|
|
897
|
+
(this.styles.has(DotNetNumberStyleId.AllowExponent) &&
|
|
898
|
+
this.hasExponentChar(text)) ||
|
|
899
|
+
(this.styles.has(DotNetNumberStyleId.AllowDecimalPoint) &&
|
|
900
|
+
this.hasDecimalChar(text));
|
|
901
|
+
|
|
902
|
+
if (parseAsFloat) {
|
|
903
|
+
const normalized = text.replace(
|
|
904
|
+
this.localeSettings.decimalSeparator,
|
|
905
|
+
".",
|
|
906
|
+
);
|
|
907
|
+
const floatValue = Number.parseFloat(normalized);
|
|
908
|
+
if (!Number.isFinite(floatValue)) {
|
|
909
|
+
return new Err("Invalid float format (for Integer field)");
|
|
910
|
+
}
|
|
911
|
+
if (!Number.isInteger(floatValue)) {
|
|
912
|
+
return new Err("Value has fractional component");
|
|
913
|
+
}
|
|
914
|
+
return new Ok(BigInt(floatValue));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (!/^[+-]?\d+$/.test(text)) {
|
|
918
|
+
return new Err("Invalid integer format");
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const value = BigInt(text);
|
|
923
|
+
return new Ok(negated ? -value : value);
|
|
924
|
+
} catch {
|
|
925
|
+
return new Err("Invalid integer format");
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Formatter for floating-point numbers using .NET-compatible format strings.
|
|
932
|
+
*
|
|
933
|
+
* Supports all standard numeric format strings (C, D, E, F, G, N, P, R, X, B) and
|
|
934
|
+
* custom format strings with digit placeholders, separators, and sections.
|
|
935
|
+
*
|
|
936
|
+
* @example
|
|
937
|
+
* ```typescript
|
|
938
|
+
* const formatter = new DotNetFloatFormatter();
|
|
939
|
+
* formatter.localeSettings = DotNetLocaleSettings.createInvariant();
|
|
940
|
+
*
|
|
941
|
+
* // Currency format
|
|
942
|
+
* formatter.trySetFormat('C2');
|
|
943
|
+
* console.log(formatter.toString(1234.56)); // "$1,234.56"
|
|
944
|
+
*
|
|
945
|
+
* // Percentage format
|
|
946
|
+
* formatter.trySetFormat('P1');
|
|
947
|
+
* console.log(formatter.toString(0.1234)); // "12.3%"
|
|
948
|
+
*
|
|
949
|
+
* // Custom format with sections
|
|
950
|
+
* formatter.trySetFormat('#,##0.00;(#,##0.00)');
|
|
951
|
+
* console.log(formatter.toString(-1234.56)); // "(1,234.56)"
|
|
952
|
+
*
|
|
953
|
+
* // Parsing
|
|
954
|
+
* formatter.styles = DotNetNumberStyles.number;
|
|
955
|
+
* const result = formatter.tryFromString('1,234.56');
|
|
956
|
+
* if (result.isOk()) {
|
|
957
|
+
* console.log(result.value); // 1234.56
|
|
958
|
+
* }
|
|
959
|
+
* ```
|
|
960
|
+
*
|
|
961
|
+
* @public
|
|
962
|
+
* @category Numeric Formatting
|
|
963
|
+
*/
|
|
964
|
+
export class DotNetFloatFormatter extends DotNetNumberFormatter {
|
|
965
|
+
/**
|
|
966
|
+
* Formats a number using the current format string.
|
|
967
|
+
*
|
|
968
|
+
* @param value - The number to format.
|
|
969
|
+
* @returns The formatted number string.
|
|
970
|
+
*/
|
|
971
|
+
toString(value: number): string {
|
|
972
|
+
if (this.format === "") {
|
|
973
|
+
return this.localeSettings.defaultFloat.format(value);
|
|
974
|
+
}
|
|
975
|
+
return this.formatNumber(value, true);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Attempts to parse a number from a string using the current parsing styles.
|
|
980
|
+
*
|
|
981
|
+
* @param strValue - The string to parse.
|
|
982
|
+
* @returns A Result containing the parsed number if successful, or an error if parsing fails.
|
|
983
|
+
*
|
|
984
|
+
* @example
|
|
985
|
+
* ```typescript
|
|
986
|
+
* formatter.styles = DotNetNumberStyles.number;
|
|
987
|
+
* const result = formatter.tryFromString(' 1,234.56 ');
|
|
988
|
+
* if (result.isOk()) {
|
|
989
|
+
* console.log(result.value); // 1234.56
|
|
990
|
+
* }
|
|
991
|
+
* ```
|
|
992
|
+
*/
|
|
993
|
+
tryFromString(strValue: string): Result<number> {
|
|
994
|
+
const unstyled = this.unstyleNumberString(strValue);
|
|
995
|
+
if (unstyled.isErr()) {
|
|
996
|
+
return unstyled.createOuter("Invalid number format");
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const { unstyled: text, negated } = unstyled.value;
|
|
1000
|
+
const hasLeadingSign = text.startsWith("+") || text.startsWith("-");
|
|
1001
|
+
|
|
1002
|
+
if (
|
|
1003
|
+
!this.styles.has(DotNetNumberStyleId.AllowLeadingSign) &&
|
|
1004
|
+
hasLeadingSign
|
|
1005
|
+
) {
|
|
1006
|
+
return new Err("Unallowed leading sign character");
|
|
1007
|
+
}
|
|
1008
|
+
// Note: We always allow exponent characters during input parsing, regardless of format.
|
|
1009
|
+
// The format string only affects output formatting, not input parsing capabilities.
|
|
1010
|
+
// Scientific notation like "1.23e5" should always be parseable.
|
|
1011
|
+
if (
|
|
1012
|
+
!this.styles.has(DotNetNumberStyleId.AllowDecimalPoint) &&
|
|
1013
|
+
this.hasDecimalChar(text)
|
|
1014
|
+
) {
|
|
1015
|
+
return new Err("Unallowed decimal point character");
|
|
1016
|
+
}
|
|
1017
|
+
if (!this.hasDigitChar(text)) {
|
|
1018
|
+
return new Err("No digit character");
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Handle scientific notation: normalize the mantissa part to respect culture decimal separator
|
|
1022
|
+
// but keep the exponent part as-is (always uses 'e' or 'E' and standard notation)
|
|
1023
|
+
let normalized = text;
|
|
1024
|
+
const expMatch = text.match(/[eE]/);
|
|
1025
|
+
if (expMatch) {
|
|
1026
|
+
// Split at the exponent indicator to separately handle mantissa and exponent
|
|
1027
|
+
const expIndex = text.indexOf(expMatch[0]);
|
|
1028
|
+
const mantissa = text.substring(0, expIndex);
|
|
1029
|
+
const exponent = text.substring(expIndex);
|
|
1030
|
+
|
|
1031
|
+
// Replace culture-specific decimal separator only in the mantissa part
|
|
1032
|
+
const normalizedMantissa = mantissa.replace(
|
|
1033
|
+
this.localeSettings.decimalSeparator,
|
|
1034
|
+
".",
|
|
1035
|
+
);
|
|
1036
|
+
normalized = normalizedMantissa + exponent;
|
|
1037
|
+
} else {
|
|
1038
|
+
// No exponent, just replace decimal separator as before
|
|
1039
|
+
normalized = text.replace(this.localeSettings.decimalSeparator, ".");
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const parsed = Number.parseFloat(normalized);
|
|
1043
|
+
if (!Number.isFinite(parsed)) {
|
|
1044
|
+
return new Err("Invalid float format");
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
return new Ok(negated ? -parsed : parsed);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Formatter for decimal numbers with high precision using .NET-compatible format strings.
|
|
1053
|
+
*
|
|
1054
|
+
* Similar to {@link DotNetFloatFormatter} but with different default precision behavior.
|
|
1055
|
+
* When no format is specified, trailing zeros after the decimal point are automatically trimmed.
|
|
1056
|
+
*
|
|
1057
|
+
* @example
|
|
1058
|
+
* ```typescript
|
|
1059
|
+
* const formatter = new DotNetDecimalFormatter();
|
|
1060
|
+
* formatter.localeSettings = DotNetLocaleSettings.createInvariant();
|
|
1061
|
+
*
|
|
1062
|
+
* // High precision formatting
|
|
1063
|
+
* formatter.trySetFormat('F6');
|
|
1064
|
+
* console.log(formatter.toString(123.456)); // "123.456000"
|
|
1065
|
+
*
|
|
1066
|
+
* // Default behavior (trims trailing zeros)
|
|
1067
|
+
* console.log(formatter.toString(123.400)); // "123.4"
|
|
1068
|
+
* ```
|
|
1069
|
+
*
|
|
1070
|
+
* @public
|
|
1071
|
+
* @category Numeric Formatting
|
|
1072
|
+
*/
|
|
1073
|
+
export class DotNetDecimalFormatter extends DotNetNumberFormatter {
|
|
1074
|
+
/**
|
|
1075
|
+
* Formats a number using the current format string.
|
|
1076
|
+
* When no format is specified, trailing zeros are trimmed.
|
|
1077
|
+
*
|
|
1078
|
+
* @param value - The number to format.
|
|
1079
|
+
* @returns The formatted number string.
|
|
1080
|
+
*/
|
|
1081
|
+
toString(value: number): string {
|
|
1082
|
+
if (this.format === "") {
|
|
1083
|
+
return this.trimTrailingPadZeros(
|
|
1084
|
+
this.localeSettings.defaultDecimal.format(value),
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
return this.formatNumber(value, true);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Attempts to parse a number from a string using the current parsing styles.
|
|
1092
|
+
*
|
|
1093
|
+
* @param strValue - The string to parse.
|
|
1094
|
+
* @returns A Result containing the parsed number if successful, or an error if parsing fails.
|
|
1095
|
+
*/
|
|
1096
|
+
tryFromString(strValue: string): Result<number> {
|
|
1097
|
+
const doubleFormatter = new DotNetFloatFormatter();
|
|
1098
|
+
doubleFormatter.styles = this.styles;
|
|
1099
|
+
doubleFormatter.localeSettings = this.localeSettings;
|
|
1100
|
+
|
|
1101
|
+
const parsed = doubleFormatter.tryFromString(strValue);
|
|
1102
|
+
if (parsed.isErr()) {
|
|
1103
|
+
return parsed.createOuter(parsed.error.replace("float", "decimal"));
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
return parsed;
|
|
1107
|
+
}
|
|
1108
|
+
}
|