diff --git a/A00_Common/spreadsheet/spreadsheet_functions.ts b/A00_Common/spreadsheet/spreadsheet_functions.ts index d0cabc22..a92ef894 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions.ts @@ -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 = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS }; +const TABLE: Record = { + ...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(일련번호)", 뜻: "일" }, +]; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_lookup.ts b/A00_Common/spreadsheet/spreadsheet_functions_lookup.ts new file mode 100644 index 00000000..214caa5f --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_functions_lookup.ts @@ -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(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 = { + 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); + }, + }, +}; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_math.ts b/A00_Common/spreadsheet/spreadsheet_functions_math.ts index 99f21e12..6d65b53e 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions_math.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions_math.ts @@ -415,4 +415,76 @@ export const MATH_FUNCTIONS: Record = { 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), }; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts index 10d4916a..dd33073f 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts @@ -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 = { IF: { min: 2, @@ -311,217 +263,207 @@ export const MISC_FUNCTIONS: Record = { } }, }, - 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())); }, }, }; diff --git a/A00_Common/spreadsheet/spreadsheet_numfmt.ts b/A00_Common/spreadsheet/spreadsheet_numfmt.ts index 1351fcab..b6533dff 100644 --- a/A00_Common/spreadsheet/spreadsheet_numfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_numfmt.ts @@ -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; diff --git a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts index 92972b24..129b4d08 100644 --- a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts @@ -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)",