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
470 lines
13 KiB
TypeScript
470 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_functions_misc.ts (주인 B)
|
|
* 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는
|
|
* `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27).
|
|
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
ZERO,
|
|
cmp,
|
|
frac,
|
|
mul,
|
|
parseDecimal,
|
|
roundAt,
|
|
toInteger,
|
|
type Frac,
|
|
} from "@ui/sheet/ui_template_sheet_frac";
|
|
import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types";
|
|
import { formatValue } from "./spreadsheet_numfmt";
|
|
import {
|
|
bool,
|
|
fracToDouble,
|
|
fromDouble,
|
|
isErr,
|
|
isFrac,
|
|
isRange,
|
|
mkErr,
|
|
num,
|
|
str,
|
|
toBool,
|
|
toScalar,
|
|
toText,
|
|
} from "./spreadsheet_functions_math";
|
|
|
|
const ONE = frac(1n);
|
|
|
|
/** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */
|
|
function wildcardToSearchRegex(text: string): RegExp {
|
|
const escaped = text
|
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
.replace(/\*/g, ".*")
|
|
.replace(/\?/g, ".");
|
|
return new RegExp(escaped, "i");
|
|
}
|
|
|
|
// ── 숫자 → 고정 소수 글(FIXED) ───────────────────────────────────────────────
|
|
|
|
function groupThousands(digits: string): string {
|
|
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
}
|
|
|
|
function formatFixed(n: Frac, decimals: number, commas: boolean): string {
|
|
const d = Math.max(0, decimals);
|
|
const rounded = roundAt(n, d, "round");
|
|
const negative = cmp(rounded, ZERO) < 0;
|
|
const magnitude = negative ? frac(-rounded.n, rounded.d) : rounded;
|
|
const scaled = toInteger(mul(magnitude, frac(10n ** BigInt(d))), "round");
|
|
const digits = scaled.toString().padStart(d + 1, "0");
|
|
let whole = d > 0 ? digits.slice(0, -d) : digits;
|
|
const fraction = d > 0 ? digits.slice(-d) : "";
|
|
if (commas) whole = groupThousands(whole);
|
|
const body = fraction ? `${whole}.${fraction}` : whole;
|
|
return negative ? `-${body}` : body;
|
|
}
|
|
|
|
function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
|
|
const out: boolean[] = [];
|
|
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++) {
|
|
const cell = v.at(r, c);
|
|
if (isErr(cell)) return cell;
|
|
if (typeof cell === "boolean") out.push(cell);
|
|
else if (isFrac(cell)) out.push(cmp(cell, ZERO) !== 0);
|
|
}
|
|
}
|
|
} else if (isErr(v)) {
|
|
return v;
|
|
} else if (v !== null) {
|
|
const b = toBool(v);
|
|
if (isErr(b)) return b;
|
|
out.push(b);
|
|
}
|
|
}
|
|
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,
|
|
max: 3,
|
|
call: (args) => {
|
|
const c = bool(args[0]());
|
|
if (isErr(c)) return c;
|
|
if (c) return args[1]();
|
|
return args.length > 2 ? args[2]() : false;
|
|
},
|
|
},
|
|
AND: {
|
|
min: 1,
|
|
call: (args) => {
|
|
const bs = collectBools(args);
|
|
if (isErr(bs)) return bs;
|
|
if (bs.length === 0) return mkErr("#VALUE!");
|
|
return bs.every(Boolean);
|
|
},
|
|
},
|
|
OR: {
|
|
min: 1,
|
|
call: (args) => {
|
|
const bs = collectBools(args);
|
|
if (isErr(bs)) return bs;
|
|
if (bs.length === 0) return mkErr("#VALUE!");
|
|
return bs.some(Boolean);
|
|
},
|
|
},
|
|
NOT: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const b = bool(args[0]());
|
|
return isErr(b) ? b : !b;
|
|
},
|
|
},
|
|
IFERROR: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const v = args[0]();
|
|
if (!isRange(v) && isErr(v)) return args[1]();
|
|
return v;
|
|
},
|
|
},
|
|
CONCATENATE: {
|
|
min: 1,
|
|
call: (args) => {
|
|
let out = "";
|
|
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++) {
|
|
const t = toText(v.at(r, c));
|
|
if (isErr(t)) return t;
|
|
out += t;
|
|
}
|
|
}
|
|
} else {
|
|
const t = str(v);
|
|
if (isErr(t)) return t;
|
|
out += t;
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
},
|
|
FIXED: {
|
|
min: 1,
|
|
max: 3,
|
|
call: (args) => {
|
|
const n = num(args[0]());
|
|
if (isErr(n)) return n;
|
|
const decArg = args.length > 1 ? num(args[1]()) : frac(2n);
|
|
if (isErr(decArg)) return decArg;
|
|
const decimals = Math.trunc(fracToDouble(decArg));
|
|
const noCommaArg = args.length > 2 ? bool(args[2]()) : false;
|
|
if (isErr(noCommaArg)) return noCommaArg;
|
|
return formatFixed(n, decimals, !noCommaArg);
|
|
},
|
|
},
|
|
TEXT: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const v = toScalar(args[0]());
|
|
if (isErr(v)) return v;
|
|
const code = str(args[1]());
|
|
if (isErr(code)) return code;
|
|
return formatValue(v, code).글;
|
|
},
|
|
},
|
|
LEN: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
return isErr(s) ? s : frac(BigInt(s.length));
|
|
},
|
|
},
|
|
LEFT: {
|
|
min: 1,
|
|
max: 2,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
if (isErr(s)) return s;
|
|
const nArg = args.length > 1 ? num(args[1]()) : ONE;
|
|
if (isErr(nArg)) return nArg;
|
|
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
|
|
return s.slice(0, n);
|
|
},
|
|
},
|
|
RIGHT: {
|
|
min: 1,
|
|
max: 2,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
if (isErr(s)) return s;
|
|
const nArg = args.length > 1 ? num(args[1]()) : ONE;
|
|
if (isErr(nArg)) return nArg;
|
|
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
|
|
return n === 0 ? "" : s.slice(-n);
|
|
},
|
|
},
|
|
MID: {
|
|
min: 3,
|
|
max: 3,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
if (isErr(s)) return s;
|
|
const startN = num(args[1]());
|
|
if (isErr(startN)) return startN;
|
|
const lenN = num(args[2]());
|
|
if (isErr(lenN)) return lenN;
|
|
const start = Math.trunc(fracToDouble(startN));
|
|
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
|
|
if (start < 1) return mkErr("#VALUE!");
|
|
return s.slice(start - 1, start - 1 + len);
|
|
},
|
|
},
|
|
VALUE: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
if (isErr(s)) return s;
|
|
const trimmed = s.trim();
|
|
const pct = trimmed.endsWith("%");
|
|
const body = pct ? trimmed.slice(0, -1) : trimmed;
|
|
try {
|
|
const v = parseDecimal(body);
|
|
return pct ? { n: v.n, d: v.d * 100n } : v;
|
|
} catch {
|
|
return mkErr("#VALUE!", `글을 수로 못 읽음: ${s}`);
|
|
}
|
|
},
|
|
},
|
|
TEXTJOIN: {
|
|
min: 3,
|
|
call: (args) => {
|
|
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++) {
|
|
const t = toText(v.at(r, c));
|
|
if (isErr(t)) return t;
|
|
if (!(ignoreEmpty && t === "")) parts.push(t);
|
|
}
|
|
}
|
|
} else {
|
|
const t = str(v);
|
|
if (isErr(t)) return t;
|
|
if (!(ignoreEmpty && t === "")) parts.push(t);
|
|
}
|
|
}
|
|
return parts.join(delim);
|
|
},
|
|
},
|
|
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 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);
|
|
},
|
|
},
|
|
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 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()));
|
|
},
|
|
},
|
|
};
|