jtc-utils
Version:
Utilities for Japanese Traditional Companies
210 lines (185 loc) • 6.17 kB
text/typescript
import { toHalfwidthAscii } from "../toHalfwidthAscii.cjs";
const ReNumberFormat =
/^((?:"[^"]*"|'[^']*'|[^"'#0,.])*)([#,]*[0,]*)([.]0*#*)?((?:"[^"]*"|'[^']*'|[^"'#0])*)(?:;((?:"[^"]*"|'[^']*'|[^"'#0,.])*)([#,]*[0,]*)([.]0*#*)?((?:"[^"]*"|'[^']*'|[^"'#0])*))?(?:;((?:"[^"]*"|'[^']*'|[^"'#0,.])*)([#,]*[0,]*)([.]0*#*)?((?:"[^"]*"|'[^']*'|[^"'#0])*))?$/;
const ReDecimalText = /("(""|[^"])*"|'(''|[^'])*')/g;
declare type NumberFormatPattern = {
prefix: string;
suffix: string;
groupingDigits: number;
formatter: Intl.NumberFormat;
};
const NumberFormatCache = new Map<string, NumberFormat>();
export class NumberFormat {
static get(format: string, locale = "en-US") {
const cached = NumberFormatCache.get(`${locale} ${format}`);
if (cached) {
return cached;
}
const m = ReNumberFormat.exec(format);
if (!m || !m[2]) {
throw new TypeError(`Invalid format: ${format}`);
}
const patterns = new Array<NumberFormatPattern>();
for (let i = 0; i < 3; i++) {
let iFormat = m[i * 4 + 2] || "";
const fFormat = m[i * 4 + 3] || "";
if (!iFormat) {
break;
}
const prefix = (m[i * 4 + 1] || "").replace(ReDecimalText, (m) => {
const sep = m.charAt(0);
return m.substring(1, m.length - 1).replaceAll(sep + sep, sep);
});
let suffix = (m[i * 4 + 4] || "").replace(ReDecimalText, (m) => {
const sep = m.charAt(0);
return m.substring(1, m.length - 1).replaceAll(sep + sep, sep);
});
let minimumIntegerDigits = 1;
let minimumFractionDigits = 0;
let maximumFractionDigits = 0;
let groupingDigits = 0;
if (fFormat) {
if (fFormat.length > 1) {
maximumFractionDigits = fFormat.length - 1;
const zeroPos = fFormat.lastIndexOf("0");
minimumFractionDigits = zeroPos !== -1 ? zeroPos : 0;
} else {
suffix = fFormat + suffix;
}
}
const groupingPos = iFormat.lastIndexOf(",");
if (groupingPos !== -1) {
groupingDigits = iFormat.length - groupingPos - 1;
iFormat = iFormat.replaceAll(",", "");
}
const zeroPos = iFormat.indexOf("0");
if (zeroPos !== -1) {
minimumIntegerDigits = Math.max(iFormat.length - zeroPos, 1);
}
patterns.push({
prefix,
suffix,
groupingDigits: groupingDigits !== 3 ? groupingDigits : 0,
formatter: new Intl.NumberFormat(locale, {
minimumIntegerDigits,
minimumFractionDigits,
maximumFractionDigits,
useGrouping: groupingDigits === 3,
}),
});
}
const positive = patterns[0];
const negative = patterns[1] || {
...patterns[0],
prefix: `${patterns[0].prefix}-`,
};
const zero = patterns[2] || patterns[0];
// https://observablehq.com/@mbostock/localized-number-parsing
const map = new Map<string, string>();
let n = 9;
for (const sc of new Intl.NumberFormat(locale, {
useGrouping: false,
}).format(9876543210)) {
const dc = n.toString();
if (sc !== dc) {
map.set(sc, dc);
}
n--;
}
const parts = new Intl.NumberFormat(locale, {
useGrouping: true,
}).formatToParts(12345.6);
const decimalSeparator =
parts.find((d) => d.type === "decimal")?.value ?? ".";
if (decimalSeparator && decimalSeparator !== ".") {
map.set(decimalSeparator, ".");
}
const groupingSeparator =
parts.find((d) => d.type === "group")?.value ?? "";
if (groupingSeparator) {
map.set(groupingSeparator, "");
}
if (NumberFormatCache.size >= 32) {
for (const entry of NumberFormatCache) {
NumberFormatCache.delete(entry[0]);
break;
}
}
const dformat = new NumberFormat(
positive,
negative,
zero,
decimalSeparator,
groupingSeparator,
map,
);
NumberFormatCache.set(`${locale} ${format}`, dformat);
return dformat;
}
private constructor(
public positive: NumberFormatPattern,
public negative: NumberFormatPattern,
public zero: NumberFormatPattern,
public decimalSeparator: string,
public groupingSeparator: string,
public map: Map<string, string>,
) {}
format(value: number) {
if (Number.isNaN(value)) {
return "";
}
const format =
value === 0 ? this.zero : value < 0 ? this.negative : this.positive;
let formatted = Number.isFinite(value)
? format.formatter.format(Math.abs(value))
: Math.abs(value).toString();
if (format.groupingDigits > 0 && this.groupingSeparator) {
const re = new RegExp(
`\\B(?=(\\d{${format.groupingDigits}})+(?!\\d))`,
"g",
);
const sep = formatted.indexOf(this.decimalSeparator);
if (sep !== -1) {
formatted =
formatted.substring(0, sep).replace(re, this.groupingSeparator) +
(sep !== -1 ? formatted.substring(sep) : "");
} else {
formatted = formatted.replace(re, this.groupingSeparator);
}
}
return format.prefix + formatted + format.suffix;
}
parse(value: string) {
if (!value) {
return Number.NaN;
}
if (
(this.positive.prefix.length > 0 || this.positive.suffix.length > 0) &&
this.positive.prefix.length + this.positive.suffix.length <
value.length &&
value.startsWith(this.positive.prefix) &&
value.endsWith(this.positive.suffix)
) {
// skip
} else if (
(this.negative.prefix.length > 0 || this.negative.suffix.length > 0) &&
this.negative.prefix.length + this.negative.suffix.length <
value.length &&
value.startsWith(this.negative.prefix) &&
value.endsWith(this.negative.suffix)
) {
value = `-${value.substring(
this.negative.prefix.length,
value.length - this.negative.suffix.length,
)}`;
}
if (this.map.size > 0) {
let value2 = "";
for (const c of value) {
value2 += this.map.get(c) ?? c;
}
value = value2;
}
return Number.parseFloat(toHalfwidthAscii(value));
}
}