MEDIAN·MODE·STDEV(.S/.P)·VAR·RANK(.EQ)·LARGE·SMALL·PERCENTILE·QUARTILE· COUNTBLANK을 새 spreadsheet_functions_stat.ts에 둠. NOW·WEEKDAY·EDATE· EOMONTH·DATEDIF·NETWORKDAYS·HOUR·MINUTE·TIME을 misc.ts에 더함(EDATE는 월말 넘침을 다음 달로 안 넘기고 그 달 말일로 눌러 담음 — 엑셀과 같게). numfmt에 날짜 형식(yyyy-mm-dd·m/d·요일 aaa/aaaa)을 더함. sub1 func_help용 이름 목록은 functions.ts의 FUNC_HELP_BATCH3에 둠(그 파일은 안 고침). 경계값 시험 180건 전부 통과. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_numfmt.ts (주인 B)
|
|
* 숫자 형식 코드 → 보이는 글 — 엑셀 코드 부분 집합: 일반 · `0` `#` `?` `.` `,`(천 단위 · 끝 쉼표 = 천으로 나눔) ·
|
|
* 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`.
|
|
* 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함.
|
|
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`)도 봄
|
|
* (3차 · 2026-09-27) — 시간(`h:mm:ss`)은 아직 밖(글 그대로).
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
ZERO,
|
|
cmp,
|
|
div,
|
|
frac,
|
|
fracToString,
|
|
mul,
|
|
roundAt,
|
|
sub,
|
|
toInteger,
|
|
type Frac,
|
|
} from "@ui/sheet/ui_template_sheet_frac";
|
|
import type { ErrorValue, FormattedText, Scalar } from "./spreadsheet_types";
|
|
|
|
function isErrorScalar(v: Scalar): v is ErrorValue {
|
|
return (
|
|
typeof v === "object" && v !== null && "error" in (v as unknown as Record<string, unknown>)
|
|
);
|
|
}
|
|
|
|
function groupThousands(digits: string): string {
|
|
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
}
|
|
|
|
// ── 구역 나누기(`;` · 따옴표 · `\` 뒤는 안 나눔) ─────────────────────────────
|
|
|
|
function splitSections(code: string): string[] {
|
|
const sections: string[] = [];
|
|
let cur = "";
|
|
let inQuote = false;
|
|
for (let i = 0; i < code.length; i++) {
|
|
const ch = code[i];
|
|
if (ch === '"') {
|
|
inQuote = !inQuote;
|
|
cur += ch;
|
|
continue;
|
|
}
|
|
if (ch === "\\" && i + 1 < code.length) {
|
|
cur += ch + code[i + 1];
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ch === ";" && !inQuote) {
|
|
sections.push(cur);
|
|
cur = "";
|
|
continue;
|
|
}
|
|
cur += ch;
|
|
}
|
|
sections.push(cur);
|
|
return sections;
|
|
}
|
|
|
|
const COLOR_NAMES = ["black", "white", "red", "green", "blue", "yellow", "magenta", "cyan"];
|
|
|
|
function extractColor(section: string): { rest: string; color?: string } {
|
|
let color: string | undefined;
|
|
const rest = section.replace(/\[([^\]]+)\]/g, (_m, inner: string) => {
|
|
const lower = inner.toLowerCase();
|
|
if (COLOR_NAMES.includes(lower)) {
|
|
color = lower;
|
|
return "";
|
|
}
|
|
const currency = /^\$([^-\]]*)(-[0-9A-Fa-f]+)?$/.exec(inner);
|
|
if (currency) return currency[1]; // `[$₩-412]` 등 통화 태그 → 글자만 남김
|
|
return ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시
|
|
});
|
|
return { rest, color };
|
|
}
|
|
|
|
// ── 낱말 나누기 ──────────────────────────────────────────────────────────
|
|
|
|
type Tok =
|
|
| { k: "digit"; ch: "0" | "#" | "?" }
|
|
| { k: "comma" }
|
|
| { k: "point" }
|
|
| { k: "percent" }
|
|
| { k: "text" }
|
|
| { k: "general" }
|
|
| { k: "lit"; ch: string };
|
|
|
|
function tokenize(s: string): { toks: Tok[]; fillChar?: string } {
|
|
const toks: Tok[] = [];
|
|
let fillChar: string | undefined;
|
|
for (let i = 0; i < s.length; i++) {
|
|
const ch = s[i];
|
|
// 코드 속에 섞인 맨 `General`(따옴표 없이) — 실무 흔한 `General"개"` 같은 단위 접미 버릇.
|
|
if (s.slice(i, i + 7).toLowerCase() === "general") {
|
|
toks.push({ k: "general" });
|
|
i += 6;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
let j = i + 1;
|
|
while (j < s.length && s[j] !== '"') {
|
|
toks.push({ k: "lit", ch: s[j] });
|
|
j++;
|
|
}
|
|
i = j;
|
|
continue;
|
|
}
|
|
if (ch === "\\" && i + 1 < s.length) {
|
|
toks.push({ k: "lit", ch: s[i + 1] });
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ch === "_" && i + 1 < s.length) {
|
|
toks.push({ k: "lit", ch: " " });
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ch === "*" && i + 1 < s.length) {
|
|
fillChar = s[i + 1];
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ch === "0" || ch === "#" || ch === "?") {
|
|
toks.push({ k: "digit", ch });
|
|
continue;
|
|
}
|
|
if (ch === ",") {
|
|
toks.push({ k: "comma" });
|
|
continue;
|
|
}
|
|
if (ch === ".") {
|
|
toks.push({ k: "point" });
|
|
continue;
|
|
}
|
|
if (ch === "%") {
|
|
toks.push({ k: "percent" });
|
|
continue;
|
|
}
|
|
if (ch === "@") {
|
|
toks.push({ k: "text" });
|
|
continue;
|
|
}
|
|
toks.push({ k: "lit", ch });
|
|
}
|
|
return { toks, fillChar };
|
|
}
|
|
|
|
// ── 수 구역 그리기 ───────────────────────────────────────────────────────
|
|
|
|
function renderNumericSection(toks: Tok[], magnitude: Frac): string {
|
|
const pointIdx = toks.findIndex((t) => t.k === "point");
|
|
const hasPercent = toks.some((t) => t.k === "percent");
|
|
const scaledMag = hasPercent ? mul(magnitude, frac(100n)) : magnitude;
|
|
|
|
let lastDigit = -1;
|
|
toks.forEach((t, i) => {
|
|
if (t.k === "digit") lastDigit = i;
|
|
});
|
|
|
|
let scale = 0;
|
|
for (let i = lastDigit + 1; i < toks.length; i++) if (toks[i].k === "comma") scale++;
|
|
|
|
let hasGrouping = false;
|
|
const introEnd = pointIdx >= 0 ? pointIdx : toks.length;
|
|
for (let i = 0; i < introEnd && i <= lastDigit; i++)
|
|
if (toks[i].k === "comma") hasGrouping = true;
|
|
|
|
const intRun = pointIdx >= 0 ? toks.slice(0, pointIdx) : toks;
|
|
const fracRun = pointIdx >= 0 ? toks.slice(pointIdx + 1) : [];
|
|
const intDigitToks = intRun.filter((t): t is Extract<Tok, { k: "digit" }> => t.k === "digit");
|
|
const fracDigitToks = fracRun.filter((t): t is Extract<Tok, { k: "digit" }> => t.k === "digit");
|
|
|
|
const scaleDiv = scale > 0 ? 1000n ** BigInt(scale) : 1n;
|
|
const adjusted = scaleDiv === 1n ? scaledMag : div(scaledMag, frac(scaleDiv));
|
|
|
|
const rounded = roundAt(adjusted, fracDigitToks.length, "round");
|
|
const scaleUp = frac(10n ** BigInt(fracDigitToks.length));
|
|
const scaledInt = toInteger(mul(rounded, scaleUp), "round");
|
|
const allDigits = scaledInt.toString().padStart(fracDigitToks.length + 1, "0");
|
|
const rawIntStr =
|
|
fracDigitToks.length > 0 ? allDigits.slice(0, -fracDigitToks.length) : allDigits;
|
|
const rawFracStr = fracDigitToks.length > 0 ? allDigits.slice(-fracDigitToks.length) : "";
|
|
|
|
let intStr = rawIntStr;
|
|
if (intDigitToks.length > intStr.length) {
|
|
const need = intDigitToks.length - intStr.length;
|
|
let pad = "";
|
|
for (let i = 0; i < need; i++) pad += intDigitToks[i].ch === "0" ? "0" : "";
|
|
intStr = pad + intStr;
|
|
}
|
|
if (hasGrouping) intStr = groupThousands(intStr);
|
|
|
|
let keepUntil = fracDigitToks.length;
|
|
for (let i = fracDigitToks.length - 1; i >= 0; i--) {
|
|
if (fracDigitToks[i].ch === "0") break;
|
|
if (rawFracStr[i] !== "0") break;
|
|
keepUntil = i;
|
|
}
|
|
const fracStr = rawFracStr.slice(0, keepUntil);
|
|
|
|
let out = "";
|
|
let intEmitted = false;
|
|
let fracEmitted = false;
|
|
for (let i = 0; i < toks.length; i++) {
|
|
const t = toks[i];
|
|
if (t.k === "digit" || t.k === "comma") {
|
|
const inFrac = pointIdx >= 0 && i > pointIdx;
|
|
if (inFrac) {
|
|
if (!fracEmitted) {
|
|
out += fracStr;
|
|
fracEmitted = true;
|
|
}
|
|
} else if (!intEmitted) {
|
|
out += intStr;
|
|
intEmitted = true;
|
|
}
|
|
continue;
|
|
}
|
|
if (t.k === "point") {
|
|
if (fracStr.length > 0) out += ".";
|
|
continue;
|
|
}
|
|
if (t.k === "percent") {
|
|
out += "%";
|
|
continue;
|
|
}
|
|
if (t.k === "text") continue; // 수 구역엔 의미 없음(방어)
|
|
if (t.k === "general") {
|
|
out += generalFormat(magnitude);
|
|
continue;
|
|
}
|
|
out += t.ch;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ── 분수 형식(`0/0` · `# ?/?` · `# ??/??` · 분모 고정 `# ?/8`) ───────────────
|
|
|
|
interface FracPlan {
|
|
prefix: Tok[];
|
|
wholeDigits: number; // 0 = 정수부 자리 없음(가분수로 통째)
|
|
sepToks: Tok[]; // 정수부 · 분자 사이 리터럴(보통 칸 하나)
|
|
fixedDenom: bigint | null; // null = 분모 자리표시(자동 찾기) · 있으면 그 값 고정
|
|
denomDigits: number; // 자동일 때 자리표시 개수(최대 분모 = 10^자리 - 1)
|
|
suffix: Tok[];
|
|
}
|
|
|
|
/** 슬래시 앞뒤 자리표시(0/#/?)·고정 숫자 자리를 찾음 — 없으면 분수 형식이 아님(null). */
|
|
function detectFraction(toks: Tok[]): FracPlan | null {
|
|
if (toks.some((t) => t.k === "point")) return null;
|
|
const slashIdx = toks.findIndex((t) => t.k === "lit" && t.ch === "/");
|
|
if (slashIdx < 0) return null;
|
|
|
|
let i = slashIdx - 1;
|
|
while (i >= 0 && toks[i].k === "digit") i--;
|
|
const numeratorStart = i + 1;
|
|
if (numeratorStart === slashIdx) return null; // 분자 자리표시 없음
|
|
|
|
let j = slashIdx + 1;
|
|
let denomDigits = 0;
|
|
let fixedDigits = "";
|
|
let mode: "auto" | "fixed" | null = null;
|
|
while (j < toks.length) {
|
|
const t = toks[j];
|
|
if (t.k === "digit") {
|
|
if (mode === "fixed") break;
|
|
mode = "auto";
|
|
denomDigits++;
|
|
j++;
|
|
continue;
|
|
}
|
|
if (t.k === "lit" && /[0-9]/.test(t.ch)) {
|
|
if (mode === "auto") break;
|
|
mode = "fixed";
|
|
fixedDigits += t.ch;
|
|
j++;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
if (mode === null) return null;
|
|
const denomEnd = j;
|
|
|
|
const sepToks: Tok[] = [];
|
|
let k = numeratorStart - 1;
|
|
while (k >= 0 && toks[k].k !== "digit") {
|
|
sepToks.unshift(toks[k]);
|
|
k--;
|
|
}
|
|
let wholeDigits = 0;
|
|
while (k >= 0 && toks[k].k === "digit") {
|
|
wholeDigits++;
|
|
k--;
|
|
}
|
|
const prefix = wholeDigits > 0 ? toks.slice(0, k + 1) : toks.slice(0, numeratorStart);
|
|
if (wholeDigits === 0) sepToks.length = 0;
|
|
|
|
return {
|
|
prefix,
|
|
wholeDigits,
|
|
sepToks,
|
|
fixedDenom: mode === "fixed" ? BigInt(fixedDigits) : null,
|
|
denomDigits,
|
|
suffix: toks.slice(denomEnd),
|
|
};
|
|
}
|
|
|
|
/** 분모가 `maxDenom` 이하인 가장 가까운 분수(0 ≤ x) — 다 훑어봐도 999 번 안쪽(자리표시 최대 3 개). */
|
|
function bestFraction(x: Frac, maxDenom: bigint): { num: bigint; den: bigint } {
|
|
let bestNum = 0n;
|
|
let bestDen = 1n;
|
|
let bestDiff: Frac | null = null;
|
|
for (let den = 1n; den <= maxDenom; den++) {
|
|
const num = toInteger(mul(x, frac(den)), "round");
|
|
const gap = sub(x, frac(num, den));
|
|
const diff = frac(gap.n < 0n ? -gap.n : gap.n, gap.d);
|
|
if (bestDiff === null || cmp(diff, bestDiff) < 0) {
|
|
bestDiff = diff;
|
|
bestNum = num;
|
|
bestDen = den;
|
|
}
|
|
}
|
|
return { num: bestNum, den: bestDen };
|
|
}
|
|
|
|
function renderFractionSection(plan: FracPlan, magnitude: Frac): string {
|
|
let whole = 0n;
|
|
let remainder = magnitude;
|
|
if (plan.wholeDigits > 0) {
|
|
whole = toInteger(magnitude, "trunc");
|
|
remainder = sub(magnitude, frac(whole));
|
|
}
|
|
let num: bigint;
|
|
let den: bigint;
|
|
if (plan.fixedDenom !== null) {
|
|
den = plan.fixedDenom;
|
|
num = toInteger(mul(remainder, frac(den)), "round");
|
|
} else {
|
|
const maxDenom = 10n ** BigInt(plan.denomDigits) - 1n;
|
|
const best = bestFraction(remainder, maxDenom);
|
|
num = best.num;
|
|
den = best.den;
|
|
}
|
|
if (plan.wholeDigits > 0 && den > 0n && num >= den) {
|
|
whole += num / den;
|
|
num %= den;
|
|
}
|
|
const fracText = num !== 0n ? `${num}/${den}` : "";
|
|
|
|
let out = "";
|
|
for (const t of plan.prefix) if (t.k === "lit") out += t.ch;
|
|
if (plan.wholeDigits > 0) {
|
|
if (whole !== 0n || fracText === "") out += whole.toString();
|
|
if (fracText !== "") for (const t of plan.sepToks) if (t.k === "lit") out += t.ch;
|
|
}
|
|
out += fracText;
|
|
for (const t of plan.suffix) if (t.k === "lit") out += t.ch;
|
|
return out;
|
|
}
|
|
|
|
function generalFormat(value: Frac): string {
|
|
return fracToString(value);
|
|
}
|
|
|
|
// ── 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`) ───────────────────────
|
|
// 엑셀 일련번호(1970-01-01 = 25569) → 달력. 1900 윤년 버그는 misc.ts 와 같이 안 다룸.
|
|
|
|
const MS_PER_DAY = 86400000;
|
|
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
|
|
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
|
|
|
|
function toDouble(x: Frac): number {
|
|
return Number(x.n) / Number(x.d);
|
|
}
|
|
|
|
function serialToDateUTC(serial: number): Date {
|
|
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
|
|
}
|
|
|
|
type DatePart =
|
|
{ k: "lit"; text: string } | { k: "y" | "m" | "d"; width: 1 | 2 | 4 } | { k: "a"; width: 3 | 4 };
|
|
|
|
/** `y/m/d/a` 낱말 뜀(자리수가 아는 폭이 아니면 그대로 글로) — 없으면 날짜 형식 아님(null). */
|
|
function detectDateFormat(s: string): DatePart[] | null {
|
|
const parts: DatePart[] = [];
|
|
let hasDateToken = false;
|
|
let i = 0;
|
|
while (i < s.length) {
|
|
const ch = s[i];
|
|
if (ch === '"') {
|
|
let j = i + 1;
|
|
let text = "";
|
|
while (j < s.length && s[j] !== '"') {
|
|
text += s[j];
|
|
j++;
|
|
}
|
|
parts.push({ k: "lit", text });
|
|
i = j + 1;
|
|
continue;
|
|
}
|
|
if (ch === "\\" && i + 1 < s.length) {
|
|
parts.push({ k: "lit", text: s[i + 1] });
|
|
i += 2;
|
|
continue;
|
|
}
|
|
const lower = ch.toLowerCase();
|
|
if (lower === "y" || lower === "m" || lower === "d" || lower === "a") {
|
|
let j = i;
|
|
while (j < s.length && s[j].toLowerCase() === lower) j++;
|
|
const width = j - i;
|
|
if (lower === "a" && (width === 3 || width === 4)) {
|
|
parts.push({ k: "a", width });
|
|
hasDateToken = true;
|
|
} else if (lower !== "a" && (width === 1 || width === 2 || (lower === "y" && width === 4))) {
|
|
parts.push({ k: lower as "y" | "m" | "d", width: width as 1 | 2 | 4 });
|
|
hasDateToken = true;
|
|
} else {
|
|
parts.push({ k: "lit", text: s.slice(i, j) });
|
|
}
|
|
i = j;
|
|
continue;
|
|
}
|
|
parts.push({ k: "lit", text: ch });
|
|
i++;
|
|
}
|
|
return hasDateToken ? parts : null;
|
|
}
|
|
|
|
function renderDatePart(part: DatePart, date: Date): string {
|
|
if (part.k === "lit") return part.text;
|
|
if (part.k === "y") {
|
|
const y = date.getUTCFullYear();
|
|
return part.width === 4 ? String(y) : String(y % 100).padStart(2, "0");
|
|
}
|
|
if (part.k === "m") {
|
|
const m = date.getUTCMonth() + 1;
|
|
return part.width === 1 ? String(m) : String(m).padStart(2, "0");
|
|
}
|
|
if (part.k === "d") {
|
|
const d = date.getUTCDate();
|
|
return part.width === 1 ? String(d) : String(d).padStart(2, "0");
|
|
}
|
|
const wd = WEEKDAY_SHORT[date.getUTCDay()];
|
|
return part.width === 3 ? wd : `${wd}요일`;
|
|
}
|
|
|
|
function renderDateSection(parts: DatePart[], value: Frac): string {
|
|
const date = serialToDateUTC(toDouble(value));
|
|
return parts.map((p) => renderDatePart(p, date)).join("");
|
|
}
|
|
|
|
function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } {
|
|
const isNeg = cmp(value, ZERO) < 0;
|
|
const isZero = cmp(value, ZERO) === 0;
|
|
if (sections.length === 1) return { code: sections[0], forceMinus: isNeg };
|
|
if (sections.length === 2) {
|
|
return isNeg
|
|
? { code: sections[1], forceMinus: false }
|
|
: { code: sections[0], forceMinus: false };
|
|
}
|
|
if (isZero) return { code: sections[2] ?? sections[0], forceMinus: false };
|
|
if (isNeg) return { code: sections[1], forceMinus: false };
|
|
return { code: sections[0], forceMinus: false };
|
|
}
|
|
|
|
function formatNumber(value: Frac, code: string | undefined): FormattedText {
|
|
if (code === undefined || code === "" || code.trim().toLowerCase() === "general") {
|
|
return { 글: generalFormat(value), 정렬: "right" };
|
|
}
|
|
const sections = splitSections(code);
|
|
const { code: chosen, forceMinus } = chooseSection(sections, value);
|
|
const { rest, color } = extractColor(chosen);
|
|
const datePlan = detectDateFormat(rest);
|
|
if (datePlan) {
|
|
const extra: Partial<FormattedText> = {};
|
|
if (color) extra.색 = color;
|
|
return { 글: renderDateSection(datePlan, value), 정렬: "right", ...extra };
|
|
}
|
|
const { toks, fillChar } = tokenize(rest);
|
|
const extra: Partial<FormattedText> = {};
|
|
if (color) extra.색 = color;
|
|
if (fillChar) extra.채움글 = fillChar;
|
|
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
|
|
const fraction = detectFraction(toks);
|
|
if (fraction) {
|
|
const body = renderFractionSection(fraction, magnitude);
|
|
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
|
|
}
|
|
if (!toks.some((t) => t.k === "digit" || t.k === "general")) {
|
|
let out = "";
|
|
for (const t of toks) if (t.k === "lit") out += t.ch;
|
|
return { 글: out, 정렬: "right", ...extra };
|
|
}
|
|
const body = renderNumericSection(toks, magnitude);
|
|
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
|
|
}
|
|
|
|
function formatText(value: string, code: string | undefined): FormattedText {
|
|
if (code === undefined || code === "") return { 글: value, 정렬: "left" };
|
|
const sections = splitSections(code);
|
|
// 구역 하나뿐(`;` 없음)에 `@` 가 있을 때만 그 구역을 씀(순수 수 형식 하나뿐이면 글엔 안 씀) ·
|
|
// 넷째 구역은 명시적 글 자리라 `@` 없어도 그대로(고정 라벨 버릇) · 2~3 구역엔 글 자리가 없어 그대로.
|
|
let textCode: string | undefined;
|
|
if (sections.length >= 4) textCode = sections[3];
|
|
else if (sections.length === 1 && sections[0].includes("@")) textCode = sections[0];
|
|
if (textCode === undefined) return { 글: value, 정렬: "left" };
|
|
const { rest, color } = extractColor(textCode);
|
|
const { toks, fillChar } = tokenize(rest);
|
|
const extra: Partial<FormattedText> = {};
|
|
if (color) extra.색 = color;
|
|
if (fillChar) extra.채움글 = fillChar;
|
|
let out = "";
|
|
for (const t of toks) {
|
|
if (t.k === "text") out += value;
|
|
else if (t.k === "lit") out += t.ch;
|
|
else if (t.k === "percent") out += "%";
|
|
}
|
|
return { 글: out, 정렬: "left", ...extra };
|
|
}
|
|
|
|
/** `code` 없음 = 일반(수는 유효 11 자리 안쪽 · 칸 폭은 격자 몫) · 오류 값은 코드 글 그대로 */
|
|
export function formatValue(value: Scalar, code: string | undefined): FormattedText {
|
|
if (isErrorScalar(value)) return { 글: value.error, 정렬: "center" };
|
|
if (value === null) return { 글: "", 정렬: "right" };
|
|
if (typeof value === "boolean") return { 글: value ? "TRUE" : "FALSE", 정렬: "center" };
|
|
if (typeof value === "string") return formatText(value, code);
|
|
return formatNumber(value, code);
|
|
}
|