수학 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
528 lines
16 KiB
TypeScript
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;
|
|
},
|
|
},
|
|
};
|