summarum/src/engine/formatter.ts
Андрей Пименов 509926bcef fix: address adversarial review findings
- Fix race condition in write_atomic_bytes by using unique temp file names
- Fix logic flaw in DataDirGate authorization on migration failures
- Reject invalid padding in base64_decode
- Set size limits on json fetches for rates
- Fix Date math around DST boundaries for tomorrow/yesterday
- Handle division by zero exceptions gracefully in goal seek
- Fix OOM vector for huge hexadecimal number rendering
2026-08-19 04:11:27 +03:00

155 lines
5.8 KiB
TypeScript

// Turns values into display strings: unit/currency formats plus
// precision and grouping settings.
import { Decimal, DateVal, EngineSettings, Value } from "./types";
// Intl.DateTimeFormat construction is relatively expensive and every line with
// a date rebuilds one on each render — cache by locale+options instead.
const dtfCache = new Map<string, Intl.DateTimeFormat>();
function dtf(locale: string | undefined, opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = (locale ?? "") + JSON.stringify(opts);
let f = dtfCache.get(key);
if (!f) {
f = new Intl.DateTimeFormat(locale, opts);
dtfCache.set(key, f);
}
return f;
}
/** Fixed-layout date display (iso/dmy/mdy), independent of OS locale. */
function formatDateFixed(v: DateVal, fmt: "iso" | "dmy" | "mdy"): string {
const parts = dtf("en-GB", {
timeZone: v.timeZone, year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", hourCycle: "h23",
}).formatToParts(v.ms);
const get = (t: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === t)?.value ?? "";
const [y, mo, d] = [get("year"), get("month"), get("day")];
const date = fmt === "iso" ? `${y}-${mo}-${d}` : fmt === "dmy" ? `${d}.${mo}.${y}` : `${mo}/${d}/${y}`;
return v.hasTime ? `${date} ${get("hour")}:${get("minute")}` : date;
}
function group(intPart: string, sep: string): string {
if (!sep) return intPart;
const neg = intPart.startsWith("-");
const digits = neg ? intPart.slice(1) : intPart;
if (digits.length < 4) return intPart;
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
return (neg ? "-" : "") + grouped;
}
export function formatNumber(v: Decimal, s: EngineSettings): string {
let rounded = v.toDecimalPlaces(s.precision);
if (rounded.isZero() && !v.isZero() && v.abs().gte("1e-12")) {
// 1 mm in km must not show "0 km" — fall back to significant digits
// (anything below 1e-12 is conversion noise, e.g. 32 F in C)
rounded = v.toSignificantDigits(Math.max(s.precision, 1));
}
const str = rounded.toString();
if (str.includes("e") || str.includes("E")) return str;
const [int, frac] = str.split(".");
const intG = group(int, s.groupSeparator);
return frac !== undefined ? intG + s.decimalSeparator + frac : intG;
}
export function formatValue(v: Value, s: EngineSettings): string {
if (v.kind === "chart") return "";
if (v.kind === "percent") {
return formatNumber(v.value, s) + "%";
}
if (v.kind === "date") {
const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone };
if (v.timeOnly) {
return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
}
if (s.dateFormat !== "system") return formatDateFixed(v, s.dateFormat);
if (v.hasTime) {
return dtf(undefined, {
...opts, hour: "2-digit", minute: "2-digit", hour12: false,
day: "numeric", month: "short", year: "numeric",
}).format(v.ms);
}
return dtf(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms);
}
// quantity
if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") {
if (v.value.abs().gte(new Decimal("1e10000"))) {
return formatNumber(v.value, s);
}
const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
const neg = int.isNegative();
const abs = int.abs();
const prefix = v.repr === "hex" ? "0x" : v.repr === "binary" ? "0b" : "0o";
const radix = v.repr === "hex" ? 16 : v.repr === "binary" ? 2 : 8;
const digits = BigInt(abs.toFixed(0)).toString(radix);
return (neg ? "-" : "") + prefix + (v.repr === "hex" ? digits.toUpperCase() : digits);
}
if (v.repr === "scientific") {
return v.value.toExponential(Math.min(s.precision, 10)).replace(/e\+?/, "e");
}
if (v.repr === "plain") {
return v.value.toString(); // no grouping (unix timestamps)
}
if (v.repr === "fraction") {
return formatFraction(v.value);
}
if (v.repr === "roman") {
return formatRoman(v.value) ?? formatNumber(v.value, s);
}
const num = formatNumber(v.value, s);
if (!v.unit) return num;
return withUnit(num, v.unit.format);
}
function withUnit(num: string, f: string): string {
if (f.includes("{}")) {
return f.replace("{}", num); // currency template
}
// suffix units: letters get a space ("12.7 cm"), symbols attach ("5″", "25°C")
return /^[\p{L}]/u.test(f) && !f.startsWith("°") ? `${num} ${f}` : `${num}${f}`;
}
/** Best rational approximation via continued fractions: 0.75 -> "3/4". */
// Native float, not Decimal: the continued-fraction algorithm is tuned to
// float epsilon by design, and this is display formatting, not arithmetic
// carried forward into the result.
function formatFraction(v: Decimal): string {
const neg = v.isNegative();
const x = v.abs().toNumber();
if (!Number.isFinite(x)) return v.toString();
let h1 = 1, h0 = 0, k1 = 0, k0 = 1, b = x;
for (let i = 0; i < 40; i++) {
const a = Math.floor(b);
[h1, h0] = [a * h1 + h0, h1];
[k1, k0] = [a * k1 + k0, k1];
if (k1 > 10000) break;
if (Math.abs(x - h1 / k1) < 1e-12) break;
const frac = b - a;
if (frac < 1e-12) break;
b = 1 / frac;
}
const sign = neg ? "-" : "";
if (k1 === 1) return sign + String(h1);
const whole = Math.floor(h1 / k1);
const rem = h1 - whole * k1;
return whole > 0 ? `${sign}${whole} ${rem}/${k1}` : `${sign}${rem}/${k1}`;
}
function formatRoman(v: Decimal): string | null {
if (!v.isInteger() || v.lt(1) || v.gt(3999)) return null;
let n = v.toNumber();
const table: Array<[number, string]> = [
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
[50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
];
let out = "";
for (const [val, sym] of table) {
while (n >= val) {
out += sym;
n -= val;
}
}
return out;
}