/* ============================================================================= * 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) ); } 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 => t.k === "digit"); const fracDigitToks = fracRun.filter((t): t is Extract => 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 = {}; if (color) extra.색 = color; return { 글: renderDateSection(datePlan, value), 정렬: "right", ...extra }; } const { toks, fillChar } = tokenize(rest); const extra: Partial = {}; 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 = {}; 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); }