feat(spreadsheet): 분수 형식 · 함수 2차 묶음(30개) · misc 파일 쪼갬

numfmt — 분수 형식(0/0 · # ?/? · # ??/?? · 분모 고정 # ?/8) 엑셀과 같게 구현.
함수 — MROUND·ODD·EVEN·LOG·LOG10·LN·EXP(math) · SUMIFS·COUNTIFS·AVERAGEIF(S)·MAXIFS·MINIFS·
IFS·SWITCH·XLOOKUP(찾기·조건부 집계) · TEXTJOIN·TRIM·UPPER·LOWER·SUBSTITUTE·REPLACE·FIND·
SEARCH·REPT·ISNUMBER·ISTEXT·ISBLANK·ISERROR·NA·TODAY·DATE·YEAR·MONTH·DAY(misc) 더함.
misc.ts 가 700 줄을 넘어 찾기·조건부 집계를 spreadsheet_functions_lookup.ts(새 파일)로 쪼갬.
sub1 의 func_help 이름 목록에 더할 것을 spreadsheet_functions.ts 의 FUNC_HELP_BATCH2 에 둠
(그 파일은 안 고침 · 머리 주석에 읽는 길 적음). 경계값 시험 135건 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
This commit is contained in:
2026-09-27 20:59:21 +09:00
co-authored by Claude Sonnet 5
parent 0415177f44
commit d60b1ccc7e
6 changed files with 1148 additions and 274 deletions
+103 -6
View File
@@ -1,18 +1,21 @@
/* =============================================================================
* spreadsheet_functions.ts (주인 B)
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` 에 두고 여기서 모음.
* 1단계 약 45 — 수학 SUM PRODUCT SUMPRODUCT ROUND ROUNDUP ROUNDDOWN INT TRUNC CEILING FLOOR ABS MOD
* POWER SQRT PI SIN COS TAN ASIN ACOS ATAN RADIANS DEGREES MIN MAX AVERAGE · 논리 IF AND OR NOT IFERROR ·
* 글 CONCATENATE FIXED TEXT LEN LEFT RIGHT MID VALUE · 찾기 VLOOKUP HLOOKUP INDEX MATCH CHOOSE ·
* 세기 COUNT COUNTA COUNTIF SUMIF. 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만).
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` 에
* 두고 여기서 모음. 1단계 약 45 + 2단계 첫 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2`(sub1 의
* `spreadsheet_func_help.ts` 가 읽어 이름 목록에 더할 것 — 그 파일은 sub1 몫이라 안 고침).
* 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음).
* ========================================================================== */
import type { SheetFunction } from "./spreadsheet_types";
import { MATH_FUNCTIONS } from "./spreadsheet_functions_math";
import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc";
import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup";
const TABLE: Record<string, SheetFunction> = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS };
const TABLE: Record<string, SheetFunction> = {
...MATH_FUNCTIONS,
...MISC_FUNCTIONS,
...LOOKUP_FUNCTIONS,
};
export function getFunction(name: string): SheetFunction | undefined {
return TABLE[name.toUpperCase()];
@@ -22,3 +25,97 @@ export function getFunction(name: string): SheetFunction | undefined {
export function functionNames(): string[] {
return Object.keys(TABLE).sort();
}
/** 2단계 첫 묶음(2026-09-27) — sub1 `spreadsheet_func_help.ts` 의 `TABLE` 에 이 모양대로 더할 것.
* `이름` · `꼴`(인자 모양 · 대괄호 = 생략 가능) · `뜻`(한 줄). */
export interface FuncHelpEntry {
이름: string;
꼴: string;
뜻: string;
}
export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [
{ 이름: "MROUND", 꼴: "MROUND(수, 배수)", 뜻: "배수의 가장 가까운 정수 배로 반올림" },
{ 이름: "ODD", 꼴: "ODD(수)", 뜻: "홀수로 올림(0 에서 먼 쪽)" },
{ 이름: "EVEN", 꼴: "EVEN(수)", 뜻: "짝수로 올림(0 에서 먼 쪽)" },
{ 이름: "LOG", 꼴: "LOG(수, [밑=10])", 뜻: "로그(밑 지정)" },
{ 이름: "LOG10", 꼴: "LOG10(수)", 뜻: "상용로그" },
{ 이름: "LN", 꼴: "LN(수)", 뜻: "자연로그" },
{ 이름: "EXP", 꼴: "EXP(수)", 뜻: "e 의 거듭제곱" },
{
이름: "SUMIFS",
꼴: "SUMIFS(합할범위, 조건범위1, 조건1, [조건범위2, 조건2], …)",
뜻: "여러 조건에 다 맞는 칸의 합",
},
{
이름: "COUNTIFS",
꼴: "COUNTIFS(조건범위1, 조건1, [조건범위2, 조건2], …)",
뜻: "여러 조건에 다 맞는 칸 개수",
},
{
이름: "AVERAGEIF",
꼴: "AVERAGEIF(범위, 조건, [평균범위])",
뜻: "조건에 맞는 칸(또는 짝 칸)의 평균",
},
{
이름: "AVERAGEIFS",
꼴: "AVERAGEIFS(평균범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 평균",
},
{
이름: "MAXIFS",
꼴: "MAXIFS(최대범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 최댓값",
},
{
이름: "MINIFS",
꼴: "MINIFS(최소범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 최솟값",
},
{
이름: "IFS",
꼴: "IFS(조건1, 값1, [조건2, 값2], …)",
뜻: "처음으로 참인 조건의 값",
},
{
이름: "SWITCH",
꼴: "SWITCH(식, 값1, 결과1, [값2, 결과2], …, [기본값])",
뜻: "식과 같은 값을 찾아 그 결과",
},
{
이름: "XLOOKUP",
꼴: "XLOOKUP(찾을값, 찾을범위, 돌려줄범위, [못찾으면], [맞춤꼴], [찾는방향])",
뜻: "찾을범위에서 찾아 돌려줄범위의 같은 자리 값",
},
{
이름: "TEXTJOIN",
꼴: "TEXTJOIN(구분자, 빈칸건너뜀, 글1, [글2], …)",
뜻: "구분자로 여러 글을 이어 붙임",
},
{ 이름: "TRIM", 꼴: "TRIM(글)", 뜻: "앞뒤 · 겹친 빈칸을 뺌" },
{ 이름: "UPPER", 꼴: "UPPER(글)", 뜻: "대문자로" },
{ 이름: "LOWER", 꼴: "LOWER(글)", 뜻: "소문자로" },
{
이름: "SUBSTITUTE",
꼴: "SUBSTITUTE(글, 옛글, 새글, [몇번째])",
뜻: "글 안 옛글을 새글로 바꿈",
},
{ 이름: "REPLACE", 꼴: "REPLACE(글, 시작, 글자수, 새글)", 뜻: "자리로 콕 집어 바꿈" },
{ 이름: "FIND", 꼴: "FIND(찾을글, 안글, [시작])", 뜻: "글자 그대로 찾음(대소문자 가림)" },
{
이름: "SEARCH",
꼴: "SEARCH(찾을글, 안글, [시작])",
뜻: "찾음(대소문자 안 가림 · * ? 자리표시)",
},
{ 이름: "REPT", 꼴: "REPT(글, 번)", 뜻: "글을 번 만큼 되풀이" },
{ 이름: "ISNUMBER", 꼴: "ISNUMBER(값)", 뜻: "수면 참" },
{ 이름: "ISTEXT", 꼴: "ISTEXT(값)", 뜻: "글이면 참" },
{ 이름: "ISBLANK", 꼴: "ISBLANK(값)", 뜻: "빈 칸이면 참" },
{ 이름: "ISERROR", 꼴: "ISERROR(값)", 뜻: "오류 값이면 참" },
{ 이름: "NA", 꼴: "NA()", 뜻: "#N/A 오류를 만듦" },
{ 이름: "TODAY", 꼴: "TODAY()", 뜻: "오늘 날짜(일련번호)" },
{ 이름: "DATE", 꼴: "DATE(년, 월, 일)", 뜻: "날짜 일련번호를 만듦" },
{ 이름: "YEAR", 꼴: "YEAR(일련번호)", 뜻: "연도" },
{ 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" },
{ 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" },
];
@@ -0,0 +1,536 @@
/* =============================================================================
* spreadsheet_functions_lookup.ts (주인 B · misc 700 줄 넘어 2026-09-27 쪼갬)
* 찾기 · 조건부 세기·합·평균·최대·최소 함수. `spreadsheet_functions_misc.ts` 에서
* VLOOKUP · HLOOKUP · INDEX · MATCH · CHOOSE · COUNT · COUNTA · COUNTIF · SUMIF ·
* SUMIFS · COUNTIFS · AVERAGEIF(S) · MAXIFS · MINIFS · IFS · SWITCH · XLOOKUP 를 옮겨 옴.
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import {
ZERO,
add,
cmp,
div,
frac,
parseDecimal,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
import {
bool,
collectNumbers,
fracToDouble,
isErr,
isFrac,
isRange,
mkErr,
num,
toNumber,
toScalar,
} from "./spreadsheet_functions_math";
const ONE = frac(1n);
// ── 값 견줌(찾기 · 세기 함수 공용) ───────────────────────────────────────────
function typeRank(v: Scalar): number {
if (v === null) return -1;
if (isFrac(v)) return 0;
if (typeof v === "string") return 1;
if (typeof v === "boolean") return 2;
return 3;
}
function compareScalars(a: Scalar, b: Scalar): number {
if (isFrac(a) && isFrac(b)) return cmp(a, b);
if (typeof a === "string" && typeof b === "string") {
const la = a.toUpperCase();
const lb = b.toUpperCase();
return la < lb ? -1 : la > lb ? 1 : 0;
}
if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1;
return 0;
}
function equalScalars(a: Scalar, b: Scalar): boolean {
return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0;
}
function wrapScalar(v: Scalar): RangeValue {
return { kind: "range", rows: 1, cols: 1, at: () => v };
}
// ── 조건 글(`>10` · `<=5` · `사과*`) ─────────────────────────────────────────
function wildcardToRegex(text: string): RegExp {
const escaped = text
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`, "i");
}
function parseCriteria(crit: Scalar): (cell: Scalar) => boolean {
if (crit === null) return (cell) => cell === null;
if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit;
if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0;
if (isErr(crit)) return () => false;
const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit);
const op = m?.[1] ?? "";
const rest = m?.[2] ?? crit;
let numRest: Frac | null = null;
try {
numRest = rest.trim() === "" ? null : parseDecimal(rest);
} catch {
numRest = null;
}
if (numRest !== null) {
const nr = numRest;
return (cell) => {
if (!isFrac(cell)) return false;
const c = cmp(cell, nr);
if (op === "<=") return c <= 0;
if (op === ">=") return c >= 0;
if (op === "<>") return c !== 0;
if (op === "<") return c < 0;
if (op === ">") return c > 0;
return c === 0;
};
}
const pat = wildcardToRegex(rest);
if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell));
return (cell) => typeof cell === "string" && pat.test(cell);
}
/** `args[from]` 부터 (범위,조건) 짝을 이어가며 모두 맞는 자리만 참 — *IFS 함수 공용.
* `shape` 범위와 크기가 다른 조건 범위가 있으면 `#VALUE!`. */
function criteriaMask(args: LazyArg[], from: number, shape: RangeValue): boolean[] | ErrorValue {
const mask = new Array<boolean>(shape.rows * shape.cols).fill(true);
for (let i = from; i + 1 < args.length; i += 2) {
const rangeArg = args[i]();
if (!isRange(rangeArg)) return mkErr("#VALUE!");
if (rangeArg.rows !== shape.rows || rangeArg.cols !== shape.cols) {
return mkErr("#VALUE!", "조건 범위 크기가 다름");
}
const crit = toScalar(args[i + 1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
let k = 0;
for (let r = 0; r < shape.rows; r++) {
for (let c = 0; c < shape.cols; c++) {
if (mask[k] && !pred(rangeArg.at(r, c))) mask[k] = false;
k++;
}
}
}
return mask;
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const LOOKUP_FUNCTIONS: Record<string, SheetFunction> = {
VLOOKUP: {
min: 3,
max: 4,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const table = args[1]();
if (!isRange(table)) return mkErr("#VALUE!");
const colN = num(args[2]());
if (isErr(colN)) return colN;
const col = Math.trunc(fracToDouble(colN)) - 1;
if (col < 0 || col >= table.cols) return mkErr("#REF!");
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
if (isErr(approxRaw)) return approxRaw;
if (approxRaw) {
let best = -1;
for (let r = 0; r < table.rows; r++) {
const key = table.at(r, 0);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
if (compareScalars(key, lookup) <= 0) best = r;
else break;
}
if (best < 0) return mkErr("#N/A");
return table.at(best, col);
}
for (let r = 0; r < table.rows; r++) {
if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col);
}
return mkErr("#N/A");
},
},
HLOOKUP: {
min: 3,
max: 4,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const table = args[1]();
if (!isRange(table)) return mkErr("#VALUE!");
const rowN = num(args[2]());
if (isErr(rowN)) return rowN;
const row = Math.trunc(fracToDouble(rowN)) - 1;
if (row < 0 || row >= table.rows) return mkErr("#REF!");
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
if (isErr(approxRaw)) return approxRaw;
if (approxRaw) {
let best = -1;
for (let c = 0; c < table.cols; c++) {
const key = table.at(0, c);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
if (compareScalars(key, lookup) <= 0) best = c;
else break;
}
if (best < 0) return mkErr("#N/A");
return table.at(row, best);
}
for (let c = 0; c < table.cols; c++) {
if (equalScalars(table.at(0, c), lookup)) return table.at(row, c);
}
return mkErr("#N/A");
},
},
INDEX: {
min: 2,
max: 3,
call: (args) => {
const rangeArg = args[0]();
if (isErr(rangeArg)) return rangeArg;
const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg);
const rowN = num(args[1]());
if (isErr(rowN)) return rowN;
const row = Math.trunc(fracToDouble(rowN));
const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n);
if (isErr(colN)) return colN;
const col = Math.trunc(fracToDouble(colN));
if (row === 0 && col === 0) return mkErr("#VALUE!");
if (row === 0) {
if (col < 1 || col > range.cols) return mkErr("#REF!");
const c = col - 1;
return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) };
}
if (col === 0) {
if (row < 1 || row > range.rows) return mkErr("#REF!");
const r = row - 1;
return {
kind: "range",
rows: 1,
cols: range.cols,
at: (_r: number, c: number) => range.at(r, c),
};
}
if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!");
return range.at(row - 1, col - 1);
},
},
MATCH: {
min: 2,
max: 3,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const rangeArg = args[1]();
if (!isRange(rangeArg)) return mkErr("#VALUE!");
if (rangeArg.rows !== 1 && rangeArg.cols !== 1) {
return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만");
}
const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows;
const at = (i: number): Scalar =>
rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0);
const modeN = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(modeN)) return modeN;
const mode = Math.trunc(fracToDouble(modeN));
if (mode === 0) {
for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1));
return mkErr("#N/A");
}
let best = -1;
for (let i = 0; i < n; i++) {
const key = at(i);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
const c = compareScalars(key, lookup);
if (mode > 0 ? c <= 0 : c >= 0) best = i;
else break;
}
if (best < 0) return mkErr("#N/A");
return frac(BigInt(best + 1));
},
},
CHOOSE: {
min: 2,
call: (args) => {
const idxN = num(args[0]());
if (isErr(idxN)) return idxN;
const idx = Math.trunc(fracToDouble(idxN));
if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!");
return args[idx]();
},
},
COUNT: {
min: 0,
call: (args) => {
const values = collectNumbers(args, false, false);
return isErr(values) ? values : frac(BigInt(values.length));
},
},
COUNTA: {
min: 0,
call: (args) => {
let count = 0;
for (const a of args) {
const v = a();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++;
}
} else if (v !== null) {
count++;
}
}
return frac(BigInt(count));
},
},
COUNTIF: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++;
}
return frac(BigInt(count));
},
},
SUMIF: {
min: 2,
max: 3,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
const sumRange = args.length > 2 ? args[2]() : range;
if (!isRange(sumRange)) return mkErr("#VALUE!");
if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) {
return mkErr("#VALUE!", "합 범위 크기가 다름");
}
let total = ZERO;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
if (!pred(range.at(r, c))) continue;
const n = toNumber(sumRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
}
}
return total;
},
},
SUMIFS: {
min: 3,
call: (args) => {
const sumRange = args[0]();
if (!isRange(sumRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, sumRange);
if (isErr(mask)) return mask;
let total = ZERO;
let k = 0;
for (let r = 0; r < sumRange.rows; r++) {
for (let c = 0; c < sumRange.cols; c++) {
if (mask[k]) {
const n = toNumber(sumRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
}
k++;
}
}
return total;
},
},
COUNTIFS: {
min: 2,
call: (args) => {
const first = args[0]();
if (!isRange(first)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 0, first);
if (isErr(mask)) return mask;
return frac(BigInt(mask.filter(Boolean).length));
},
},
AVERAGEIF: {
min: 2,
max: 3,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
const avgRange = args.length > 2 ? args[2]() : range;
if (!isRange(avgRange)) return mkErr("#VALUE!");
if (avgRange.rows !== range.rows || avgRange.cols !== range.cols) {
return mkErr("#VALUE!", "평균 범위 크기가 다름");
}
let total = ZERO;
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
if (!pred(range.at(r, c))) continue;
const n = toNumber(avgRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
count++;
}
}
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
},
},
AVERAGEIFS: {
min: 3,
call: (args) => {
const avgRange = args[0]();
if (!isRange(avgRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, avgRange);
if (isErr(mask)) return mask;
let total = ZERO;
let count = 0;
let k = 0;
for (let r = 0; r < avgRange.rows; r++) {
for (let c = 0; c < avgRange.cols; c++) {
if (mask[k]) {
const n = toNumber(avgRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
count++;
}
k++;
}
}
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
},
},
MAXIFS: {
min: 3,
call: (args) => {
const targetRange = args[0]();
if (!isRange(targetRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, targetRange);
if (isErr(mask)) return mask;
let best: Frac | null = null;
let k = 0;
for (let r = 0; r < targetRange.rows; r++) {
for (let c = 0; c < targetRange.cols; c++) {
if (mask[k]) {
const cell = targetRange.at(r, c);
if (isFrac(cell) && (best === null || cmp(cell, best) > 0)) best = cell;
}
k++;
}
}
return best ?? ZERO;
},
},
MINIFS: {
min: 3,
call: (args) => {
const targetRange = args[0]();
if (!isRange(targetRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, targetRange);
if (isErr(mask)) return mask;
let best: Frac | null = null;
let k = 0;
for (let r = 0; r < targetRange.rows; r++) {
for (let c = 0; c < targetRange.cols; c++) {
if (mask[k]) {
const cell = targetRange.at(r, c);
if (isFrac(cell) && (best === null || cmp(cell, best) < 0)) best = cell;
}
k++;
}
}
return best ?? ZERO;
},
},
IFS: {
min: 2,
call: (args) => {
for (let i = 0; i + 1 < args.length; i += 2) {
const c = bool(args[i]());
if (isErr(c)) return c;
if (c) return args[i + 1]();
}
return mkErr("#N/A");
},
},
SWITCH: {
min: 3,
call: (args) => {
const target = toScalar(args[0]());
if (isErr(target)) return target;
let i = 1;
for (; i + 1 < args.length; i += 2) {
const cand = toScalar(args[i]());
if (isErr(cand)) return cand;
if (equalScalars(target, cand)) return args[i + 1]();
}
return i < args.length ? args[i]() : mkErr("#N/A");
},
},
XLOOKUP: {
min: 3,
max: 6,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const lookupRange = args[1]();
const returnRange = args[2]();
if (!isRange(lookupRange) || !isRange(returnRange)) return mkErr("#VALUE!");
const n = lookupRange.rows * lookupRange.cols;
if (n !== returnRange.rows * returnRange.cols) {
return mkErr("#VALUE!", "찾을 범위 · 돌려줄 범위 크기가 다름");
}
const at = (rv: RangeValue, i: number): Scalar => (rv.rows === 1 ? rv.at(0, i) : rv.at(i, 0));
const modeN = args.length > 4 ? num(args[4]()) : ZERO;
if (isErr(modeN)) return modeN;
const mode = Math.trunc(fracToDouble(modeN));
const searchN = args.length > 5 ? num(args[5]()) : ONE;
if (isErr(searchN)) return searchN;
const reverse = Math.trunc(fracToDouble(searchN)) < 0;
let best = -1;
for (let step = 0; step < n; step++) {
const i = reverse ? n - 1 - step : step;
const key = at(lookupRange, i);
if (typeRank(key) !== typeRank(lookup)) continue;
const c = compareScalars(key, lookup);
if (c === 0) {
best = i;
break;
}
if (mode === 1 && c > 0) {
if (best < 0 || compareScalars(key, at(lookupRange, best)) < 0) best = i;
} else if (mode === -1 && c < 0) {
if (best < 0 || compareScalars(key, at(lookupRange, best)) > 0) best = i;
}
}
if (best < 0) return args.length > 3 ? args[3]() : mkErr("#N/A");
return at(returnRange, best);
},
},
};
@@ -415,4 +415,76 @@ export const MATH_FUNCTIONS: Record<string, SheetFunction> = {
return div(values.reduce(add, ZERO), frac(BigInt(values.length)));
},
},
MROUND: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const m = num(args[1]());
if (isErr(m)) return m;
if (cmp(m, ZERO) === 0) return ZERO;
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(m, ZERO) > 0) {
return mkErr("#NUM!", "수 · 배수의 부호가 다름");
}
return mul(frac(toInteger(div(n, m), "round")), m);
},
},
ODD: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
let v = toInteger(n, "away");
if (v === 0n) v = 1n;
else if (v % 2n === 0n) v += v < 0n ? -1n : 1n;
return frac(v);
},
},
EVEN: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
let v = toInteger(n, "away");
if (v % 2n !== 0n) v += v < 0n ? -1n : 1n;
return frac(v);
},
},
LOG: {
min: 1,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
const base = args.length > 1 ? num(args[1]()) : frac(10n);
if (isErr(base)) return base;
if (cmp(base, ZERO) <= 0 || cmp(base, ONE) === 0) return mkErr("#NUM!");
return fromDouble(Math.log(fracToDouble(n)) / Math.log(fracToDouble(base)));
},
},
LOG10: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
return fromDouble(Math.log10(fracToDouble(n)));
},
},
LN: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
return fromDouble(Math.log(fracToDouble(n)));
},
},
EXP: unaryDouble(Math.exp),
};
@@ -1,12 +1,12 @@
/* =============================================================================
* spreadsheet_functions_misc.ts (주인 B)
* 논리 · 글 · 찾기 · 세기 함수. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만).
* 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는
* `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27).
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import {
ZERO,
add,
cmp,
frac,
mul,
@@ -15,12 +15,12 @@ import {
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types";
import { formatValue } from "./spreadsheet_numfmt";
import {
bool,
collectNumbers,
fracToDouble,
fromDouble,
isErr,
isFrac,
isRange,
@@ -28,82 +28,19 @@ import {
num,
str,
toBool,
toNumber,
toScalar,
toText,
} from "./spreadsheet_functions_math";
const ONE = frac(1n);
// ── 값 견줌(찾기 · 세기 함수 공용) ───────────────────────────────────────────
function typeRank(v: Scalar): number {
if (v === null) return -1;
if (isFrac(v)) return 0;
if (typeof v === "string") return 1;
if (typeof v === "boolean") return 2;
return 3;
}
function compareScalars(a: Scalar, b: Scalar): number {
if (isFrac(a) && isFrac(b)) return cmp(a, b);
if (typeof a === "string" && typeof b === "string") {
const la = a.toUpperCase();
const lb = b.toUpperCase();
return la < lb ? -1 : la > lb ? 1 : 0;
}
if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1;
return 0;
}
function equalScalars(a: Scalar, b: Scalar): boolean {
return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0;
}
function wrapScalar(v: Scalar): RangeValue {
return { kind: "range", rows: 1, cols: 1, at: () => v };
}
// ── 조건 글(`>10` · `<=5` · `사과*`) ─────────────────────────────────────────
function wildcardToRegex(text: string): RegExp {
/** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */
function wildcardToSearchRegex(text: string): RegExp {
const escaped = text
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`, "i");
}
function parseCriteria(crit: Scalar): (cell: Scalar) => boolean {
if (crit === null) return (cell) => cell === null;
if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit;
if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0;
if (isErr(crit)) return () => false;
const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit);
const op = m?.[1] ?? "";
const rest = m?.[2] ?? crit;
let numRest: Frac | null = null;
try {
numRest = rest.trim() === "" ? null : parseDecimal(rest);
} catch {
numRest = null;
}
if (numRest !== null) {
const nr = numRest;
return (cell) => {
if (!isFrac(cell)) return false;
const c = cmp(cell, nr);
if (op === "<=") return c <= 0;
if (op === ">=") return c >= 0;
if (op === "<>") return c !== 0;
if (op === "<") return c < 0;
if (op === ">") return c > 0;
return c === 0;
};
}
const pat = wildcardToRegex(rest);
if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell));
return (cell) => typeof cell === "string" && pat.test(cell);
return new RegExp(escaped, "i");
}
// ── 숫자 → 고정 소수 글(FIXED) ───────────────────────────────────────────────
@@ -126,8 +63,6 @@ function formatFixed(n: Frac, decimals: number, commas: boolean): string {
return negative ? `-${body}` : body;
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
const out: boolean[] = [];
for (const a of args) {
@@ -152,6 +87,23 @@ function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
return out;
}
// ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ───────────────────────
// 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) —
// 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖).
const MS_PER_DAY = 86400000;
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
function serialToDate(serial: number): Date {
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
}
function dateToSerial(d: Date): number {
return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL;
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
IF: {
min: 2,
@@ -311,217 +263,207 @@ export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
}
},
},
VLOOKUP: {
TEXTJOIN: {
min: 3,
max: 4,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const table = args[1]();
if (!isRange(table)) return mkErr("#VALUE!");
const colN = num(args[2]());
if (isErr(colN)) return colN;
const col = Math.trunc(fracToDouble(colN)) - 1;
if (col < 0 || col >= table.cols) return mkErr("#REF!");
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
if (isErr(approxRaw)) return approxRaw;
if (approxRaw) {
let best = -1;
for (let r = 0; r < table.rows; r++) {
const key = table.at(r, 0);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
if (compareScalars(key, lookup) <= 0) best = r;
else break;
}
if (best < 0) return mkErr("#N/A");
return table.at(best, col);
}
for (let r = 0; r < table.rows; r++) {
if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col);
}
return mkErr("#N/A");
},
},
HLOOKUP: {
min: 3,
max: 4,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const table = args[1]();
if (!isRange(table)) return mkErr("#VALUE!");
const rowN = num(args[2]());
if (isErr(rowN)) return rowN;
const row = Math.trunc(fracToDouble(rowN)) - 1;
if (row < 0 || row >= table.rows) return mkErr("#REF!");
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
if (isErr(approxRaw)) return approxRaw;
if (approxRaw) {
let best = -1;
for (let c = 0; c < table.cols; c++) {
const key = table.at(0, c);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
if (compareScalars(key, lookup) <= 0) best = c;
else break;
}
if (best < 0) return mkErr("#N/A");
return table.at(row, best);
}
for (let c = 0; c < table.cols; c++) {
if (equalScalars(table.at(0, c), lookup)) return table.at(row, c);
}
return mkErr("#N/A");
},
},
INDEX: {
min: 2,
max: 3,
call: (args) => {
const rangeArg = args[0]();
if (isErr(rangeArg)) return rangeArg;
const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg);
const rowN = num(args[1]());
if (isErr(rowN)) return rowN;
const row = Math.trunc(fracToDouble(rowN));
const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n);
if (isErr(colN)) return colN;
const col = Math.trunc(fracToDouble(colN));
if (row === 0 && col === 0) return mkErr("#VALUE!");
if (row === 0) {
if (col < 1 || col > range.cols) return mkErr("#REF!");
const c = col - 1;
return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) };
}
if (col === 0) {
if (row < 1 || row > range.rows) return mkErr("#REF!");
const r = row - 1;
return {
kind: "range",
rows: 1,
cols: range.cols,
at: (_r: number, c: number) => range.at(r, c),
};
}
if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!");
return range.at(row - 1, col - 1);
},
},
MATCH: {
min: 2,
max: 3,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const rangeArg = args[1]();
if (!isRange(rangeArg)) return mkErr("#VALUE!");
if (rangeArg.rows !== 1 && rangeArg.cols !== 1) {
return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만");
}
const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows;
const at = (i: number): Scalar =>
rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0);
const modeN = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(modeN)) return modeN;
const mode = Math.trunc(fracToDouble(modeN));
if (mode === 0) {
for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1));
return mkErr("#N/A");
}
let best = -1;
for (let i = 0; i < n; i++) {
const key = at(i);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
const c = compareScalars(key, lookup);
if (mode > 0 ? c <= 0 : c >= 0) best = i;
else break;
}
if (best < 0) return mkErr("#N/A");
return frac(BigInt(best + 1));
},
},
CHOOSE: {
min: 2,
call: (args) => {
const idxN = num(args[0]());
if (isErr(idxN)) return idxN;
const idx = Math.trunc(fracToDouble(idxN));
if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!");
return args[idx]();
},
},
COUNT: {
min: 0,
call: (args) => {
const values = collectNumbers(args, false, false);
return isErr(values) ? values : frac(BigInt(values.length));
},
},
COUNTA: {
min: 0,
call: (args) => {
let count = 0;
for (const a of args) {
const v = a();
const delim = str(args[0]());
if (isErr(delim)) return delim;
const ignoreEmpty = bool(args[1]());
if (isErr(ignoreEmpty)) return ignoreEmpty;
const parts: string[] = [];
for (let i = 2; i < args.length; i++) {
const v = args[i]();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++;
for (let c = 0; c < v.cols; c++) {
const t = toText(v.at(r, c));
if (isErr(t)) return t;
if (!(ignoreEmpty && t === "")) parts.push(t);
}
}
} else if (v !== null) {
count++;
} else {
const t = str(v);
if (isErr(t)) return t;
if (!(ignoreEmpty && t === "")) parts.push(t);
}
}
return frac(BigInt(count));
return parts.join(delim);
},
},
COUNTIF: {
TRIM: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.split(" ").filter(Boolean).join(" ");
},
},
UPPER: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.toUpperCase();
},
},
LOWER: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.toLowerCase();
},
},
SUBSTITUTE: {
min: 3,
max: 4,
call: (args) => {
const text = str(args[0]());
if (isErr(text)) return text;
const oldT = str(args[1]());
if (isErr(oldT)) return oldT;
const newT = str(args[2]());
if (isErr(newT)) return newT;
if (oldT === "") return text;
if (args.length <= 3) return text.split(oldT).join(newT);
const nthArg = num(args[3]());
if (isErr(nthArg)) return nthArg;
const nth = Math.trunc(fracToDouble(nthArg));
let idx = -1;
let count = 0;
let pos = 0;
for (;;) {
const found = text.indexOf(oldT, pos);
if (found < 0) break;
count++;
if (count === nth) {
idx = found;
break;
}
pos = found + oldT.length;
}
return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length);
},
},
REPLACE: {
min: 4,
max: 4,
call: (args) => {
const text = str(args[0]());
if (isErr(text)) return text;
const startN = num(args[1]());
if (isErr(startN)) return startN;
const lenN = num(args[2]());
if (isErr(lenN)) return lenN;
const newText = str(args[3]());
if (isErr(newText)) return newText;
const start = Math.trunc(fracToDouble(startN));
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
if (start < 1) return mkErr("#VALUE!");
return text.slice(0, start - 1) + newText + text.slice(start - 1 + len);
},
},
FIND: {
min: 2,
max: 3,
call: (args) => {
const findT = str(args[0]());
if (isErr(findT)) return findT;
const within = str(args[1]());
if (isErr(within)) return within;
const startArg = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(startArg)) return startArg;
const start = Math.trunc(fracToDouble(startArg));
if (start < 1) return mkErr("#VALUE!");
const idx = within.indexOf(findT, start - 1);
return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1));
},
},
SEARCH: {
min: 2,
max: 3,
call: (args) => {
const findT = str(args[0]());
if (isErr(findT)) return findT;
const within = str(args[1]());
if (isErr(within)) return within;
const startArg = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(startArg)) return startArg;
const start = Math.trunc(fracToDouble(startArg));
if (start < 1) return mkErr("#VALUE!");
const pat = wildcardToSearchRegex(findT);
const m = pat.exec(within.slice(start - 1));
return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!");
},
},
REPT: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++;
}
return frac(BigInt(count));
const s = str(args[0]());
if (isErr(s)) return s;
const nArg = num(args[1]());
if (isErr(nArg)) return nArg;
const n = Math.trunc(fracToDouble(nArg));
return n < 0 ? mkErr("#VALUE!") : s.repeat(n);
},
},
SUMIF: {
min: 2,
ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) },
ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" },
ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null },
ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) },
NA: { min: 0, max: 0, call: () => mkErr("#N/A") },
TODAY: {
min: 0,
max: 0,
call: () => {
const now = new Date();
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
return fromDouble(dateToSerial(new Date(midnight)));
},
},
DATE: {
min: 3,
max: 3,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
const sumRange = args.length > 2 ? args[2]() : range;
if (!isRange(sumRange)) return mkErr("#VALUE!");
if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) {
return mkErr("#VALUE!", "합 범위 크기가 다름");
}
let total = ZERO;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
if (!pred(range.at(r, c))) continue;
const n = toNumber(sumRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
}
}
return total;
const y = num(args[0]());
if (isErr(y)) return y;
const m = num(args[1]());
if (isErr(m)) return m;
const d = num(args[2]());
if (isErr(d)) return d;
const date = new Date(
Date.UTC(
Math.trunc(fracToDouble(y)),
Math.trunc(fracToDouble(m)) - 1,
Math.trunc(fracToDouble(d)),
),
);
return fromDouble(dateToSerial(date));
},
},
YEAR: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear()));
},
},
MONTH: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1));
},
},
DAY: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate()));
},
},
};
@@ -14,6 +14,7 @@ import {
fracToString,
mul,
roundAt,
sub,
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
@@ -235,6 +236,130 @@ function renderNumericSection(toks: Tok[], magnitude: Frac): string {
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);
}
@@ -265,6 +390,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 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;
@@ -111,6 +111,9 @@ fracEq(
fracEq("ROUND", call("ROUND", n("1.155"), n(2)), "1.16"); // 분수 반올림 — 부동소수 함정 없음
fracEq("ROUNDUP", call("ROUNDUP", n("1.001"), n(2)), "1.01");
fracEq("ROUNDDOWN", call("ROUNDDOWN", n("1.999"), n(2)), "1.99");
fracEq("ROUND 음수 자릿수(십의 자리)", call("ROUND", n(1234), n(-2)), "1200");
fracEq("ROUNDUP 음수 자릿수", call("ROUNDUP", n(1234), n(-2)), "1300");
fracEq("ROUNDDOWN 음수 자릿수", call("ROUNDDOWN", n(1234), n(-2)), "1200");
fracEq("INT 양수", call("INT", n("8.9")), "8");
fracEq("INT 음수(floor)", call("INT", n("-8.9")), "-9");
fracEq("TRUNC", call("TRUNC", n("8.9")), "8");
@@ -142,6 +145,29 @@ errEq("AVERAGE 빈 값 없음", call("AVERAGE", range(1, 1, [["x"]])), "#DIV/0!"
errEq("MOD 0 나눔", call("MOD", n(1), n(0)), "#DIV/0!");
errEq("CEILING 부호 다름", call("CEILING", n(-2), n(1)), "#NUM!");
// 2단계 첫 묶음(2026-09-27)
fracEq("MROUND", call("MROUND", n(10), n(3)), "9");
fracEq("MROUND 소수", call("MROUND", n("1.3"), n("0.2")), "1.4");
errEq("MROUND 부호 다름", call("MROUND", n(10), n(-3)), "#NUM!");
fracEq("ODD 양수", call("ODD", n("1.5")), "3");
fracEq("ODD 짝수 정수", call("ODD", n(2)), "3");
fracEq("ODD 0", call("ODD", n(0)), "1");
fracEq("ODD 음수", call("ODD", n(-2)), "-3");
fracEq("EVEN 양수", call("EVEN", n("1.5")), "2");
fracEq("EVEN 홀수 정수", call("EVEN", n(3)), "4");
fracEq("EVEN 음수", call("EVEN", n(-1)), "-2");
fracEq("LOG 밑 지정", call("LOG", n(8), n(2)), "3");
fracEq("LOG10", call("LOG10", n(100)), "2");
fracEq("LN(1) = 0", call("LN", n(1)), "0");
ok(
"EXP(1) ≈ e",
(() => {
const v = call("EXP", n(1));
return isFrac(v) && fracToString(v).startsWith("2.71828182845905");
})(),
);
errEq("LOG10 0 이하", call("LOG10", n(0)), "#NUM!");
// ── 논리 · 글 ────────────────────────────────────────────────────────────
fracEq("IF 참 값", call("IF", bl(true), n(1), n(2)), "1");
@@ -161,6 +187,22 @@ textEq("RIGHT", call("RIGHT", txt("가나다"), n(2)), "나다");
textEq("MID", call("MID", txt("가나다라"), n(2), n(2)), "나다");
fracEq("VALUE", call("VALUE", txt("1,234.5")), "1234.5");
errEq("VALUE 실패", call("VALUE", txt("가나다")), "#VALUE!");
textEq(
"TEXTJOIN 빈칸 건너뜀",
call("TEXTJOIN", txt("-"), bl(true), txt("a"), txt(""), txt("b")),
"a-b",
);
textEq("TRIM", call("TRIM", txt(" 가 나 ")), "가 나");
textEq("UPPER", call("UPPER", txt("abc")), "ABC");
textEq("LOWER", call("LOWER", txt("ABC")), "abc");
textEq("SUBSTITUTE 전부", call("SUBSTITUTE", txt("abcabc"), txt("a"), txt("X")), "XbcXbc");
textEq("SUBSTITUTE 몇번째", call("SUBSTITUTE", txt("abcabc"), txt("a"), txt("X"), n(2)), "abcXbc");
textEq("REPLACE", call("REPLACE", txt("abcdef"), n(2), n(3), txt("XYZ")), "aXYZef");
fracEq("FIND", call("FIND", txt("c"), txt("abcabc")), "3");
errEq("FIND 대소문자 가림", call("FIND", txt("C"), txt("abcabc")), "#VALUE!");
fracEq("SEARCH 대소문자 안가림", call("SEARCH", txt("C"), txt("abcabc")), "3");
fracEq("SEARCH 자리표시(*)", call("SEARCH", txt("a*c"), txt("xxabcyy")), "3");
textEq("REPT", call("REPT", txt("ab"), n(3)), "ababab");
// ── 찾기 ────────────────────────────────────────────────────────────────
@@ -207,6 +249,51 @@ fracEq(
"40",
);
// 2단계 첫 묶음 — 여러 조건 · IFS 계열 · XLOOKUP
const NAMES4 = range(1, 4, [["가", "나", "가", "다"]]);
const AMOUNTS4 = range(1, 4, [[frac(10n), frac(20n), frac(30n), frac(40n)]]);
const AMOUNTS4B = range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]);
fracEq("SUMIFS 두 조건", call("SUMIFS", AMOUNTS4, NAMES4, txt("가"), AMOUNTS4B, txt(">1")), "30");
fracEq("COUNTIFS 두 조건", call("COUNTIFS", NAMES4, txt("가"), AMOUNTS4B, txt(">1")), "1");
fracEq("AVERAGEIF", call("AVERAGEIF", AMOUNTS4, txt(">15")), "30");
fracEq("AVERAGEIFS", call("AVERAGEIFS", AMOUNTS4, NAMES4, txt("가")), "20");
fracEq("MAXIFS", call("MAXIFS", AMOUNTS4, NAMES4, txt("가")), "30");
fracEq("MINIFS", call("MINIFS", AMOUNTS4, NAMES4, txt("가")), "10");
fracEq("IFS 처음 참", call("IFS", bl(false), n(1), bl(true), n(2), bl(true), n(3)), "2");
textEq(
"SWITCH 맞음",
call("SWITCH", n(2), n(1), txt("one"), n(2), txt("two"), n(3), txt("three")),
"two",
);
textEq("SWITCH 기본값", call("SWITCH", n(9), n(1), txt("one"), txt("default")), "default");
errEq("SWITCH 안 맞음(기본값 없음)", call("SWITCH", n(9), n(1), txt("one")), "#N/A");
const XNAMES = range(1, 3, [["가", "나", "다"]]);
const XAMOUNTS = range(1, 3, [[frac(10n), frac(20n), frac(30n)]]);
fracEq("XLOOKUP", call("XLOOKUP", txt("나"), XNAMES, XAMOUNTS), "20");
textEq("XLOOKUP 못찾음 대체값", call("XLOOKUP", txt("없음"), XNAMES, XAMOUNTS, txt("no")), "no");
errEq("XLOOKUP 못찾음 기본", call("XLOOKUP", txt("없음"), XNAMES, XAMOUNTS), "#N/A");
// 정보 · 날짜
boolEq("ISNUMBER 참", call("ISNUMBER", n(5)) as boolean, true);
boolEq("ISNUMBER 거짓(글)", call("ISNUMBER", txt("x")) as boolean, false);
boolEq("ISTEXT 참", call("ISTEXT", txt("x")) as boolean, true);
boolEq("ISBLANK 참", call("ISBLANK", lit(null)) as boolean, true);
boolEq("ISBLANK 거짓(0)", call("ISBLANK", n(0)) as boolean, false);
boolEq("ISERROR 참", call("ISERROR", lit({ error: "#DIV/0!" } as ErrorValue)) as boolean, true);
boolEq("ISERROR 거짓", call("ISERROR", n(1)) as boolean, false);
errEq("NA", call("NA"), "#N/A");
{
const serial = call("DATE", n(2026), n(1), n(15));
fracEq("YEAR(DATE(2026,1,15))", call("YEAR", lit(serial)), "2026");
fracEq("MONTH(DATE(2026,1,15))", call("MONTH", lit(serial)), "1");
fracEq("DAY(DATE(2026,1,15))", call("DAY", lit(serial)), "15");
}
{
const todaySerial = call("TODAY");
const wantYear = new Date().getUTCFullYear();
fracEq("YEAR(TODAY()) = 지금 연도", call("YEAR", lit(todaySerial)), String(wantYear));
}
// ── 숫자 형식 ────────────────────────────────────────────────────────────
function fmt(value: Scalar, code: string): string {
@@ -249,6 +336,16 @@ ok(
fmt(frac(-150n, 100n), "0.00_);[Red](0.00)") === "(1.50)",
fmt(frac(-150n, 100n), "0.00_);[Red](0.00)"),
);
// 분수 형식
ok("분수 가분수(자동 분모)", fmt(frac(1n, 2n), "0/0") === "1/2", fmt(frac(1n, 2n), "0/0"));
ok(
"분수 정수부+분자(자동 분모)",
fmt(frac(5n, 2n), "# ?/?") === "2 1/2",
fmt(frac(5n, 2n), "# ?/?"),
);
ok("분수 분모 고정", fmt(frac(4n, 10n), "# ?/8") === " 3/8", fmt(frac(4n, 10n), "# ?/8"));
ok("분수 정확히 나눠떨어짐(정수만)", fmt(frac(3n), "# ?/?") === "3", fmt(frac(3n), "# ?/?"));
ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@"));
ok(
"일반(General)",