fix(spreadsheet): 엑셀 칸 형식 이름(Fixed·Standard·Currency 등) 오독 고침

엑셀이 클립보드로 칸 형식을 코드 대신 이름으로 넘길 때(mso-number-format:Fixed
등) numfmt 가 그 이름을 코드로 잘못 읽어 `d·m·y` 낱자를 날짜 자리표시로 잡아
글자가 깨지던 것(Fixed→Fixe11 등, 2026-09-27 날짜 형식 추가의 부작용)을 고침.
엑셀 이름표(General·General Number·Fixed·Standard·Currency·Percent·
Scientific·Short Date·Long Date)를 실제 형식 코드로 바꿔치기하고, 모르는
이름은 일반으로 떨어뜨리되 aaa·yyyy 같은 진짜 날짜 자리표시는 그대로 살림
(날짜 자리표시 탐지도 앞뒤가 딴 낱말 글자가 아닐 때만 인정하게 다듬음).
지수(과학) 형식(0.00E+00)도 새로 구현. Currency 는 사용자 실제 화면 확인대로
통화 기호 없이 렌더 — ₩ 가 붙는 자리는 이 여섯 이름으로는 재현 안 됨(실제
붙여넣기 원문이 다시 나타나면 추가 확인 필요). 사용자 표 견본(43.91·14,182·
0.48·21.07·1.93·15,166·23.20)으로 시험 191건 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
This commit is contained in:
2026-09-28 09:53:16 +09:00
co-authored by Claude Sonnet 5
parent e4eef2a47b
commit 4bbef4ac54
2 changed files with 148 additions and 5 deletions
+97 -5
View File
@@ -365,6 +365,50 @@ 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 와 같이 안 다룸.
@@ -411,10 +455,19 @@ function detectDateFormat(s: string): DatePart[] | null {
let j = i;
while (j < s.length && s[j].toLowerCase() === lower) j++;
const width = j - i;
if (lower === "a" && (width === 3 || width === 4)) {
// 앞뒤가 영문 알파벳이면 딴 낱말 속에 섞인 우연한 글자(`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 (lower !== "a" && (width === 1 || width === 2 || (lower === "y" && width === 4))) {
} 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 {
@@ -466,11 +519,44 @@ function chooseSection(sections: string[], value: Frac): { code: string; forceMi
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 {
if (code === undefined || code === "" || code.trim().toLowerCase() === "general") {
const resolved = code === undefined ? undefined : resolveExcelStyleName(code);
if (resolved === undefined || resolved === "" || resolved.trim().toLowerCase() === "general") {
return { 글: generalFormat(value), 정렬: "right" };
}
const sections = splitSections(code);
const sections = splitSections(resolved);
const { code: chosen, forceMinus } = chooseSection(sections, value);
const { rest, color } = extractColor(chosen);
const datePlan = detectDateFormat(rest);
@@ -484,6 +570,11 @@ function formatNumber(value: Frac, code: string | undefined): 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);
@@ -500,7 +591,8 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText {
function formatText(value: string, code: string | undefined): FormattedText {
if (code === undefined || code === "") return { 글: value, 정렬: "left" };
const sections = splitSections(code);
const resolved = resolveExcelStyleName(code);
const sections = splitSections(resolved);
// 구역 하나뿐(`;` 없음)에 `@` 가 있을 때만 그 구역을 씀(순수 수 형식 하나뿐이면 글엔 안 씀) ·
// 넷째 구역은 명시적 글 자리라 `@` 없어도 그대로(고정 라벨 버릇) · 2~3 구역엔 글 자리가 없어 그대로.
let textCode: string | undefined;
@@ -485,6 +485,57 @@ ok(
);
ok("참거짓은 형식 무시", fmt(true, "0.00") === "TRUE", fmt(true, "0.00"));
// 엑셀 「칸 형식 이름」(코드 대신 · mso-number-format:Fixed 등) — 2026-09-28 사용자 표 실측 회귀 방지
// (콘크리트 43.91 · 14,182 · 0.48 · 21.07 · 모르터 1.93 · 계 15,166 · 23.20)
ok("이름형식 Fixed", fmt(toFrac("43.91"), "Fixed") === "43.91", fmt(toFrac("43.91"), "Fixed"));
ok(
"이름형식 Fixed(소수 끝)",
fmt(toFrac("1.93"), "Fixed") === "1.93",
fmt(toFrac("1.93"), "Fixed"),
);
ok(
"이름형식 Standard(천 단위)",
fmt(toFrac("14182"), "Standard") === "14,182.00",
fmt(toFrac("14182"), "Standard"),
);
ok(
"이름형식 Standard 계",
fmt(toFrac("15166"), "Standard") === "15,166.00",
fmt(toFrac("15166"), "Standard"),
);
ok(
"이름형식 Currency(기호 없음 — 사용자 확인)",
fmt(toFrac("14182"), "Currency") === "14,182.00" &&
!fmt(toFrac("14182"), "Currency").includes("₩"),
fmt(toFrac("14182"), "Currency"),
);
ok("이름형식 Percent", fmt(toFrac("0.48"), "Percent") === "48.00%", fmt(toFrac("0.48"), "Percent"));
ok(
"이름형식 General Number",
fmt(toFrac("21.07"), "General Number") === "21.07",
fmt(toFrac("21.07"), "General Number"),
);
ok(
"이름형식 Scientific",
fmt(toFrac("43.91"), "Scientific") === "4.39E+01",
fmt(toFrac("43.91"), "Scientific"),
);
ok(
"이름형식 Short Date",
fmt(dateSerial(2026, 3, 5) as Scalar, "Short Date") === "2026-03-05",
fmt(dateSerial(2026, 3, 5) as Scalar, "Short Date"),
);
ok(
"이름형식 모르는 이름은 일반",
fmt(toFrac("23.20"), "AnUnknownStyleName") === "23.2",
fmt(toFrac("23.20"), "AnUnknownStyleName"),
);
ok(
"실무 코드는 이름표를 안 건드림(회귀)",
fmt(frac(1234567n), "#,##0") === "1,234,567",
fmt(frac(1234567n), "#,##0"),
);
// ── 채우기 ──────────────────────────────────────────────────────────────
function bookWith(cells: Record<string, Cell>): Workbook {