Files
Aislo/A00_Common/spreadsheet/spreadsheet_numfmt.ts
T
eomsangdonandClaude Sonnet 5 7725b39ecb feat(spreadsheet): 스프레드시트 B(함수 · 숫자 형식 · 채우기) 1단계 구현
수학 26 · 논리/글/찾기/세기 22 함수 · 엑셀 숫자 형식 코드 해석기 · 채우기 핸들(복사 · 등차 연속 · 글 끝번호 · 식 상대이동) 구현.
자기 시험 69건(resources/tester/test_spreadsheet_b.py, vite ssr 번들 → node) 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 20:23:45 +09:00

292 lines
10 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\)` …)은 모두 맞아야 함.
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 · 시간 형식은 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<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 ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시
});
return { rest, color };
}
// ── 낱말 나누기 ──────────────────────────────────────────────────────────
type Tok =
| { k: "digit"; ch: "0" | "#" | "?" }
| { k: "comma" }
| { k: "point" }
| { k: "percent" }
| { k: "text" }
| { 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];
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; // 수 구역엔 의미 없음(방어)
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 {
const trimmed = code?.trim();
if (!trimmed || trimmed.toLowerCase() === "general") {
return { 글: generalFormat(value), 정렬: "right" };
}
const sections = splitSections(trimmed);
const { code: chosen, forceMinus } = chooseSection(sections, value);
const { rest, color } = extractColor(chosen);
const { toks, fillChar } = tokenize(rest);
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
if (fillChar) extra.채움글 = fillChar;
if (!toks.some((t) => t.k === "digit")) {
let out = "";
for (const t of toks) if (t.k === "lit") out += t.ch;
return { 글: out, 정렬: "right", ...extra };
}
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
const body = renderNumericSection(toks, magnitude);
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
}
function formatText(value: string, code: string | undefined): FormattedText {
const trimmed = code?.trim();
if (!trimmed) return { 글: value, 정렬: "left" };
const sections = splitSections(trimmed);
// 구역 하나뿐(`;` 없음) = 그 구역이 수 · 글 모두에 적용(`General` 과 같은 이치) · 2~3 구역엔 글 자리가 없어 그대로.
const textCode =
sections.length >= 4 ? sections[3] : sections.length === 1 ? sections[0] : undefined;
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);
}