Files
Aislo/A00_Common/spreadsheet/spreadsheet_functions_misc.ts
T
eomsangdonandClaude Sonnet 5 7725b39ecb feat(spreadsheet): 스프레드시트 B(함수 · 숫자 형식 · 채우기) 1단계 구현
수학 26 · 논리/글/찾기/세기 22 함수 · 엑셀 숫자 형식 코드 해석기 · 채우기 핸들(복사 · 등차 연속 · 글 끝번호 · 식 상대이동) 구현.
자기 시험 69건(resources/tester/test_spreadsheet_b.py, vite ssr 번들 → node) 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 20:23:45 +09:00

528 lines
16 KiB
TypeScript

/* =============================================================================
* spreadsheet_functions_misc.ts (주인 B)
* 논리 · 글 · 찾기 · 세기 함수. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만).
* ========================================================================== */
import {
ZERO,
add,
cmp,
frac,
mul,
parseDecimal,
roundAt,
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
import { formatValue } from "./spreadsheet_numfmt";
import {
bool,
collectNumbers,
fracToDouble,
isErr,
isFrac,
isRange,
mkErr,
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 {
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);
}
// ── 숫자 → 고정 소수 글(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;
}
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}`);
}
},
},
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;
},
},
};