Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-27 21:18:42 +09:00
26 changed files with 2630 additions and 376 deletions
+1
View File
@@ -244,6 +244,7 @@ export function createSpreadsheet(
recalc() {
ctx.engine.rebuild(ctx.book);
grid.render();
for (const p of others) p.refresh();
return ctx.engine.snapshot();
},
destroy() {
+18 -15
View File
@@ -91,25 +91,26 @@ export function resolveBoxBorders(
};
}
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). */
function borderCss(side?: BorderSide): string {
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). `scale` = 확대 · 축소 배율(선 굵기도 같이). */
function borderCss(side: BorderSide | undefined, scale: number): string {
if (!side) return "";
const color = side.색 ?? "#000000";
const w = (px: number): number => Math.max(1, Math.round(px * scale));
switch (side.선) {
case "hair":
return `1px dotted ${color}`;
return `${w(1)}px dotted ${color}`;
case "thin":
return `1px solid ${color}`;
return `${w(1)}px solid ${color}`;
case "medium":
return `2px solid ${color}`;
return `${w(2)}px solid ${color}`;
case "thick":
return `3px solid ${color}`;
return `${w(3)}px solid ${color}`;
case "double":
return `3px double ${color}`;
return `${w(3)}px double ${color}`;
case "dotted":
return `1px dotted ${color}`;
return `${w(1)}px dotted ${color}`;
case "dashed":
return `1px dashed ${color}`;
return `${w(1)}px dashed ${color}`;
}
}
@@ -129,16 +130,18 @@ const VALIGN_CSS: Record<VAlign, string> = {
bottom: "flex-end",
};
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). */
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
export function applyCellStyle(
cell: HTMLElement,
style: CellStyle,
borders: ResolvedBorders,
hAlign: HAlign,
scale = 1,
): void {
const s = cell.style;
s.fontFamily = style.글꼴 ?? "";
s.fontSize = style.크기 ? `${style.크기}pt` : "";
s.fontSize = style.크기 ? `${style.크기 * scale}pt` : "";
s.fontWeight = style.굵게 ? "700" : "400";
s.fontStyle = style.기울임 ? "italic" : "normal";
s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"]
@@ -146,10 +149,10 @@ export function applyCellStyle(
.join(" ");
s.color = style.글자색 ?? "";
s.backgroundColor = style.채움 ?? "";
s.borderTop = borderCss(borders.위);
s.borderLeft = borderCss(borders.왼);
s.borderBottom = borderCss(borders.아래);
s.borderRight = borderCss(borders.오른);
s.borderTop = borderCss(borders.위, scale);
s.borderLeft = borderCss(borders.왼, scale);
s.borderBottom = borderCss(borders.아래, scale);
s.borderRight = borderCss(borders.오른, scale);
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
@@ -0,0 +1,272 @@
/* =============================================================================
* spreadsheet_condfmt.ts (3단계 · 주인 sub_laptop_4)
* 조건부 서식 — 칸 값이 규칙에 맞으면 채움색 · 글자색을 덮어씀. 저장 자리 `Sheet.조건부서식`
* (0 계약 `spreadsheet_types.ts` 에 방금 더함 · 계약 더함).
*
* 규칙 차례 = 우선순위(배열 앞쪽이 먼저 이김 · 엑셀 「규칙 관리」 위쪽과 같음). 스타일은 `서식` 표
* 번호를 안 씀 — 규칙마다 채움 · 글자색을 직접 가져 표 번호 조율(A 몫)이 필요 없음. 「수식이 참」
* 조건은 임의 수식 글을 파싱·계산해야 해서(엔진 A) 이 파일은 `evalFormula` 콜백만 받고 엔진을 모름.
*
* 잇는 법(A · C · D · sub7 — 지금은 하나도 안 이어짐):
* 1) 격자(C)가 칸을 그릴 때 `resolveCondFmtStyle(sheet, r, c, env)` 로 덮어쓸 스타일을 얻어
* 지금 셀 스타일 위에 얹음(env.value 는 `ctx.engine.value` 를 수 · 글 · 참거짓 · null 로 바꿔 줌).
* 2) `env.evalFormula(식, r, c)` 구현 — `spreadsheet_parser.parse(식)` 뒤 `spreadsheet_eval` 로
* 풀어 참거짓으로(A 가 만들 자리 · 지금 없음).
* 3) 행 · 열 넣기 · 지우기 뒤 `remapCondFmtRanges` 를 그 시트 `조건부서식` 에 적용해 범위를 옮김
* (`spreadsheet_commands.insertDelete` 가 지금 이 규칙을 모름 — refshift 참조 옮김과 같은 자리에
* 걸어야 함 · A 가 채울 자리).
* 4) 도구 모음 · 우클릭 메뉴에 [조건부 서식] 항목을 두고 `createCondFmtPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { inRange, rangeToA1 } from "./spreadsheet_address";
import type {
CellRange,
CondFmtCompareOp,
CondFmtCondition,
CondFmtRule,
CondFmtStyle,
Sheet,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { CondFmtCompareOp, CondFmtCondition, CondFmtRule, CondFmtStyle };
/** 칸 값을 이미 수 · 글 · 참거짓 · 빈칸으로 바꿔 넘김(Frac → 수는 호출부 몫 — 이 파일은 엔진을 모름). */
export type CondFmtValue = number | string | boolean | null;
function testCondition(
condition: CondFmtCondition,
value: CondFmtValue,
evalFormula: (식: string) => boolean,
): boolean {
switch (condition.종류) {
case "비교": {
if (typeof value !== "number") return false;
switch (condition.연산) {
case "보다큼":
return value > condition.값;
case "보다작음":
return value < condition.값;
case "크거나같음":
return value >= condition.값;
case "작거나같음":
return value <= condition.값;
case "같음":
return value === condition.값;
case "다름":
return value !== condition.값;
}
break;
}
case "사이":
return typeof value === "number" && value >= condition.아래 && value <= condition.위;
case "글포함":
return typeof value === "string" && value.includes(condition.글);
case "수식":
return evalFormula(condition.식);
}
return false;
}
export interface CondFmtEnv {
value(r: number, c: number): CondFmtValue;
/** 없으면 「수식이 참」 조건은 늘 거짓 */
evalFormula?(formula: string, r: number, c: number): boolean;
}
/** 칸 하나에 먹일 조건부 서식 — 처음 맞는 규칙(우선순위 위) · 없으면 null. */
export function resolveCondFmtStyle(
sheet: Sheet,
r: number,
c: number,
env: CondFmtEnv,
): CondFmtStyle | null {
for (const rule of sheet.조건부서식 ?? []) {
if (!rule.범위.some((range) => inRange(range, r, c))) continue;
const value = env.value(r, c);
const hit = testCondition(rule.조건, value, (식) => env.evalFormula?.(식, r, c) ?? false);
if (hit) return rule.스타일;
}
return null;
}
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
export function remapCondFmtRanges(
rules: CondFmtRule[],
shift: (range: CellRange) => CellRange | null,
): CondFmtRule[] {
const out: CondFmtRule[] = [];
for (const rule of rules) {
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
if (moved.length) out.push({ ...rule, 범위: moved });
}
return out;
}
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
let seq = 0;
const newId = (): string => `cf${Date.now().toString(36)}${seq++}`;
export function addCondFmtRule(ctx: SpreadsheetContext, rule: Omit<CondFmtRule, "id">): void {
const sheet = ctx.sheet();
applySheet(ctx, {
...sheet,
조건부서식: [...(sheet.조건부서식 ?? []), { ...rule, id: newId() }],
});
}
export function removeCondFmtRule(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 조건부서식: (sheet.조건부서식 ?? []).filter((r) => r.id !== id) });
}
/** 우선순위 한 칸 옮김(엑셀 「규칙 관리」 위 · 아래 화살표와 같음). */
export function moveCondFmtRule(ctx: SpreadsheetContext, id: string, dir: -1 | 1): void {
const sheet = ctx.sheet();
const list = [...(sheet.조건부서식 ?? [])];
const idx = list.findIndex((r) => r.id === id);
const swapWith = idx + dir;
if (idx < 0 || swapWith < 0 || swapWith >= list.length) return;
[list[idx], list[swapWith]] = [list[swapWith], list[idx]];
applySheet(ctx, { ...sheet, 조건부서식: list });
}
const conditionText = (c: CondFmtCondition): string => {
switch (c.종류) {
case "비교":
return `${c.연산} ${c.값}`;
case "사이":
return `${c.아래} ~ ${c.위} 사이`;
case "글포함":
return `「${c.글}」 포함`;
case "수식":
return `수식 =${c.식}`;
}
};
/** 규칙 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. 잇는 법 머리 주석 참고. */
export function createCondFmtPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-condfmt" });
const list = el("ul", { className: "ss-condfmt__list" });
const compareOps: CondFmtCompareOp[] = [
"보다큼",
"보다작음",
"크거나같음",
"작거나같음",
"같음",
"다름",
];
const kindSelect = createSelectField({
label: "조건",
options: [
{ value: "비교", text: "값 비교" },
{ value: "사이", text: "사이" },
{ value: "글포함", text: "글 포함" },
{ value: "수식", text: "수식이 참" },
],
});
const opSelect = createSelectField({
label: "연산",
options: compareOps.map((op) => ({ value: op, text: op })),
});
const val1 = createInputField({ label: "값(또는 아래 · 글 · 수식)" });
const val2 = createInputField({ label: "위(사이일 때만)" });
const fill = createInputField({ label: "채움색", type: "text", placeholder: "#RRGGBB" });
const fontColor = createInputField({ label: "글자색", type: "text", placeholder: "#RRGGBB" });
const addBtn = createButton({ label: "규칙 더하기" });
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위;
if (!범위.length) return;
const 조건 = buildCondition(kindSelect.select.value, opSelect.select.value, val1, val2);
if (!조건) return;
const 스타일: CondFmtStyle = {};
if (fill.input.value) 스타일.채움 = fill.input.value;
if (fontColor.input.value) 스타일.글자색 = fontColor.input.value;
addCondFmtRule(ctx, { 범위: [...범위], 조건, 스타일 });
render();
});
function buildCondition(
kind: string,
op: string,
a: ReturnType<typeof createInputField>,
b: ReturnType<typeof createInputField>,
): CondFmtCondition | null {
if (kind === "비교") {
const n = Number(a.input.value);
return Number.isFinite(n) ? { 종류: "비교", 연산: op as CondFmtCompareOp, 값: n } : null;
}
if (kind === "사이") {
const 아래 = Number(a.input.value);
const 위 = Number(b.input.value);
return Number.isFinite(아래) && Number.isFinite(위) ? { 종류: "사이", 아래, 위 } : null;
}
if (kind === "글포함") return a.input.value ? { 종류: "글포함", 글: a.input.value } : null;
if (kind === "수식") return a.input.value ? { 종류: "수식", 식: a.input.value } : null;
return null;
}
function render(): void {
const rules = ctx.sheet().조건부서식 ?? [];
list.replaceChildren(
...rules.map((rule, idx) => {
const li = el("li", { className: "ss-condfmt__row" });
const swatch = el("span", { className: "ss-condfmt__swatch" });
if (rule.스타일.채움) swatch.style.background = rule.스타일.채움;
const label = el("span", {
text: `${rule.범위.map(rangeToA1).join(", ")} — ${conditionText(rule.조건)}`,
});
const up = createButton({ label: "↑", variant: "ghost" });
up.disabled = idx === 0;
up.addEventListener("click", () => {
moveCondFmtRule(ctx, rule.id, -1);
render();
});
const down = createButton({ label: "↓", variant: "ghost" });
down.disabled = idx === rules.length - 1;
down.addEventListener("click", () => {
moveCondFmtRule(ctx, rule.id, 1);
render();
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
removeCondFmtRule(ctx, rule.id);
render();
});
li.append(swatch, label, up, down, del);
return li;
}),
);
}
const form = el("div", {
className: "ss-condfmt__form",
children: [
kindSelect.root,
opSelect.root,
val1.root,
val2.root,
fill.root,
fontColor.root,
addBtn,
],
});
root.append(el("h4", { text: "조건부 서식 규칙" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
+47 -2
View File
@@ -2,19 +2,22 @@
* spreadsheet_extras.ts (주인 D)
* 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) ·
* 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
* 도구 모음을 누른 뒤 초점을 격자로 되돌림.
* 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 정렬(↑ ↓ 정렬…) · 자동 필터(단추 · Ctrl+Shift+L · Alt+↓).
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { parseRange } from "./spreadsheet_address";
import { parseClipboard } from "./spreadsheet_clipboard";
import { attachComments, getComment, setComment } from "./spreadsheet_comments";
import type { Editor } from "./spreadsheet_editor";
import { attachFilter, toggleFilter } from "./spreadsheet_filter";
import { mountFindPanel } from "./spreadsheet_find_panel";
import { createFormatPainter } from "./spreadsheet_format_painter";
import type { KeyHooks } from "./spreadsheet_keys";
import type { MouseHooks } from "./spreadsheet_mouse";
import { pasteSpecial, type PasteSpecialMode } from "./spreadsheet_paste_special";
import { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
@@ -22,6 +25,10 @@ const TEXT = {
pasteTitle: "골라 붙여넣기",
modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record<PasteSpecialMode, string>,
memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소",
sortAsc: "오름차순 정렬(활성 칸 열 기준)",
sortDesc: "내림차순 정렬(활성 칸 열 기준)",
sortDialog: "정렬 — 기준 여럿 · 머리 행",
filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)",
};
export interface Extras {
@@ -108,6 +115,33 @@ export function attachExtras(
});
if (!ctx.readOnly) ed.bar.prepend(brush);
// ── 정렬 · 필터(sub3) — 수식 입력줄 앞 데이터 단추 ───────────────────────
const filter = attachFilter(ctx);
let closeSort: (() => void) | null = null;
const dataButton = (text: string, title: string, run: () => void, dialog = false) => {
const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } });
b.addEventListener("click", () => {
run();
if (!dialog) back();
});
return b;
};
const data = [
dataButton("↑", TEXT.sortAsc, () => sortSelection(ctx, false)),
dataButton("↓", TEXT.sortDesc, () => sortSelection(ctx, true)),
dataButton(
"정렬…",
TEXT.sortDialog,
() => {
closeSort?.();
closeSort = openSortDialog(ctx);
},
true,
),
dataButton("필터", TEXT.filter, () => toggleFilter(ctx)),
];
if (!ctx.readOnly) brush.after(...data);
// ── 골라 붙여넣기 ─────────────────────────────────────────────────────────
let armed = false;
const picker = el("div", { className: "ss-pick", attrs: { hidden: "", title: TEXT.pasteTitle } });
@@ -167,6 +201,8 @@ export function attachExtras(
root: null,
refresh: () => showBrush(),
destroy() {
closeSort?.();
data.forEach((b) => b.remove());
ctx.root.removeEventListener("paste", onPaste, true);
toolbarRoot?.removeEventListener("click", onToolbarClick);
toolbarRoot?.removeEventListener("change", back);
@@ -181,6 +217,15 @@ export function attachExtras(
find: (replace) => find.open(replace),
pasteSpecial: () => (armed = true),
comment: editMemo,
filter: () => toggleFilter(ctx),
dropdown() {
const f = ctx.sheet().필터;
const g = f && parseRange(f.범위);
const { r, c } = ctx.selection.활성;
if (!g || r !== g.r0 || c < g.c0 || c > g.c1) return false;
filter.open(c);
return true;
},
escape() {
painter.clear();
sticky = false;
@@ -203,6 +248,6 @@ export function attachExtras(
put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침
},
},
parts: [find, comments, self],
parts: [find, comments, filter, self],
};
}
+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()));
},
},
};
+6 -2
View File
@@ -152,8 +152,12 @@
}
.aislo-grid--gridlines .aislo-cell {
border-right: 1px solid var(--aislo-grid-line);
border-bottom: 1px solid var(--aislo-grid-line);
border-right-style: solid;
border-bottom-style: solid;
border-right-width: var(--aislo-grid-gridline-w, 1px);
border-bottom-width: var(--aislo-grid-gridline-w, 1px);
border-right-color: var(--aislo-grid-line);
border-bottom-color: var(--aislo-grid-line);
}
.aislo-cell__text {
+89 -52
View File
@@ -49,10 +49,12 @@ import type {
SpreadsheetContext,
} from "./spreadsheet_view_types";
const HEAD_ROW_H = 24;
const HEAD_COL_W = 46;
const HEAD_ROW_H_BASE = 24;
const HEAD_COL_W_BASE = 46;
const RESIZE_HOTZONE = 4;
const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"];
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 4;
/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */
interface CellPool {
@@ -125,6 +127,17 @@ function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
return map;
}
/** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */
function scaledGeometry(base: LineGeometry, factor: number): LineGeometry {
if (factor === 1) return base;
return {
size: (i) => base.size(i) * factor,
offset: (i) => base.offset(i) * factor,
total: (i) => base.total(i) * factor,
indexAt: (px) => base.indexAt(px / factor),
};
}
interface CellDisplay {
text: string;
hAlign: HAlign;
@@ -207,8 +220,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const colHeadPools = { frozen: createPool(), scroll: createPool() };
const rowHeadPools = { frozen: createPool(), scroll: createPool() };
let colGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet());
let rowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet());
let baseColGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet());
let baseRowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet());
let colGeo: LineGeometry = baseColGeo;
let rowGeo: LineGeometry = baseRowGeo;
let extent = computeExtent(ctx.sheet());
let merges = buildMergeMap(ctx.sheet());
let frozenRows = 0;
@@ -216,6 +231,9 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
let frozenW = 0;
let frozenH = 0;
let lastSheetId = "";
let zoom = 1;
const headRowH = (): number => Math.round(HEAD_ROW_H_BASE * zoom);
const headColW = (): number => Math.round(HEAD_COL_W_BASE * zoom);
const resize = new ResizeObserver(() => layoutPanes());
resize.observe(root);
@@ -224,45 +242,48 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
function layoutPanes(): void {
const w = root.clientWidth;
const h = root.clientHeight;
root.style.fontFamily = ctx.book.기본?.글꼴 ?? "";
root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기 * zoom}pt` : `${10 * zoom}pt`;
root.style.setProperty("--aislo-grid-gridline-w", `${Math.max(1, Math.round(zoom))}px`);
frozenW = colGeo.total(frozenCols);
frozenH = rowGeo.total(frozenRows);
corner.style.width = `${HEAD_COL_W}px`;
corner.style.height = `${HEAD_ROW_H}px`;
colHeadFrozen.style.left = `${HEAD_COL_W}px`;
corner.style.width = `${headColW()}px`;
corner.style.height = `${headRowH()}px`;
colHeadFrozen.style.left = `${headColW()}px`;
colHeadFrozen.style.top = "0";
colHeadFrozen.style.width = `${frozenW}px`;
colHeadFrozen.style.height = `${HEAD_ROW_H}px`;
colHeadScroll.style.left = `${HEAD_COL_W + frozenW}px`;
colHeadFrozen.style.height = `${headRowH()}px`;
colHeadScroll.style.left = `${headColW() + frozenW}px`;
colHeadScroll.style.top = "0";
colHeadScroll.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
colHeadScroll.style.height = `${HEAD_ROW_H}px`;
colHeadScroll.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
colHeadScroll.style.height = `${headRowH()}px`;
rowHeadFrozen.style.left = "0";
rowHeadFrozen.style.top = `${HEAD_ROW_H}px`;
rowHeadFrozen.style.width = `${HEAD_COL_W}px`;
rowHeadFrozen.style.top = `${headRowH()}px`;
rowHeadFrozen.style.width = `${headColW()}px`;
rowHeadFrozen.style.height = `${frozenH}px`;
rowHeadScroll.style.left = "0";
rowHeadScroll.style.top = `${HEAD_ROW_H + frozenH}px`;
rowHeadScroll.style.width = `${HEAD_COL_W}px`;
rowHeadScroll.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
paneTL.style.left = `${HEAD_COL_W}px`;
paneTL.style.top = `${HEAD_ROW_H}px`;
rowHeadScroll.style.top = `${headRowH() + frozenH}px`;
rowHeadScroll.style.width = `${headColW()}px`;
rowHeadScroll.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
paneTL.style.left = `${headColW()}px`;
paneTL.style.top = `${headRowH()}px`;
paneTL.style.width = `${frozenW}px`;
paneTL.style.height = `${frozenH}px`;
paneTR.style.left = `${HEAD_COL_W + frozenW}px`;
paneTR.style.top = `${HEAD_ROW_H}px`;
paneTR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
paneTR.style.left = `${headColW() + frozenW}px`;
paneTR.style.top = `${headRowH()}px`;
paneTR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
paneTR.style.height = `${frozenH}px`;
paneBL.style.left = `${HEAD_COL_W}px`;
paneBL.style.top = `${HEAD_ROW_H + frozenH}px`;
paneBL.style.left = `${headColW()}px`;
paneBL.style.top = `${headRowH() + frozenH}px`;
paneBL.style.width = `${frozenW}px`;
paneBL.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
paneBR.style.left = `${HEAD_COL_W + frozenW}px`;
paneBR.style.top = `${HEAD_ROW_H + frozenH}px`;
paneBR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
paneBR.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
paneBL.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
paneBR.style.left = `${headColW() + frozenW}px`;
paneBR.style.top = `${headRowH() + frozenH}px`;
paneBR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
paneBR.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`;
overlay.style.left = `${HEAD_COL_W}px`;
overlay.style.top = `${HEAD_ROW_H}px`;
overlay.style.left = `${headColW()}px`;
overlay.style.top = `${headRowH()}px`;
overlay.style.right = "0";
overlay.style.bottom = "0";
renderVisible();
@@ -280,6 +301,22 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
}
paneBR.addEventListener("scroll", onScroll);
/** Ctrl+휠 확대 · 축소(10~400%) — 칸 · 머리 · 선 굵기가 같이 커짐(`headRowH`·`headColW`·`applyCellStyle` scale). */
function onWheel(ev: WheelEvent): void {
if (!ev.ctrlKey) return;
ev.preventDefault();
const before = zoom;
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015)));
if (next === before) return;
zoom = next;
colGeo = scaledGeometry(baseColGeo, zoom);
rowGeo = scaledGeometry(baseRowGeo, zoom);
paneBR.scrollLeft *= zoom / before;
paneBR.scrollTop *= zoom / before;
layoutPanes();
}
root.addEventListener("wheel", onWheel, { passive: false });
const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false;
function renderRowOfCells(
@@ -329,7 +366,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
extent.cols - 1,
);
const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식);
applyCellStyle(cellEl, style, borders, display.hAlign);
applyCellStyle(cellEl, style, borders, display.hAlign, zoom);
if (display.color) cellEl.style.color = display.color;
const comment = sheet.comments?.[addr];
setCellText(cellEl, display.text, display.title || comment || undefined);
@@ -420,8 +457,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
root.classList.toggle("aislo-grid--gridlines", gridlines());
const w = root.clientWidth;
const h = root.clientHeight;
const scrollW = Math.max(0, w - HEAD_COL_W - frozenW);
const scrollH = Math.max(0, h - HEAD_ROW_H - frozenH);
const scrollW = Math.max(0, w - headColW() - frozenW);
const scrollH = Math.max(0, h - headRowH() - frozenH);
const frozenColsVisible = visibleIndices(colGeo, 0, Math.max(0, frozenCols - 1));
const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(paneBR.scrollLeft));
const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW) + 1);
@@ -485,10 +522,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
function render(): void {
const sheet = ctx.sheet();
root.style.fontFamily = ctx.book.기본?.글꼴 ?? "";
root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기}pt` : "";
colGeo = buildColGeometry(ctx.book, sheet);
rowGeo = buildRowGeometry(ctx.book, sheet);
baseColGeo = buildColGeometry(ctx.book, sheet);
baseRowGeo = buildRowGeometry(ctx.book, sheet);
colGeo = scaledGeometry(baseColGeo, zoom);
rowGeo = scaledGeometry(baseRowGeo, zoom);
extent = computeExtent(sheet);
merges = buildMergeMap(sheet);
frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows);
@@ -534,8 +571,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const box = boxOf(r, c);
const inFrozenCol = c < frozenCols;
const inFrozenRow = r < frozenRows;
const x = HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft);
const y = HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop);
const x = headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft);
const y = headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop);
return { x, y, w: box.w, h: box.h };
}
@@ -559,8 +596,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const inFrozenCol = range.c0 < frozenCols;
const inFrozenRow = range.r0 < frozenRows;
const screen: CellBox = {
x: HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft),
y: HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop),
x: headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft),
y: headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop),
w: box.w,
h: box.h,
};
@@ -579,8 +616,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const inFrozenRow = sel.활성.r < frozenRows;
const handle = document.createElement("div");
handle.className = "aislo-grid__fill-handle";
handle.style.left = `${HEAD_COL_W + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`;
handle.style.top = `${HEAD_ROW_H + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`;
handle.style.left = `${headColW() + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`;
handle.style.top = `${headRowH() + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`;
selLayer!.append(handle);
}
@@ -606,8 +643,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const inFrozenRow = ref.범위.r0 < frozenRows;
const node = document.createElement("div");
node.className = "aislo-grid__ref-highlight";
node.style.left = `${HEAD_COL_W + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`;
node.style.top = `${HEAD_ROW_H + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`;
node.style.left = `${headColW() + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`;
node.style.top = `${headRowH() + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`;
node.style.width = `${merged.w}px`;
node.style.height = `${merged.h}px`;
node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length];
@@ -620,17 +657,17 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const x = clientX - bounds.left;
const y = clientY - bounds.top;
if (x < 0 || y < 0 || x > root.clientWidth || y > root.clientHeight) return null;
if (x < HEAD_COL_W && y < HEAD_ROW_H) return { kind: "corner", r: 0, c: 0 };
if (x < headColW() && y < headRowH()) return { kind: "corner", r: 0, c: 0 };
const contentX =
x < HEAD_COL_W ? -1 : x - HEAD_COL_W + (x - HEAD_COL_W > frozenW ? paneBR.scrollLeft : 0);
x < headColW() ? -1 : x - headColW() + (x - headColW() > frozenW ? paneBR.scrollLeft : 0);
const contentY =
y < HEAD_ROW_H ? -1 : y - HEAD_ROW_H + (y - HEAD_ROW_H > frozenH ? paneBR.scrollTop : 0);
if (y < HEAD_ROW_H) {
y < headRowH() ? -1 : y - headRowH() + (y - headRowH() > frozenH ? paneBR.scrollTop : 0);
if (y < headRowH()) {
const c = colGeo.indexAt(contentX);
const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE;
return { kind: edge ? "colEdge" : "colHead", r: 0, c };
}
if (x < HEAD_COL_W) {
if (x < headColW()) {
const r = rowGeo.indexAt(contentY);
const edge = Math.abs(rowGeo.offset(r + 1) - contentY) <= RESIZE_HOTZONE;
return { kind: edge ? "rowEdge" : "rowHead", r, c: 0 };
@@ -669,8 +706,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
}
function visibleRange(): CellRange {
const scrollW = Math.max(0, root.clientWidth - HEAD_COL_W - frozenW);
const scrollH = Math.max(0, root.clientHeight - HEAD_ROW_H - frozenH);
const scrollW = Math.max(0, root.clientWidth - headColW() - frozenW);
const scrollH = Math.max(0, root.clientHeight - headRowH() - frozenH);
return {
r0: rowGeo.indexAt(paneBR.scrollTop),
c0: colGeo.indexAt(paneBR.scrollLeft),
@@ -41,6 +41,10 @@ export interface KeyHooks {
comment?(): void;
/** 고름 중 Esc(서식 붓 끄기 등) */
escape?(): void;
/** Ctrl+Shift+L 필터 켜기 · 끄기 */
filter?(): void;
/** Alt+↓ — 필터 머리 칸이면 값 목록을 열고 true */
dropdown?(): boolean;
}
export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void {
@@ -97,6 +101,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
const sheet = ctx.sheet();
const sel = ctx.selection;
const arrow = ARROWS[e.key];
if (e.altKey && e.key === "ArrowDown" && hooks.dropdown?.()) return true;
if (arrow && !e.altKey) {
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
return true;
@@ -135,6 +140,10 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
if (!hooks.find) return false;
hooks.find(k === "h");
return true;
case "l":
if (!e.shiftKey || !hooks.filter) return false;
hooks.filter();
return true;
case "v":
if (e.shiftKey) hooks.pasteSpecial?.();
return false; // paste 사건은 그대로 — E 클립보드 · 골라 붙여넣기가 받음
+47 -4
View File
@@ -1,16 +1,20 @@
/* =============================================================================
* spreadsheet_menu.ts (주인 E)
* 우클릭 메뉴 — 칸 · 행 머리 · 열 머리마다: 잘라내기 · 복사 · (붙여넣기 = 「Ctrl+V 를 쓸 것」 안내) ·
* 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기.
* 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기 · 메모 삽입/편집/삭제.
* `ui_template_context_menu.ts` 재사용 — 우클릭 밖 자리는 그냥 눌러 선택부터 옮김.
* 메모는 `spreadsheet_comments.ts`(sub1 · 브레인 답 2026-09-27)의 `setComment` · `deleteComment` ·
* `getComment` 를 부름 — 안에서 「시트통째」 명령 · 되돌리기 짝까지 만듦. 글 입력 창은 이 파일이
* 직접 띄움(작은 오버레이 · `showConfirmDialog` 와 같은 결의 자체 모달 · native prompt 안 씀).
*
* 이음(D `spreadsheet.ts` 잇는 자리) — `attachMenu(ctx)` 한 번 부름(반환 `root` = 메뉴 껍데기 ·
* 안 붙여도 됨 · `ctx.root` 안에 스스로 넣음). `ctx.grid.hitTest` 가 준비된 뒤(C 격자 붙은 뒤) 불러야 함.
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { showToast } from "@ui/ui_template_elements";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import { deleteComment, getComment, setComment } from "./spreadsheet_comments";
import { st } from "./spreadsheet_text";
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
@@ -36,7 +40,39 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
document.execCommand(kind);
}
function cellMenuItems(range: CellRange): MapContextMenuItem[] {
/** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는
* 문제는 `showConfirmDialog` 머리 주석과 같은 까닭). 저장을 누르면 `onSave`. */
function openCommentEditor(initial: string, onSave: (text: string) => void): void {
const overlay = el("div", { className: "ss-comment-editor" });
const panel = el("div", { className: "ss-comment-editor__panel" });
const textarea = el("textarea", {
className: "ss-comment-editor__input",
}) as HTMLTextAreaElement;
textarea.value = initial;
const done = (): void => overlay.remove();
const actions = el("div", {
className: "ss-comment-editor__actions",
children: [
createButton({ label: "취소", variant: "ghost", onClick: done }),
createButton({
label: "저장",
variant: "filled",
onClick: () => {
onSave(textarea.value);
done();
},
}),
],
});
panel.append(textarea, actions);
overlay.append(panel);
document.body.append(overlay);
textarea.focus();
textarea.select();
}
function cellMenuItems(range: CellRange, r: number, c: number): MapContextMenuItem[] {
const note = getComment(ctx.sheet(), r, c);
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
@@ -58,6 +94,13 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
: { 종류: "병합", 시트: ctx.selection.시트, 범위: range },
),
],
[
note ? st("MenuCommentEdit") : st("MenuCommentInsert"),
() => openCommentEditor(note ?? "", (text) => setComment(ctx, r, c, text)),
],
...(note
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
: []),
];
}
@@ -196,7 +239,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
});
}
const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c);
menu.open(x, y, cellMenuItems(active));
menu.open(x, y, cellMenuItems(active, hit.r, hit.c));
}
}
+201
View File
@@ -0,0 +1,201 @@
/* =============================================================================
* spreadsheet_names.ts (3단계 · 주인 sub_laptop_4)
* 이름 정의 — 범위에 이름을 붙이고 수식에서 `=단가*수량` 처럼 씀. 저장 자리 `Sheet.이름정의`(0 계약에
* 방금 더함) — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트 참조는 3단계 밖).
*
* 수식 나무는 이미 `{type:"name", name}` 갈래를 가짐(계약 `spreadsheet_types.ts` 3절 · 정의 없으면
* `#NAME?`). 이 파일은 이름을 저장 · 관리만 하고 실제로 식을 풀 때 이름을 찾는 일은 A 몫.
*
* 잇는 법(A · D · sub7 — 지금은 하나도 안 이어짐):
* 1) `spreadsheet_eval.ts` 가 `{type:"name"}` 나무를 만나면 `resolveName(book, 식이든시트, 이름)` 을
* 불러 범위를 얻고, 범위가 칸 하나면 그 칸 값 · 여러 칸이면 `RangeValue` 로 바꿔 씀(없으면 지금처럼 `#NAME?`).
* 2) 편집기 이름 상자(주소 상자)에서 범위를 고른 채 이름을 치고 Enter 하면 `defineName(ctx, 이름, 범위)`.
* 3) 도구 모음 · 메뉴에 [이름 관리] 항목을 두고 `createNameManagerPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import type { CellRange, NamedRange, Sheet, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { NamedRange };
const CELL_LIKE = /^[A-Za-z]{1,3}\d+$/;
const RESERVED = /^(TRUE|FALSE)$/i;
const NAME_RE = /^[\p{L}_][\p{L}\p{N}_.]*$/u;
/** 이름 규칙 — 글자 · 밑줄로 시작 · 칸 주소처럼 안 보임 · 겹치지 않음. 어기면 까닭 글 · 되면 null. */
export function invalidNameReason(name: string, existing: string[]): string | null {
if (!name) return "이름이 비었음";
if (!NAME_RE.test(name)) return "글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)";
if (CELL_LIKE.test(name)) return "칸 주소처럼 보이는 이름은 못 씀";
if (RESERVED.test(name)) return "TRUE · FALSE 는 못 씀";
if (existing.some((n) => n.toLowerCase() === name.toLowerCase())) return "이미 있는 이름";
return null;
}
export function listNames(book: Workbook): { 이름: string; 시트: string; 범위: CellRange }[] {
return book.시트.flatMap((sheet) =>
(sheet.이름정의 ?? []).map((n) => ({ 이름: n.이름, 시트: sheet.id, 범위: n.범위 })),
);
}
/** 식이 든 시트의 이름부터 찾고(자기 시트 우선) · 없으면 다른 시트 이름도(느슨한 전역 대체). */
export function resolveName(
book: Workbook,
sheetId: string,
name: string,
): { 시트: string; 범위: CellRange } | null {
const lower = name.toLowerCase();
const own = book.시트.find((s) => s.id === sheetId);
const local = own?.이름정의?.find((n) => n.이름.toLowerCase() === lower);
if (local) return { 시트: sheetId, 범위: local.범위 };
for (const sheet of book.시트) {
const found = sheet.이름정의?.find((n) => n.이름.toLowerCase() === lower);
if (found) return { 시트: sheet.id, 범위: found.범위 };
}
return null;
}
function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } });
}
/** 지금 활성 시트에 이름을 더함 — 규칙 어기면 아무 일도 안 하고 까닭을 돌려줌(호출부가 토스트로). */
export function defineName(ctx: SpreadsheetContext, name: string, 범위: CellRange): string | null {
const reason = invalidNameReason(
name,
listNames(ctx.book).map((n) => n.이름),
);
if (reason) return reason;
const sheet = ctx.sheet();
applySheet(ctx, sheet.id, {
...sheet,
이름정의: [...(sheet.이름정의 ?? []), { 이름: name, 범위 }],
});
return null;
}
/** 이름을 지움 — 그 이름을 쓰던 식은 그대로 남아 다음 풀이 때 `#NAME?`(계약대로). */
export function deleteName(ctx: SpreadsheetContext, name: string): void {
const lower = name.toLowerCase();
for (const sheet of ctx.book.시트) {
if (!sheet.이름정의?.some((n) => n.이름.toLowerCase() === lower)) continue;
applySheet(ctx, sheet.id, {
...sheet,
이름정의: sheet.이름정의!.filter((n) => n.이름.toLowerCase() !== lower),
});
return;
}
}
/** 이름을 바꿈 — 정의뿐 아니라 모든 시트 식 글 속 이름 글자도 같이 바꿈(시트이름 바꾸기와 같은 규칙). */
export function renameName(
ctx: SpreadsheetContext,
oldName: string,
newName: string,
): string | null {
const reason = invalidNameReason(
newName,
listNames(ctx.book)
.map((n) => n.이름)
.filter((n) => n.toLowerCase() !== oldName.toLowerCase()),
);
if (reason) return reason;
const commands = ctx.book.시트
.map((sheet) => renamedSheet(sheet, oldName, newName))
.filter((s): s is Sheet => s !== null)
.map((sheet) => ({ 종류: "시트통째" as const, 시트: sheet }));
if (!commands.length) return null;
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
return null;
}
function renamedSheet(sheet: Sheet, oldName: string, newName: string): Sheet | null {
// 한글 이름은 `\w` 밖이라 `\b` 가 안 먹음 — 글자 · 숫자 · 밑줄이 아닌 자리를 경계로 직접 잡음.
const re = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeReg(oldName)}(?![\\p{L}\\p{N}_])`, "giu");
let changed = false;
const 칸: Sheet["칸"] = { ...sheet.칸 };
for (const [key, cell] of Object.entries(칸)) {
if (cell.식 !== undefined && re.test(cell.식)) {
re.lastIndex = 0;
칸[key] = { ...cell, 식: cell.식.replace(re, newName) };
changed = true;
}
}
const 이름정의 = sheet.이름정의?.map((n) =>
n.이름.toLowerCase() === oldName.toLowerCase() ? { ...n, 이름: newName } : n,
);
if (이름정의 && JSON.stringify(이름정의) !== JSON.stringify(sheet.이름정의)) changed = true;
return changed ? { ...sheet, 칸, ...(이름정의 ? { 이름정의 } : {}) } : null;
}
function escapeReg(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** 이름 관리 창 — 통합문서 전체 이름 목록 + 고른 범위에 새 이름. */
export function createNameManagerPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-names" });
const list = el("ul", { className: "ss-names__list" });
const nameInput = createInputField({ label: "새 이름", placeholder: "단가" });
const addBtn = createButton({ label: "이름 더하기" });
let error: HTMLElement | null = null;
const showError = (msg: string | null): void => {
error?.remove();
error = null;
if (!msg) return;
error = el("p", { text: msg });
error.style.color = "var(--color-danger, red)";
root.append(error);
};
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위[0];
if (!범위) return;
const reason = defineName(ctx, nameInput.input.value.trim(), 범위);
showError(reason);
if (!reason) {
nameInput.input.value = "";
render();
}
});
function render(): void {
list.replaceChildren(
...listNames(ctx.book).map(({ 이름, 시트, 범위 }) => {
const li = el("li", { className: "ss-names__row" });
const label = el("span", { text: `${이름} = ${시트}!${rangeToA1(범위)}` });
const rename = createButton({ label: "이름 바꾸기", variant: "ghost" });
rename.addEventListener("click", () => {
const next = window.prompt("새 이름", 이름);
if (!next || next === 이름) return;
showError(renameName(ctx, 이름, next));
render();
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
deleteName(ctx, 이름);
render();
});
li.append(label, rename, del);
return li;
}),
);
}
const form = el("div", { className: "ss-names__form", children: [nameInput.root, addBtn] });
root.append(el("h4", { text: "이름 관리" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -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;
@@ -51,7 +51,11 @@ export function mergeAt(sheet: Sheet, r: number, c: number): CellRange {
return merges(sheet).find((m) => inRange(m, r, c)) ?? { r0: r, c0: c, r1: r, c1: c };
}
export const rowHidden = (sheet: Sheet, r: number): boolean => !!sheet.행?.[String(r + 1)]?.숨김;
/** 숨긴 행 · 필터로 걸러진 행(방향키 · Enter 가 건너뜀 — 엑셀) */
export function rowHidden(sheet: Sheet, r: number): boolean {
const row = sheet.행?.[String(r + 1)];
return !!(row?.숨김 || row?.걸러짐);
}
export function colHidden(sheet: Sheet, c: number): boolean {
const cols = sheet.열;
@@ -0,0 +1,87 @@
/* =============================================================================
* spreadsheet_stage3.css (주인 sub_laptop_4)
* 조건부 서식 · 데이터 유효성 · 이름 정의 관리 창 — 화면 스타일 한 벌. 색 · 간격은 테마 토큰만.
* ========================================================================== */
.ss-condfmt,
.ss-validation,
.ss-names {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
min-width: 280px;
}
.ss-condfmt__list,
.ss-validation__list,
.ss-names__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.ss-condfmt__row,
.ss-validation__row,
.ss-names__row {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.85rem;
}
.ss-condfmt__row > span:first-of-type,
.ss-validation__row > span:first-of-type,
.ss-names__row > span:first-of-type {
flex: 1;
}
.ss-condfmt__swatch {
display: inline-block;
width: 14px;
height: 14px;
border: 1px solid var(--color-border);
border-radius: 2px;
flex-shrink: 0;
}
.ss-condfmt__form,
.ss-validation__form,
.ss-names__form {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
align-items: flex-end;
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-8);
}
.ss-validation__dropdown {
position: absolute;
z-index: 30;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
max-height: 160px;
overflow-y: auto;
}
.ss-validation__dropdown-item {
padding: 4px 8px;
cursor: pointer;
font-size: 0.85rem;
}
.ss-validation__dropdown-item:hover {
background: var(--color-mist-violet);
}
+12 -2
View File
@@ -1,10 +1,17 @@
/* =============================================================================
* spreadsheet_text.ts (주인 E)
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 글자 — [한국어, 영어].
* `ui_template_sheet_text.ts` 와 같은 방식(`st(key)`). 0 계약 밖(E 만 씀).
* ⚠ `@ui/ui_template_locale` 를 안 씀 — `currentLanguageIndex` 하나만 필요해도 그 파일이
* 앱 전체 사전(a · b1~4 · common · m1 · m2, 압축 뒤 약 45 KB)을 통째로 끌고 와 화면 예산(28 KB)을
* 넘김(2026-09-27 브레인 지시). 자기 언어 상태를 따로 둠 — `setSpreadsheetLanguage` 로만 바꿈.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
let currentLanguageIndex = 0;
/** 앱 전역 언어 토글과 맞추고 싶으면 D(`spreadsheet.ts`) 가 부름 — 안 부르면 한국어 기본. */
export function setSpreadsheetLanguage(index: 0 | 1): void {
currentLanguageIndex = index;
}
const TEXT = {
Undo: ["되돌리기", "Undo"],
@@ -78,6 +85,9 @@ const TEXT = {
MenuClearFormat: ["서식 지우기", "Clear format"],
MenuMerge: ["병합", "Merge"],
MenuUnmerge: ["병합 해제", "Unmerge"],
MenuCommentInsert: ["메모 삽입", "Insert comment"],
MenuCommentEdit: ["메모 편집", "Edit comment"],
MenuCommentDelete: ["메모 삭제", "Delete comment"],
ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"],
} as const;
@@ -215,3 +215,43 @@
margin: 2px 4px;
border-top: 1px solid var(--color-border);
}
/* ── 메모 편집 창(칸 우클릭 [메모 삽입 · 편집]) ──────────────────────────────── */
.ss-comment-editor {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 35%);
}
.ss-comment-editor__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 280px;
padding: var(--spacing-16);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-comment-editor__input {
min-height: 80px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
resize: vertical;
}
.ss-comment-editor__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
+59 -3
View File
@@ -1,5 +1,4 @@
/* =============================================================================
* spreadsheet_types.ts
/* ====================================================================== * spreadsheet_types.ts
* 산출근거 스프레드시트 계약 — 저장 문서 · 칸 · 서식 · 수식 나무 · 값 · 명령 · 계산 엔진 모양.
* 근거 `tmp/M02_분석/9_엑셀검토.md` · PLAN 11-8. 이 파일 · `spreadsheet_view_types.ts` · 견본 JSON 은
* 0 계약 주인만 고침 — 다른 창은 브레인에게 고침을 청함. 타입만 둠(서버 Node · 시험이 그대로 씀).
@@ -14,7 +13,7 @@
* · xlsx 가져오기 · 내보내기 · 인쇄 · PDF 는 만들지 않음(단추도 없음). xlsx 는 시험(F)만 읽음.
*
* ── 크기 예산 (vite build · 압축(gzip) 뒤 · M02 산출근거를 열 때만 받는 조각) ──────
* 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험).
* 엔진 A+B 32 KB · 화면 C+D+E 28 KB · css 4 KB · 합 64 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험).
*
* ── 파일 · 주인 · 예산 줄 수 ─────────────────────────────────────────────────
* 0 spreadsheet_types.ts 이 계약(타입) ~450
@@ -53,6 +52,10 @@
* E spreadsheet_menu.ts 우클릭 메뉴 ~300
* E spreadsheet_clipboard.ts 복사 · 잘라내기 · 붙여넣기(TSV · HTML) ~550
* E spreadsheet_toolbar.css ~150
* 3 spreadsheet_condfmt.ts 조건부 서식 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~260
* 3 spreadsheet_validation.ts 데이터 유효성 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~230
* 3 spreadsheet_names.ts 이름 정의 · 이름 관리 창(주인 sub_laptop_4 · 안 이어짐) ~220
* 3 spreadsheet_stage3.css 위 셋 화면 스타일 한 벌 ~90
* F common_util/common_util_spreadsheet_node.ts Node 진입(재계산 · 폴더 밖) ~40
* F common_util/common_util_spreadsheet.py 파이썬 껍데기(common_util_node_bundle) ~60
* F package.json `build:spreadsheet` · M02 `basis` 종류(Store · Layers) · 옛 산출근거 옮김 ·
@@ -162,6 +165,13 @@ export interface Sheet {
comments?: Record<string, string>;
/** 자동 필터(시트에 하나) */
필터?: AutoFilter;
/** 조건부 서식 — 배열 앞쪽이 우선순위 위(`spreadsheet_condfmt.ts`). */
조건부서식?: CondFmtRule[];
/** 데이터 유효성(`spreadsheet_validation.ts`). */
유효성?: DataValidationRule[];
/** 이 시트에서 정의한 이름 — 범위는 이 시트 안(`spreadsheet_names.ts`). 식 나무의 `{type:"name"}` 는
* A 의 eval 이 `resolveName` 으로 풀어야 함(지금 안 이어짐 — 8-3 절 참고). */
이름정의?: NamedRange[];
}
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
@@ -452,3 +462,49 @@ export interface ClipBlock {
원점: CellAddress | null;
출처: "aislo" | "google" | "excel" | "text";
}
// ═════════════════════════════════════════════════════════════════════════════
// 9. 3단계 — 조건부 서식 · 데이터 유효성 · 이름 정의 (Sheet 확장 · sub_laptop_4 계약 더함 · 안 이어짐)
// ═════════════════════════════════════════════════════════════════════════════
export type CondFmtCompareOp =
"보다큼" | "보다작음" | "크거나같음" | "작거나같음" | "같음" | "다름";
export type CondFmtCondition =
| { 종류: "비교"; 연산: CondFmtCompareOp; 값: number }
| { 종류: "사이"; 아래: number; 위: number }
| { 종류: "글포함"; 글: string }
/** 임의 수식 글 — A 의 파서 · eval 이 있어야 풀림(`spreadsheet_condfmt.ts` 머리 「잇는 법」). */
| { 종류: "수식"; 식: string };
/** 조건부 서식이 먹이는 스타일 — `서식` 표 번호를 안 씀(규칙마다 직접 가짐). */
export interface CondFmtStyle {
채움?: string;
글자색?: string;
굵게?: boolean;
}
export interface CondFmtRule {
id: string;
범위: CellRange[];
조건: CondFmtCondition;
스타일: CondFmtStyle;
}
export type DataValidationKind =
{ 종류: "목록"; 값들: string[] } | { 종류: "수범위"; 아래?: number; 위?: number };
export interface DataValidationRule {
id: string;
범위: CellRange[];
규칙: DataValidationKind;
/** 어긴 값을 넣으려 하면 막을지 · 경고만 하고 넣을지(둘 다 D 편집기 잇기 몫 · 지금 안 이어짐). */
어기면: "막음" | "경고";
메시지?: string;
}
/** 이름 정의 — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트를 가리키는 이름은 3단계 밖). */
export interface NamedRange {
이름: string;
범위: CellRange;
}
@@ -0,0 +1,219 @@
/* =============================================================================
* spreadsheet_validation.ts (3단계 · 주인 sub_laptop_4)
* 데이터 유효성 — 목록(드롭다운) · 수 범위. 어기면 막거나(칸에 못 들어감) 경고만(그래도 들어감).
* 저장 자리 `Sheet.유효성`(0 계약에 방금 더함). 규칙은 범위 하나에 하나만 먹임(엑셀과 같이 — 겹치면
* 배열 앞쪽이 이김).
*
* 잇는 법(D · sub7 — 지금은 하나도 안 이어짐):
* 1) 편집기(`spreadsheet_editor.ts`)가 칸 편집을 커밋하기 전에 `checkValue(rule, candidate)` 를
* 불러 `어기면 === "막음"` 이면 `dispatch` 를 안 부르고(입력 취소) 메시지를 토스트로 · `"경고"` 면
* 토스트만 띄우고 그대로 진행.
* 2) 칸 편집이 시작될 때(`editor.begin`) `findValidation(sheet, r, c)` 가 「목록」 규칙이면
* `createValidationDropdown(ctx, r, c, rule)` 을 칸 옆에 띄우고 고르면 그 값을 칸에 넣음.
* 3) 행 · 열 넣기 · 지우기 뒤 `remapValidationRanges` 를 그 시트 `유효성` 에 적용(조건부 서식과 같은
* 자리 · A 몫 · 지금 안 이어짐).
* 4) 도구 모음 · 메뉴에 [데이터 유효성] 항목을 두고 `createValidationPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { inRange, rangeToA1 } from "./spreadsheet_address";
import type {
CellInput,
CellRange,
DataValidationKind,
DataValidationRule,
Sheet,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { DataValidationKind, DataValidationRule };
/** 칸이 든 범위를 덮는 첫 규칙(배열 앞쪽 우선) — 없으면 undefined. */
export function findValidation(sheet: Sheet, r: number, c: number): DataValidationRule | undefined {
return (sheet.유효성 ?? []).find((rule) => rule.범위.some((range) => inRange(range, r, c)));
}
export interface ValidationCheck {
ok: boolean;
어기면?: "막음" | "경고";
메시지: string;
}
function defaultMessage(kind: DataValidationKind): string {
if (kind.종류 === "목록") return `목록 안 값만 됨(${kind.값들.join(", ")})`;
const 아래 = kind.아래 ?? -Infinity;
const 위 = kind.위 ?? Infinity;
return `${아래 === -Infinity ? "" : 아래}${위 === Infinity ? " 이상" : ` ~ ${위}`} 수만 됨`;
}
function matchesKind(kind: DataValidationKind, candidate: CellInput): boolean {
if (kind.종류 === "목록")
return typeof candidate !== "boolean" && kind.값들.includes(String(candidate));
if (typeof candidate !== "number") return false;
if (kind.아래 !== undefined && candidate < kind.아래) return false;
if (kind.위 !== undefined && candidate > kind.위) return false;
return true;
}
/** 후보 값이 규칙을 지키는지 — 어기면 `어기면`(막음 · 경고)과 안내 글을 돌려줌. */
export function checkValue(rule: DataValidationRule, candidate: CellInput): ValidationCheck {
if (matchesKind(rule.규칙, candidate)) return { ok: true, 메시지: "" };
return { ok: false, 어기면: rule.어기면, 메시지: rule.메시지 || defaultMessage(rule.규칙) };
}
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
export function remapValidationRanges(
rules: DataValidationRule[],
shift: (range: CellRange) => CellRange | null,
): DataValidationRule[] {
const out: DataValidationRule[] = [];
for (const rule of rules) {
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
if (moved.length) out.push({ ...rule, 범위: moved });
}
return out;
}
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
let seq = 0;
const newId = (): string => `dv${Date.now().toString(36)}${seq++}`;
export function addValidationRule(
ctx: SpreadsheetContext,
rule: Omit<DataValidationRule, "id">,
): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 유효성: [...(sheet.유효성 ?? []), { ...rule, id: newId() }] });
}
export function removeValidationRule(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 유효성: (sheet.유효성 ?? []).filter((r) => r.id !== id) });
}
/** 목록 규칙일 때 칸 옆에 띄울 작은 드롭다운(고르면 `onPick` 이 불림 — 실제 `dispatch` 는 부른 쪽 몫). */
export function createValidationDropdown(
ctx: SpreadsheetContext,
r: number,
c: number,
rule: DataValidationRule,
onPick: (value: string) => void,
): HTMLElement | null {
if (rule.규칙.종류 !== "목록") return null;
const box = ctx.grid.cellBox(r, c);
const dropdown = el("div", { className: "ss-validation__dropdown" });
dropdown.style.left = `${box.x}px`;
dropdown.style.top = `${box.y + box.h}px`;
dropdown.style.minWidth = `${box.w}px`;
dropdown.replaceChildren(
...rule.규칙.값들.map((value) => {
const item = el("div", { className: "ss-validation__dropdown-item", text: value });
item.addEventListener("click", () => onPick(value));
return item;
}),
);
return dropdown;
}
const kindText = (k: DataValidationKind): string =>
k.종류 === "목록" ? `목록: ${k.값들.join(", ")}` : `수범위: ${k.아래 ?? "−∞"} ~ ${k.위 ?? "+∞"}`;
/** 유효성 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. */
export function createValidationPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-validation" });
const list = el("ul", { className: "ss-validation__list" });
const kindSelect = createSelectField({
label: "규칙",
options: [
{ value: "목록", text: "목록(드롭다운)" },
{ value: "수범위", text: "수 범위" },
],
});
const listValues = createInputField({ label: "목록 값(쉼표로 나눔)" });
const min = createInputField({ label: "아래(수범위)" });
const max = createInputField({ label: "위(수범위)" });
const onBreak = createSelectField({
label: "어기면",
options: [
{ value: "막음", text: "막음" },
{ value: "경고", text: "경고" },
],
});
const message = createInputField({ label: "안내 글(생략 가능)" });
const addBtn = createButton({ label: "규칙 더하기" });
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위;
if (!범위.length) return;
const 규칙 = build규칙();
if (!규칙) return;
addValidationRule(ctx, {
범위: [...범위],
규칙,
어기면: onBreak.select.value as "막음" | "경고",
메시지: message.input.value || undefined,
});
render();
});
function build규칙(): DataValidationKind | null {
if (kindSelect.select.value === "목록") {
const 값들 = listValues.input.value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return 값들.length ? { 종류: "목록", 값들 } : null;
}
const 아래 = min.input.value ? Number(min.input.value) : undefined;
const 위 = max.input.value ? Number(max.input.value) : undefined;
return 아래 === undefined && 위 === undefined ? null : { 종류: "수범위", 아래, 위 };
}
function render(): void {
const rules = ctx.sheet().유효성 ?? [];
list.replaceChildren(
...rules.map((rule) => {
const li = el("li", { className: "ss-validation__row" });
const label = el("span", {
text: `${rule.범위.map(rangeToA1).join(", ")} — ${kindText(rule.규칙)}(${rule.어기면})`,
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
removeValidationRule(ctx, rule.id);
render();
});
li.append(label, del);
return li;
}),
);
}
const form = el("div", {
className: "ss-validation__form",
children: [
kindSelect.root,
listValues.root,
min.root,
max.root,
onBreak.root,
message.root,
addBtn,
],
});
root.append(el("h4", { text: "데이터 유효성 규칙" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -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)",
@@ -1,6 +1,10 @@
// 스프레드시트 크기 재기 — vite 로 줄여 묶은 뒤 gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름.
// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}}
// 앱 번들에 이미 있는 `@ui/ui_template_elements*` 는 빼고 잼(스프레드시트가 새로 더하는 몫만).
// 스프레드시트 크기 재기 — gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름.
// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}, "static": [...]}
// engine = 엔진 파일만 줄여 묶은 크기 · total = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각
// (스프레드시트 조각 + 끌어오는 공용 조각 + css · 첫 화면 `index.html` 조각은 뺌).
// static = 스프레드시트 조각을 정적으로 끌어오는 조각(비어야 함 — 늦게만 받음).
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { gzipSync } from "node:zlib";
@@ -25,7 +29,7 @@ const ENGINE = [
"spreadsheet_fill",
];
async function measure(entries) {
async function engineSize() {
const out = await build({
configFile: false,
logLevel: "silent",
@@ -35,22 +39,60 @@ async function measure(entries) {
write: false,
minify: true,
target: "es2022",
cssCodeSplit: false,
lib: { entry: entries, formats: ["es"] },
rollupOptions: { external: [/ui_template_elements/] },
lib: { entry: ENGINE.map((name) => path.join(dir, `${name}.ts`)), formats: ["es"] },
},
});
const outputs = (Array.isArray(out) ? out : [out]).flatMap((o) => o.output);
const size = { js: 0, css: 0 };
for (const o of outputs) {
const body = o.type === "chunk" ? o.code : o.source;
const gz = gzipSync(typeof body === "string" ? body : Buffer.from(body)).length;
if (o.fileName.endsWith(".css")) size.css += gz;
else if (o.fileName.endsWith(".js")) size.js += gz;
let js = 0;
for (const o of (Array.isArray(out) ? out : [out]).flatMap((o) => o.output)) {
if (o.type === "chunk") js += gzipSync(o.code).length;
}
return size;
return { js, css: 0 };
}
const engine = await measure(ENGINE.map((name) => path.join(dir, `${name}.ts`)));
const total = await measure([path.join(dir, "spreadsheet.ts")]);
console.log(JSON.stringify({ engine, total }));
async function appSize() {
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-sheet-size-"));
try {
await build({
configLoader: "runner",
configFile: path.join(root, "vite.config.ts"),
logLevel: "silent",
build: { outDir, emptyOutDir: true, manifest: true },
});
const manifest = JSON.parse(fs.readFileSync(path.join(outDir, ".vite/manifest.json"), "utf8"));
const key = Object.keys(manifest).find((k) => k.endsWith("spreadsheet/spreadsheet.ts"));
const gz = (file) => gzipSync(fs.readFileSync(path.join(outDir, file))).length;
// M02 가 이미 받은 조각은 새 몫이 아님 — 첫 화면 · M02 페이지 · M02 표(ui_template_sheet 등)
// 의 정적 import 사슬. 산출근거 카드는 집계표를 연 뒤에만 열림.
const first = new Set();
const mark = (k) => {
if (first.has(k)) return;
first.add(k);
for (const i of manifest[k].imports ?? []) mark(i);
};
const page = Object.keys(manifest).find((k) => k.endsWith("M02_MasterTemplete_UI_Page.ts"));
mark("index.html");
mark(page);
for (const d of manifest[page].dynamicImports ?? []) if (d !== key) mark(d);
const size = { js: 0, css: 0 };
const seen = new Set();
const walk = (k) => {
if (seen.has(k) || first.has(k)) return;
seen.add(k);
const e = manifest[k];
size.js += gz(e.file);
for (const c of e.css ?? []) size.css += gz(c);
for (const i of e.imports ?? []) walk(i); // 그 밖 공용 조각은 셈(넉넉히)
};
walk(key);
const statics = Object.entries(manifest)
.filter(([, v]) => (v.imports ?? []).includes(key))
.map(([k]) => k);
return { total: size, static: statics };
} finally {
fs.rmSync(outDir, { recursive: true, force: true });
}
}
const engine = await engineSize();
const { total, static: statics } = await appSize();
console.log(JSON.stringify({ engine, total, static: statics }));
@@ -1,7 +1,8 @@
"""F — 스프레드시트 크기 예산 · M02 만 늦게 불러옴(계약 `spreadsheet_types.ts` 머리).
예산(압축 gzip 뒤) — 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB.
엔진은 파일마다 따로 묶어 더하므로 실제보다 조금 크게 잼(넘치면 넘친 것).
예산(압축 gzip 뒤) — 엔진 A+B 32 KB · 화면 C+D+E 28 KB · css 4 KB · 합 64 KB.
엔진 = 엔진 파일만 따로 묶은 크기 · 합 = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각
(공용 조각까지 넉넉히 셈) · 화면 = 합 − 엔진.
"""
import json
@@ -15,7 +16,7 @@ import pytest
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = Path(__file__).with_name("spreadsheet_size.mjs")
KB = 1024
BUDGET = {"engine": 22 * KB, "view": 28 * KB, "css": 4 * KB, "total": 54 * KB}
BUDGET = {"engine": 32 * KB, "view": 28 * KB, "css": 4 * KB, "total": 64 * KB}
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
@@ -36,6 +37,7 @@ def test_크기_예산():
print(
f"gzip — 엔진 {engine} · 화면 {view} · css {total['css']} · 합 {total['js'] + total['css']}"
)
assert size["static"] == [], f"스프레드시트 조각을 정적으로 끌어옴 — {size['static']}"
assert engine <= BUDGET["engine"], f"엔진 {engine} B > {BUDGET['engine']}"
assert view <= BUDGET["view"], f"화면 {view} B > {BUDGET['view']}"
assert total["css"] <= BUDGET["css"], f"css {total['css']} B > {BUDGET['css']}"
@@ -0,0 +1,157 @@
/* =============================================================================
* test_stage3_condfmt.ts — spreadsheet_condfmt 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
addCondFmtRule,
moveCondFmtRule,
remapCondFmtRanges,
removeCondFmtRule,
resolveCondFmtStyle,
} from "../../../A00_Common/spreadsheet/spreadsheet_condfmt.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
import type { Sheet } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
const range = { r0: 0, c0: 0, r1: 0, c1: 0 };
const sheet = makeSheet(
"s1",
{ A1: v(120) },
{
조건부서식: [
{
id: "cf1",
범위: [range],
조건: { 종류: "비교", 연산: "보다큼", 값: 100 },
스타일: { 채움: "#ffcccc" },
},
],
},
) as Sheet;
const book = makeBook([sheet]);
const env = { value: () => 120 };
assertEqual(
resolveCondFmtStyle(sheet, 0, 0, env),
{ 채움: "#ffcccc" },
"값 100 넘으면 스타일 먹임",
);
assertEqual(resolveCondFmtStyle(sheet, 5, 5, env), null, "범위 밖은 안 먹임");
const envLow = { value: () => 50 };
assertEqual(resolveCondFmtStyle(sheet, 0, 0, envLow), null, "조건 안 맞으면 null");
// 「사이」 · 「글포함」 · 「수식」 조건
const between = {
id: "b",
범위: [range],
조건: { 종류: "사이" as const, 아래: 10, 위: 20 },
스타일: {},
};
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [between] }, 0, 0, { value: () => 15 }),
{},
"사이 조건 참",
);
const contains = {
id: "c",
범위: [range],
조건: { 종류: "글포함" as const, 글: "돌" },
스타일: {},
};
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [contains] }, 0, 0, { value: () => "돌뒷길이" }),
{},
"글포함 조건 참",
);
const formula = { id: "f", 범위: [range], 조건: { 종류: "수식" as const, 식: "A1>0" }, 스타일: {} };
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [formula] }, 0, 0, {
value: () => 1,
evalFormula: (식) => 식 === "A1>0",
}),
{},
"수식 조건 — evalFormula 콜백으로 참",
);
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [formula] }, 0, 0, { value: () => 1 }),
null,
"evalFormula 없으면 수식 조건은 늘 거짓",
);
// 우선순위 — 배열 앞쪽이 이김
const two = makeSheet(
"s2",
{ A1: v(1) },
{
조건부서식: [
{
id: "first",
범위: [range],
조건: { 종류: "비교" as const, 연산: "같음" as const, 값: 1 },
스타일: { 채움: "red" },
},
{
id: "second",
범위: [range],
조건: { 종류: "비교" as const, 연산: "같음" as const, 값: 1 },
스타일: { 채움: "blue" },
},
],
},
) as Sheet;
assertEqual(
resolveCondFmtStyle(two, 0, 0, { value: () => 1 }),
{ 채움: "red" },
"먼저 나온 규칙이 이김",
);
// CRUD — 시트통째로 나감
const ctx = makeCtx(book, "s1");
addCondFmtRule(ctx, { 범위: [range], 조건: { 종류: "글포함", 글: "x" }, 스타일: { 굵게: true } });
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "addCondFmtRule 은 시트통째로 나감");
assertEqual(book.시트[0].조건부서식?.length, 2, "규칙이 더해짐");
const addedId = book.시트[0].조건부서식![1].id;
moveCondFmtRule(ctx, addedId, -1);
assertEqual(book.시트[0].조건부서식![0].id, addedId, "우선순위 위로 옮김");
removeCondFmtRule(ctx, "cf1");
assertEqual(
book.시트[0].조건부서식?.map((r) => r.id),
[addedId],
"규칙 지움",
);
// 행 · 열 넣기 뒤 범위 옮김
const shifted = remapCondFmtRanges(
[
{
id: "a",
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
조건: { 종류: "글포함", 글: "x" },
스타일: {},
},
],
(r) => ({ ...r, r0: r.r0 + 1, r1: r.r1 + 1 }),
);
assertEqual(
shifted[0].범위[0],
{ r0: 1, c0: 0, r1: 1, c1: 0 },
"remapCondFmtRanges — 행 하나 밀림",
);
const dropped = remapCondFmtRanges(
[
{
id: "a",
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
조건: { 종류: "글포함", 글: "x" },
스타일: {},
},
],
() => null,
);
assertEqual(dropped, [], "remapCondFmtRanges — 범위를 다 잃으면 규칙째 버림");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_condfmt 시험 끝");
@@ -0,0 +1,71 @@
/* =============================================================================
* test_stage3_names.ts — spreadsheet_names 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
defineName,
deleteName,
invalidNameReason,
listNames,
renameName,
resolveName,
} from "../../../A00_Common/spreadsheet/spreadsheet_names.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, f, v } from "./test_stage2_helpers.ts";
assertEqual(invalidNameReason("단가", []), null, "정상 이름은 통과");
assertEqual(invalidNameReason("", []), "이름이 비었음", "빈 이름 거절");
assertEqual(invalidNameReason("A1", []), "칸 주소처럼 보이는 이름은 못 씀", "칸 주소 꼴 거절");
assertEqual(
invalidNameReason("1단가", []),
"글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)",
"숫자로 시작 거절",
);
assertEqual(invalidNameReason("TRUE", []), "TRUE · FALSE 는 못 씀", "예약어 거절");
assertEqual(
invalidNameReason("단가", ["단가"]),
"이미 있는 이름",
"겹치는 이름 거절(대소문자 안 가림)",
);
const range = { r0: 0, c0: 0, r1: 0, c1: 0 };
const s1 = makeSheet("s1", { A1: v(100) });
const s2 = makeSheet("s2", { B2: f("A1*2") });
const book = makeBook([s1, s2]);
const ctx = makeCtx(book, "s1");
assertEqual(defineName(ctx, "단가", range), null, "defineName 성공 — 까닭 없음");
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "defineName 은 시트통째로 나감");
assertEqual(listNames(book), [{ 이름: "단가", 시트: "s1", 범위: range }], "listNames 로 보임");
assertEqual(defineName(ctx, "단가", range), "이미 있는 이름", "같은 이름 두 번은 거절");
assertEqual(
resolveName(book, "s1", "단가"),
{ 시트: "s1", 범위: range },
"resolveName 제 시트에서 찾음",
);
assertEqual(
resolveName(book, "s2", "단가"),
{ 시트: "s1", 범위: range },
"resolveName 다른 시트에서도 느슨히 찾음",
);
assertEqual(resolveName(book, "s1", "없는이름"), null, "없는 이름은 null");
// 이름 바꾸기 — 정의 + 다른 시트 식 글 속 이름도 같이
const s2f = makeSheet("s2b", { B2: f("단가*2") });
const book2 = makeBook([s1, s2f]);
const ctx2 = makeCtx(book2, "s1");
defineName(ctx2, "단가", range);
const reason = renameName(ctx2, "단가", "단가2");
assertEqual(reason, null, "renameName 성공");
assertEqual(book2.시트[0].이름정의?.[0].이름, "단가2", "정의 이름 바뀜");
assertEqual(book2.시트[1].칸.B2.식, "단가2*2", "다른 시트 식 글 속 이름도 바뀜");
assertEqual(renameName(ctx2, "단가2", "TRUE"), "TRUE · FALSE 는 못 씀", "바꿀 이름도 규칙을 지킴");
deleteName(ctx2, "단가2");
assertEqual(book2.시트[0].이름정의, [], "deleteName 으로 지움");
assertEqual(book2.시트[1].칸.B2.식, "단가2*2", "지워도 식 글은 그대로 남음(#NAME? 은 풀이 때)");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_names 시험 끝");
@@ -0,0 +1,77 @@
/* =============================================================================
* test_stage3_validation.ts — spreadsheet_validation 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
addValidationRule,
checkValue,
findValidation,
remapValidationRanges,
removeValidationRule,
} from "../../../A00_Common/spreadsheet/spreadsheet_validation.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
import type { Sheet } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
const range = { r0: 0, c0: 0, r1: 0, c1: 0 };
const sheet = makeSheet(
"s1",
{ A1: v("사과") },
{
유효성: [
{ id: "v1", 범위: [range], 규칙: { 종류: "목록", 값들: ["사과", "배"] }, 어기면: "막음" },
],
},
) as Sheet;
const book = makeBook([sheet]);
assertEqual(findValidation(sheet, 0, 0)?.id, "v1", "findValidation 있음");
assertEqual(findValidation(sheet, 9, 9), undefined, "범위 밖은 없음");
const rule = sheet.유효성![0];
assertEqual(checkValue(rule, "배"), { ok: true, 메시지: "" }, "목록 안 값은 통과");
assertEqual(checkValue(rule, "감").ok, false, "목록 밖 값은 막힘");
assertEqual(checkValue(rule, "감").어기면, "막음", "어기면 = 막음 그대로 옴");
const rangeRule = {
id: "v2",
범위: [range],
규칙: { 종류: "수범위" as const, 아래: 0, 위: 100 },
어기면: "경고" as const,
메시지: "0~100 사이만",
};
assertEqual(checkValue(rangeRule, 50), { ok: true, 메시지: "" }, "수범위 안 값은 통과");
assertEqual(
checkValue(rangeRule, 150),
{ ok: false, 어기면: "경고", 메시지: "0~100 사이만" },
"수범위 밖 · 안내 글 그대로",
);
assertEqual(checkValue(rangeRule, "글").ok, false, "글은 수범위 규칙에서 늘 거짓");
// CRUD — 시트통째로 나감
const ctx = makeCtx(book, "s1");
addValidationRule(ctx, { 범위: [range], 규칙: { 종류: "수범위", 위: 10 }, 어기면: "경고" });
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "addValidationRule 은 시트통째로 나감");
assertEqual(book.시트[0].유효성?.length, 2, "규칙이 더해짐");
removeValidationRule(ctx, "v1");
assertEqual(book.시트[0].유효성?.map((r) => r.id).includes("v1"), false, "지운 규칙은 없음");
// 행 · 열 넣기 뒤 범위 옮김
const shifted = remapValidationRanges(
[{ id: "a", 범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }], 규칙: { 종류: "수범위" }, 어기면: "막음" }],
(r) => ({ ...r, c0: r.c0 + 1, c1: r.c1 + 1 }),
);
assertEqual(
shifted[0].범위[0],
{ r0: 0, c0: 1, r1: 0, c1: 1 },
"remapValidationRanges — 열 하나 밀림",
);
const dropped = remapValidationRanges(
[{ id: "a", 범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }], 규칙: { 종류: "수범위" }, 어기면: "막음" }],
() => null,
);
assertEqual(dropped, [], "remapValidationRanges — 범위를 다 잃으면 규칙째 버림");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_validation 시험 끝");