/* ============================================================================= * 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 === "\\") { // 형식 끝에 홀로 남은 역슬래시 · 구역 나눔(`;`) 바로 앞 역슬래시는 깨진 자투리로 보고 버림 // (엑셀 붙여넣기가 잘려 남긴 것 — 2026-09-28 실측 rv.json). 그 밖엔 원래대로 다음 글자를 이스케이프. if (i + 1 >= code.length || code[i + 1] === ";") continue; 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 === "\\") { // 홀로 남은 역슬래시(뒤에 이스케이프할 글자가 없음)는 굴림 등 글꼴에서 ₩ 모양으로 보여 버림. if (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; // 정수부가 온통 `?`(회계 형식 0 구역의 `"-"??` 자리맞춤 버릇)뿐이고 값이 정말 0 이면 // 자리마다 빈칸(엑셀은 `?` 를 「있으면 숫자 · 없으면 빈칸」으로 봄 — `0` 처럼 0 을 안 채움). if (intDigitToks.length > 0 && rawIntStr === "0" && intDigitToks.every((t) => t.ch === "?")) { intStr = " ".repeat(intDigitToks.length); } else 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); } // ── 지수(과학) 형식 `0.00E+00` ─────────────────────────────────────────────── interface SciPlan { mantissaToks: Tok[]; // `E` 앞 가수 자리(자릿수·소수점은 수 구역과 같은 규칙) expDigits: number; // `E+`·`E-` 뒤 자리표시 개수(모자라면 0 으로 채움) expSign: "+" | "-"; // `E-00` 은 양의 지수에서 부호를 안 보임(엑셀과 같음) } /** `E`(부호 있음) 뒤 숫자 자리표시가 끝까지 이어질 때만 지수 형식 — 아니면 null. */ function detectScientific(toks: Tok[]): SciPlan | null { for (let i = 0; i < toks.length; i++) { const t = toks[i]; if (t.k !== "lit" || (t.ch !== "E" && t.ch !== "e")) continue; const signTok = toks[i + 1]; if (!signTok || signTok.k !== "lit" || (signTok.ch !== "+" && signTok.ch !== "-")) continue; let j = i + 2; let digits = 0; while (j < toks.length && toks[j].k === "digit") { digits++; j++; } if (digits === 0 || j !== toks.length) continue; return { mantissaToks: toks.slice(0, i), expDigits: digits, expSign: signTok.ch }; } return null; } function renderScientificSection(plan: SciPlan, magnitude: Frac): string { const value = toDouble(magnitude); const pointIdx = plan.mantissaToks.findIndex((t) => t.k === "point"); const fracDigits = plan.mantissaToks .slice(pointIdx >= 0 ? pointIdx + 1 : plan.mantissaToks.length) .filter((t) => t.k === "digit").length; let exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value))); let mantissaText = (value / Math.pow(10, exp)).toFixed(fracDigits); if (Math.abs(Number(mantissaText)) >= 10) { exp += 1; mantissaText = (value / Math.pow(10, exp)).toFixed(fracDigits); } const expSign = exp < 0 ? "-" : plan.expSign === "+" ? "+" : ""; const expText = String(Math.abs(exp)).padStart(plan.expDigits, "0"); return `${mantissaText}E${expSign}${expText}`; } // ── 날짜 형식(`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 === "\\") { if (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; // 앞뒤가 영문 알파벳이면 딴 낱말 속에 섞인 우연한 글자(`Fixed` 의 d · `Currency` 의 y 등) — // 자리표시로 안 봄(2026-09-28 — 그 우연 때문에 이름 형식이 날짜로 깨지던 것 고침). const before = i > 0 ? s[i - 1] : ""; const after = j < s.length ? s[j] : ""; const boundaryOk = !/[A-Za-z]/.test(before) && !/[A-Za-z]/.test(after); if (boundaryOk && lower === "a" && (width === 3 || width === 4)) { parts.push({ k: "a", width }); hasDateToken = true; } else if ( boundaryOk && 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 }; } // ── 엑셀 「칸 형식 이름」(코드 대신) — `mso-number-format:Fixed` 처럼 클립보드가 이름을 넘김 ───── // 엑셀 붙여넣기 · 일부 자리에서 형식 코드 대신 이름(Fixed·Standard·Currency·Percent·General 등)이 // 옴 — 그대로 코드로 읽으면 `y·m·d` 낱자가 날짜 자리표시로 잘못 잡혀 글자가 깨짐(2026-09-28 실측: // `Fixed`→`Fixe11`). 우리 코드로 바꿔치기 · 모르는 이름은 일반(General)으로. const EXCEL_NAMED_FORMATS: Record = { general: "General", "general number": "General", fixed: "0.00", standard: "#,##0.00", currency: "#,##0.00", // 이 문서 실측 — 통화 기호가 안 보임(사용자 확인) percent: "0.00%", scientific: "0.00E+00", "short date": "yyyy-mm-dd", "long date": "yyyy-mm-dd aaaa", }; const BARE_WORD_RE = /^[A-Za-z][A-Za-z ]*$/; /** 따옴표 한 겹 · 흔한 `\x` 이스케이프까지 방어적으로 풀어(대조용) 이름표와 맞대 봄 — * 맞으면 우리 코드로 · 알파벳 낱말뿐인 모르는 이름이면 일반으로 · 아니면 원래 코드 그대로. */ function resolveExcelStyleName(code: string): string { let key = code.trim(); if (key.length >= 2 && key.startsWith('"') && key.endsWith('"')) key = key.slice(1, -1); key = key.replace(/\\(.)/g, "$1").trim().toLowerCase().replace(/\s+/g, " "); const mapped = EXCEL_NAMED_FORMATS[key]; if (mapped !== undefined) return mapped; // 알파벳 낱말뿐이면서 진짜 날짜 자리표시(`aaa` · `yyyy` 등)도 아니면 모르는 이름 — 일반으로. if (BARE_WORD_RE.test(key) && detectDateFormat(code) === null) return "General"; return code; } function formatNumber(value: Frac, code: string | undefined): FormattedText { const resolved = code === undefined ? undefined : resolveExcelStyleName(code); if (resolved === undefined || resolved === "" || resolved.trim().toLowerCase() === "general") { return { 글: generalFormat(value), 정렬: "right" }; } const sections = splitSections(resolved); 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 sci = detectScientific(toks); if (sci) { const body = renderScientificSection(sci, magnitude); return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra }; } 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 resolved = resolveExcelStyleName(code); const sections = splitSections(resolved); // 구역 하나뿐(`;` 없음)에 `@` 가 있을 때만 그 구역을 씀(순수 수 형식 하나뿐이면 글엔 안 씀) · // 넷째 구역은 명시적 글 자리라 `@` 없어도 그대로(고정 라벨 버릇) · 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); }