Files
Aislo/A00_Common/spreadsheet/spreadsheet_numfmt.ts
eomsangdonandClaude Opus 5.5 7389335ca9 fix(spreadsheet): 16장 도구 막대 · 메뉴 · 대화 창 전수 점검 — 흠 스무 곳 고침
M02 면적산출 SA01010100 에서 도구 막대 · 데이터 메뉴 · 우클릭 · 셀 서식 여섯 탭 · 찾기 · 메모 ·
이름 관리 · 조건부 서식 · 유효성 · 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽 ·
수식 분석 · 되돌리기/다시 · 저장 안 하고 떠나기 · 다시 열기를 빈 값 · 같은 이름 · 긴 이름 ·
취소 · Esc · Enter · 밝은/어두운 테마 예외까지 돌려 찾은 흠.
- 격자(C): 틀고정이 있으면 본문 판이 그릴 행열을 틀고정 폭만큼 덜 셈 — 아래 · 오른쪽 끝이
  빈 칸으로 남던 것(29 행 고정 때 1 행만 그림) · visibleRange 도 같음 · reveal 은 바로 그림
- 도구 막대: 세로 정렬 기본 표시 「위」 → 「아래」(칸 실제와 맞춤) · 크기 409 넘으면 409 ·
  테두리 · 단위 판이 단추 위로 떠 머리까지 덮던 것 → 단추 아래 · ✕ · 수식 안내 · 확대 글
  대비(흐림 투명도 없앰 · --color-text-secondary · 밝은 5.88 · 어두운 7.91)
- 병합(도구 막대 · 우클릭): 왼위 밖 값이 말없이 지워지던 것 → 엑셀처럼 먼저 물음(confirmMerge)
- 셀 서식 · 이동 · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 메모 창: 열 때
  초점이 창 밖(BODY)이라 Esc · Enter 가 안 먹던 것 → dialogKeys(창 안 초점 · Esc 닫기 ·
  Enter 실행) · 셀 서식 창 탭마다 높이가 널뛰던 것 → 본문 최소 높이
- 우클릭 메뉴(ui_template 공용): Esc 로 닫힘 · 화면 밖이면 안쪽으로 당김 · 데이터 ▾ 메뉴와 알약
  우클릭 메뉴가 화면 좌표를 부품 기준 자리로 써 오른쪽 아래로 떨어지던 것 고침 · 행 · 열 머리
  메뉴에 잘라내기 · 붙여넣기(안내) 더함
- 자동 필터 ▼ 가 칸 밑에 깔려 눌리지 않던 것(z-index) · 값 목록이 격자 아래 끝에서 잘리면
  위로 뒤집음
- Σ: 여러 칸을 고르면 첫 칸에 =SUM() 편집을 열어 Enter 로 값을 덮던 것 → 엑셀처럼 열마다
  바로 아래(또는 고름 끝 빈 칸)에 합계를 바로 넣음(autoSumCells)
- 조건부 서식: 빈 값이 「보다큼 0」 규칙이 되던 것 · #RRGGBB 아닌 색 · 색 없는 규칙 → 안내 후 막음
- 이름 관리: 255 자 넘는 이름 막음 · 긴 이름에 창이 화면 끝까지 늘던 것 · 시트 id(s1) 대신
  시트 이름 · 「이름 바꾸기」 가 브라우저 prompt(자동 취소됨)라 안 되던 것 → 줄 안에서 고침
- 표시 형식 「@」 를 수에 걸면 빈 칸이 되던 것 → 엑셀처럼 일반으로 보임
- 연속 데이터 채우기 · 텍스트 나누기가 칸 서식을 지우던 것 → 서식 유지
- 틀 고정 「지금 칸 기준」 을 먼 칸(Z1000)에서 하면 고정 판이 격자를 다 덮어 못 움직이던 것 →
  화면 안쪽 칸에서만(안내)

ORCA(IP http://100.72.32.27:5181 · 메인 폴더 탭 · 판정은 수치): 목록 80 항목 밝은 80/80 ·
어두운 80/80 · 사각형 겹침 · 잘림 · 화면 밖 · 대비 3 미만 0 건. 시험 — test_stage3_autosum
(여러 칸 넷 더함) · test_stage2_data_tools · test_stage2_format_code · spreadsheet_b_test_entry
197/197(@ 하나 더함) · test_spreadsheet_size(css gzip 3121 · 합 67403) 통과. 시험으로 바꾼 칸은
저장 안 하고 「다시 열기」 로 버림.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
2026-09-28 22:57:48 +09:00

635 lines
23 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 === "\\") {
// 형식 끝에 홀로 남은 역슬래시 · 구역 나눔(`;`) 바로 앞 역슬래시는 깨진 자투리로 보고 버림
// (엑셀 붙여넣기가 잘려 남긴 것 — 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<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;
// 정수부가 온통 `?`(회계 형식 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<string, string> = {
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<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 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")) {
// 글 자리(@)뿐인 형식을 수에 걸면 엑셀처럼 일반으로 보임(빈 칸이 되지 않게)
if (toks.some((t) => t.k === "text"))
return { 글: generalFormat(value), 정렬: "right", ...extra };
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<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);
}