/* ============================================================================= * spreadsheet_numfmt.ts (주인 B) * 숫자 형식 코드 → 보이는 글 — 엑셀 코드 부분 집합: 일반 · `0` `#` `?` `.` `,`(천 단위 · 끝 쉼표 = 천으로 나눔) · * 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`. * 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함. * 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 · 시간 형식은 1단계 밖(글 그대로). * ========================================================================== */ import { ZERO, cmp, div, frac, fracToString, mul, roundAt, 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; } function generalFormat(value: Frac): string { return fracToString(value); } 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 { 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; 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); }