diff --git a/A00_Common/spreadsheet/spreadsheet_numfmt.ts b/A00_Common/spreadsheet/spreadsheet_numfmt.ts index 1c9fcb4f..b2c277f2 100644 --- a/A00_Common/spreadsheet/spreadsheet_numfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_numfmt.ts @@ -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 = { + 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; diff --git a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts index 068901cf..6d739c64 100644 --- a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts @@ -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): Workbook {