feat(spreadsheet): 2단계 다섯 — 찾기 · 붙여넣기 골라서 · 서식 붓 · 폭 자동 맞춤 · 함수 도움말

PLAN 11-8 스프레드시트 2단계. 0 계약(spreadsheet_types.ts · spreadsheet_view_types.ts)
타입만 보고 새 파일 다섯 짬 — 잇기는 1단계 끝에 D(sub7)가 파일 머리 주석대로:

- spreadsheet_find.ts        찾기 · 바꾸기(대소문자 · 전체칸 · 수식 안)
- spreadsheet_paste_special.ts 붙여넣기 골라서(값만 · 서식만 · 수식만)
- spreadsheet_format_painter.ts 서식 붓(칸→행→열→기본 차례로 서식을 찾음)
- spreadsheet_autofit.ts      열 머리 두 번 눌러 폭 자동 맞춤(어림 재기 · 실제 재기 자리 열어 둠)
- spreadsheet_func_help.ts    함수 자동 완성 · 인자 도움말(1단계 함수 48 개)

여러 범위 · 확대 · 눈금선 끄기는 이미 계약에 있어 새 파일 없이 붙일 자리만 확인(autofit.ts 머리).
시험 = resources/tester/spreadsheet/test_stage2_*.ts(공용 견본 · 가짜 컨텍스트는 test_stage2_helpers.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
This commit is contained in:
2026-09-27 20:20:16 +09:00
co-authored by Claude Sonnet 5
parent d53ef8d3bf
commit d38ef573e0
11 changed files with 972 additions and 0 deletions
@@ -0,0 +1,61 @@
/* =============================================================================
* spreadsheet_autofit.ts (2단계 · 주인 sub_laptop_1)
* 열 머리 경계를 두 번 눌러 폭 자동 맞춤 — 그 열 안 글자 수로 어림(엑셀 「글자 단위」).
* 진짜 픽셀 값은 C(격자)가 쓰는 글꼴로 재야 더 정확 — `measure` 자리에 C 의 실제 재기 함수를
* 나중에 꽂을 수 있게 열어 둠(안 주면 아래 어림 규칙을 씀).
*
* 잇는 법(D · sub7 · C 격자 머리) — 열 머리 경계에서 두 번 누르면(dblclick)
* `autofitColumn(ctx, col)` 을 부름. C 가 완성되면 `measure` 자리에 캔버스 `measureText` 를 넘겨 더 정밀하게.
*
* ── 이미 계약에 있어 새 파일이 필요 없는 것(붙일 자리만 확인) ──────────────────────
* · 여러 범위(Ctrl+누르기) — `spreadsheet_view_types.ts` `Selection.범위` 가 이미 배열(D `spreadsheet_selection.ts` 가 씀).
* · 눈금선 끄기 — `Command`(`종류:"눈금선"`) · `Sheet.보기?.눈금선` 이미 있음(A 가 처리).
* · 확대 · 축소 — 값 자체를 저장하지 않는 화면 전용 CSS 배율(C 격자 뿌리에 transform) · 계약 밖.
* ========================================================================== */
import type { Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export type TextMeasurer = (text: string) => number;
const PADDING = 2;
const MIN_WIDTH = 4;
/** 글꼴을 모를 때 어림 — 한글 · 한자 · 전각은 1.9 배 폭 · 나머지는 1 배. */
const defaultMeasure: TextMeasurer = (text) => {
let width = 0;
for (const ch of text) width += ch.codePointAt(0)! >= 0x1100 ? 1.9 : 1;
return width;
};
const colFromKey = (key: string): number => {
const m = /^([A-Z]+)\d+$/.exec(key);
if (!m) return -1;
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return col - 1;
};
/** 그 열에 든 칸 글자(식 칸은 식 글로 어림 — 계산값은 여기서 모름) 중 가장 넓은 것 + 여백. */
export function estimateColumnWidth(
book: Workbook,
sheetId: string,
col: number,
measure: TextMeasurer = defaultMeasure,
): number {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return MIN_WIDTH;
let widest = 0;
for (const [key, cell] of Object.entries(sheet.칸)) {
if (colFromKey(key) !== col) continue;
const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : "";
if (text) widest = Math.max(widest, measure(text));
}
return Math.max(MIN_WIDTH, Math.ceil(widest + PADDING));
}
/** 폭을 자동 맞춤 값으로 바꿈(열넓이 명령 하나). */
export function autofitColumn(ctx: SpreadsheetContext, col: number, measure?: TextMeasurer): void {
const width = estimateColumnWidth(ctx.book, ctx.selection.시트, col, measure);
ctx.dispatch({ 종류: "열폭", 시트: ctx.selection.시트, 열: [col], 폭: width });
}
+179
View File
@@ -0,0 +1,179 @@
/* =============================================================================
* spreadsheet_find.ts (2단계 · 주인 sub_laptop_1)
* 찾기 · 바꾸기 — 대소문자 구분 · 전체 칸 일치 · 「수식 안」(식 글 속 글자도 뒤짐) 옵션.
* 값 칸은 `값`(수 · 참거짓은 글로 바꿔 견줌) · 식 칸은 기본으로 식 글(`=` 뺀 것)을 뒤짐(계산값은
* 이 계층에서 모름 — CalcEngine 은 C·D 쪽에만 있음). 바꾸기는 `dispatch({종류:"칸", …})` 하나로만.
*
* 잇는 법(D · sub7) — 1단계 격자 · 편집기가 서면:
* 1) 도구 모음이나 Ctrl+F 단축키로 작은 패널을 띄우고 `createFindPanel(ctx)` 를 그 안에 붙임.
* 2) 패널의 「다음 찾기」 는 `findNext` 결과로 `ctx.select` + `ctx.grid.reveal` 호출.
* 3) 「모두 바꾸기」 버튼은 `replaceAll` 을 부르고 끝나면 바뀐 수를 토스트로.
* ========================================================================== */
import type { CellInput, SheetCellAddress, Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export interface FindOptions {
대소문자?: boolean;
전체칸?: boolean;
/** 식 칸은 식 글(수식) 속에서도 찾음 — 꺼지면 값 칸만 뒤짐(식 칸은 건너뜀) */
수식안?: boolean;
}
const norm = (text: string, opts: FindOptions | undefined): string =>
opts?.대소문자 ? text : text.toLowerCase();
/** 칸 하나를 글로 — 식 칸은 `수식안` 이 있어야 식 글, 없으면 후보에서 빠짐(null). */
function cellText(
book: Workbook,
sheetId: string,
r: number,
c: number,
opts?: FindOptions,
): string | null {
const sheet = book.시트.find((s) => s.id === sheetId);
const cell = sheet?.칸[a1(r, c)];
if (!cell) return null;
if (cell.식 !== undefined) return opts?.수식안 ? cell.식 : null;
const v = cell.값;
if (v === undefined) return null;
return typeof v === "boolean" ? (v ? "TRUE" : "FALSE") : String(v);
}
function a1(r: number, c: number): string {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
}
const matches = (haystack: string, query: string, opts: FindOptions | undefined): boolean => {
const h = norm(haystack, opts);
const q = norm(query, opts);
return opts?.전체칸 ? h === q : h.includes(q);
};
/** 시트 하나 안 찾는 순서 — 왼위부터 오른아래로(행 우선). */
function* cellsInOrder(book: Workbook, sheetId: string): Generator<SheetCellAddress> {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return;
const addrs = Object.keys(sheet.칸)
.map((key) => parseA1(key))
.sort((x, y) => x.r - y.r || x.c - y.c);
for (const { r, c } of addrs) yield { 시트: sheetId, r, c };
}
function parseA1(key: string): { r: number; c: number } {
const m = /^([A-Z]+)(\d+)$/.exec(key);
if (!m) return { r: 0, c: 0 };
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return { r: Number(m[2]) - 1, c: col - 1 };
}
/** 통합문서 전체(활성 시트 먼저 뒤지고 싶으면 `sheetOrder` 로 순서를 줌)에서 다 찾음. */
export function findAll(
book: Workbook,
query: string,
opts?: FindOptions,
sheetOrder?: string[],
): SheetCellAddress[] {
if (!query) return [];
const ids = sheetOrder ?? book.시트.map((s) => s.id);
const out: SheetCellAddress[] = [];
for (const id of ids) {
for (const at of cellsInOrder(book, id)) {
const text = cellText(book, at.시트, at.r, at.c, opts);
if (text !== null && matches(text, query, opts)) out.push(at);
}
}
return out;
}
/** `from` 다음 칸부터 한 바퀴 돌아 처음 걸리는 것(순환) · 없으면 null. */
export function findNext(
book: Workbook,
from: SheetCellAddress,
query: string,
opts?: FindOptions,
): SheetCellAddress | null {
const all = findAll(book, query, opts, [
from.시트,
...book.시트.map((s) => s.id).filter((id) => id !== from.시트),
]);
if (all.length === 0) return null;
const idx = all.findIndex((m) => m.시트 === from.시트 && m.r === from.r && m.c === from.c);
return all[(idx + 1) % all.length];
}
/** 한 칸 바꾸기 — 식 칸(`수식안`)이면 식 글 속 글자를 바꿈 · 값 칸이면 값 전체(전체칸)나 부분(글만)을 바꿈. */
export function replaceOne(
ctx: SpreadsheetContext,
at: SheetCellAddress,
query: string,
replacement: string,
opts?: FindOptions,
): void {
const sheet = ctx.book.시트.find((s) => s.id === at.시트);
const cell = sheet?.칸[a1(at.r, at.c)];
if (!sheet || !cell) return;
const next: { 값?: CellInput; 식?: string; 서식?: number } = { 서식: cell.서식 };
if (cell.식 !== undefined && opts?.수식안) {
next.식 = replaceText(cell.식, query, replacement, opts);
} else if (cell.값 !== undefined) {
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
next.값 = replaceText(text, query, replacement, opts);
} else {
return;
}
ctx.dispatch({ 종류: "칸", 시트: at.시트, 칸: { [a1(at.r, at.c)]: next } });
}
function replaceText(
text: string,
query: string,
replacement: string,
opts: FindOptions | undefined,
): string {
if (opts?.전체칸) return matches(text, query, opts) ? replacement : text;
if (opts?.대소문자) return text.split(query).join(replacement);
const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
return text.replace(re, replacement);
}
/** 모두 바꾸기 — 한 번의 되돌리기로 묶어 적용 · 바뀐 칸 수를 돌려줌. */
export function replaceAll(
ctx: SpreadsheetContext,
query: string,
replacement: string,
opts?: FindOptions,
): number {
const hits = findAll(ctx.book, query, opts);
if (hits.length === 0) return 0;
const bySheet = new Map<string, SheetCellAddress[]>();
for (const at of hits) bySheet.set(at.시트, [...(bySheet.get(at.시트) ?? []), at]);
const commands = [...bySheet.entries()].map(([sheetId, ats]) => {
const sheet = ctx.book.시트.find((s) => s.id === sheetId)!;
const 칸: Record<string, { 값?: CellInput; 식?: string; 서식?: number }> = {};
for (const at of ats) {
const cell = sheet.칸[a1(at.r, at.c)];
if (!cell) continue;
if (cell.식 !== undefined && opts?.수식안) {
칸[a1(at.r, at.c)] = {
식: replaceText(cell.식, query, replacement, opts),
서식: cell.서식,
};
} else if (cell.값 !== undefined) {
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
칸[a1(at.r, at.c)] = { 값: replaceText(text, query, replacement, opts), 서식: cell.서식 };
}
}
return { 종류: "칸" as const, 시트: sheetId, 칸 };
});
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
return hits.length;
}
@@ -0,0 +1,95 @@
/* =============================================================================
* spreadsheet_format_painter.ts (2단계 · 주인 sub_laptop_1)
* 서식 붓 — 활성 칸의 서식을 묻혀 다른 범위에 바름. 한 번 누르면 한 번만 · 두 번 누르면(sticky)
* 끌 때까지 계속. 서식 찾기는 칸 → 행 → 열 → 기본(0) 차례(`spreadsheet_types.ts` `Cell.서식` 규칙).
*
* 잇는 법(D · sub7 · E 도구 모음) — 도구 모음에 버튼을 두고 `createFormatPainter(ctx)` 하나를 붙임.
* 한 번 누르면 `pick()` → 커서를 붓 모양으로 → 범위를 고르면(mouseup) `paint(range)`.
* 두 번 누르면 `pick()` 뒤 `sticky=true` 로 두고 Esc 나 버튼을 다시 누르면 꺼짐(`active()` 로 표시).
* ========================================================================== */
import type { CellRange, CellStyle, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const EMPTY_STYLE: CellStyle = {};
/** 칸 하나의 실제 서식 — 칸 자기 서식 없으면 행 서식 · 없으면 열 서식 · 없으면 기본(0). */
export function resolveCellStyle(book: Workbook, sheetId: string, r: number, c: number): CellStyle {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return EMPTY_STYLE;
const idx = cellStyleIndex(sheet, r, c);
return book.서식[idx] ?? EMPTY_STYLE;
}
function cellStyleIndex(
sheet: {
칸: Record<string, { 서식?: number }>;
열?: Record<string, { 서식?: number }>;
행?: Record<string, { 서식?: number }>;
},
r: number,
c: number,
): number {
const key = a1(r, c);
const cellIdx = sheet.칸[key]?.서식;
if (cellIdx !== undefined) return cellIdx;
const rowIdx = sheet.행?.[String(r + 1)]?.서식;
if (rowIdx !== undefined) return rowIdx;
const colIdx = sheet.열?.[colLetters(c)]?.서식;
if (colIdx !== undefined) return colIdx;
return 0;
}
function colLetters(c: number): string {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return letters;
}
const a1 = (r: number, c: number): string => `${colLetters(c)}${r + 1}`;
export interface FormatPainterHandle extends PartHandle {
/** 지금 활성 칸의 서식을 붓에 묻힘 */
pick(): void;
/** 묻힌 서식이 있으면 범위에 바름 · `sticky` 가 아니면 한 번 쓰고 스스로 끔 */
paint(range: CellRange, sticky?: boolean): void;
active(): boolean;
clear(): void;
}
export function createFormatPainter(ctx: SpreadsheetContext): FormatPainterHandle {
let picked: CellStyle | null = null;
let sticky = false;
return {
root: null,
pick(): void {
const at = ctx.selection.활성;
picked = resolveCellStyle(ctx.book, ctx.selection.시트, at.r, at.c);
},
paint(range: CellRange, keepOn = false): void {
if (!picked) return;
sticky = keepOn;
ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: [range], 바꿀: { ...picked } });
if (!sticky) picked = null;
},
active(): boolean {
return picked !== null;
},
clear(): void {
picked = null;
sticky = false;
},
refresh(): void {
/* 상태 없는 표시만 씀 — 버튼 눌림은 active() 로 E 가 그림 */
},
destroy(): void {
picked = null;
},
};
}
@@ -0,0 +1,94 @@
/* =============================================================================
* spreadsheet_func_help.ts (2단계 · 주인 sub_laptop_1)
* 함수 자동 완성 목록 · 인자 도움말 — 1단계 함수 48 개(`spreadsheet_types.ts` 머리 「1단계 함수」)
* 이름 · 인자 모양 · 한 줄 뜻. B `spreadsheet_functions.ts`(실제 풀이)와는 따로 — 이 표는 화면 도움말 전용
* 정적 자료라 B 가 아직 없어도 씀. B 가 완성돼도 이름 목록이 갈리지 않게 이 표를 그대로 둠.
*
* 잇는 법(D · sub7 · 편집기) — 식 입력 중 `=` 뒤 낱말을 `suggestFunctions` 로 목록을 보여주고
* `(` 를 친 뒤엔 `funcHelp(name)` 로 인자 도움말 풍선을 씀(지금 몇 번째 인자인지는 D 가 콤마를 세어 넘김).
* ========================================================================== */
export interface FuncHelp {
이름: string;
/** 인자 모양 — 대괄호는 생략 가능 */
꼴: string;
뜻: string;
}
const TABLE: FuncHelp[] = [
{ 이름: "SUM", 꼴: "SUM(수1, [수2], …)", 뜻: "수를 더함" },
{ 이름: "PRODUCT", 꼴: "PRODUCT(수1, [수2], …)", 뜻: "수를 곱함" },
{ 이름: "SUMPRODUCT", 꼴: "SUMPRODUCT(범위1, [범위2], …)", 뜻: "같은 자리끼리 곱한 뒤 더함" },
{ 이름: "ROUND", 꼴: "ROUND(수, 자릿수)", 뜻: "반올림" },
{ 이름: "ROUNDUP", 꼴: "ROUNDUP(수, 자릿수)", 뜻: "올림" },
{ 이름: "ROUNDDOWN", 꼴: "ROUNDDOWN(수, 자릿수)", 뜻: "내림" },
{ 이름: "INT", 꼴: "INT(수)", 뜻: "정수로 내림(음수는 작은 쪽)" },
{ 이름: "TRUNC", 꼴: "TRUNC(수, [자릿수])", 뜻: "소수점을 버림(0 쪽으로)" },
{ 이름: "CEILING", 꼴: "CEILING(수, 배수)", 뜻: "배수 단위로 올림" },
{ 이름: "FLOOR", 꼴: "FLOOR(수, 배수)", 뜻: "배수 단위로 내림" },
{ 이름: "ABS", 꼴: "ABS(수)", 뜻: "절댓값" },
{ 이름: "MOD", 꼴: "MOD(수, 나눌수)", 뜻: "나눈 나머지" },
{ 이름: "POWER", 꼴: "POWER(수, 지수)", 뜻: "거듭제곱" },
{ 이름: "SQRT", 꼴: "SQRT(수)", 뜻: "제곱근" },
{ 이름: "PI", 꼴: "PI()", 뜻: "원주율" },
{ 이름: "SIN", 꼴: "SIN(각도값)", 뜻: "사인(라디안)" },
{ 이름: "COS", 꼴: "COS(각도값)", 뜻: "코사인(라디안)" },
{ 이름: "TAN", 꼴: "TAN(각도값)", 뜻: "탄젠트(라디안)" },
{ 이름: "ASIN", 꼴: "ASIN(수)", 뜻: "아크사인(라디안)" },
{ 이름: "ACOS", 꼴: "ACOS(수)", 뜻: "아크코사인(라디안)" },
{ 이름: "ATAN", 꼴: "ATAN(수)", 뜻: "아크탄젠트(라디안)" },
{ 이름: "RADIANS", 꼴: "RADIANS(각도)", 뜻: "도 → 라디안" },
{ 이름: "DEGREES", 꼴: "DEGREES(라디안)", 뜻: "라디안 → 도" },
{ 이름: "MIN", 꼴: "MIN(수1, [수2], …)", 뜻: "가장 작은 수" },
{ 이름: "MAX", 꼴: "MAX(수1, [수2], …)", 뜻: "가장 큰 수" },
{ 이름: "AVERAGE", 꼴: "AVERAGE(수1, [수2], …)", 뜻: "평균" },
{ 이름: "IF", 꼴: "IF(조건, 참일때, [거짓일때])", 뜻: "조건에 따라 값을 고름" },
{ 이름: "AND", 꼴: "AND(조건1, [조건2], …)", 뜻: "모두 참이면 참" },
{ 이름: "OR", 꼴: "OR(조건1, [조건2], …)", 뜻: "하나라도 참이면 참" },
{ 이름: "NOT", 꼴: "NOT(조건)", 뜻: "참 · 거짓을 뒤집음" },
{ 이름: "IFERROR", 꼴: "IFERROR(식, 오류일때)", 뜻: "오류면 대신 값을 씀" },
{ 이름: "CONCATENATE", 꼴: "CONCATENATE(글1, [글2], …)", 뜻: "글을 이어 붙임" },
{ 이름: "FIXED", 꼴: "FIXED(수, [소수자리], [쉼표뺌])", 뜻: "소수 자리를 정해 글로 바꿈" },
{ 이름: "TEXT", 꼴: "TEXT(값, 형식)", 뜻: "형식 코드로 값을 글로 바꿈" },
{ 이름: "LEN", 꼴: "LEN(글)", 뜻: "글자 수" },
{ 이름: "LEFT", 꼴: "LEFT(글, [글자수])", 뜻: "왼쪽부터 글자를 뗌" },
{ 이름: "RIGHT", 꼴: "RIGHT(글, [글자수])", 뜻: "오른쪽부터 글자를 뗌" },
{ 이름: "MID", 꼴: "MID(글, 시작, 글자수)", 뜻: "가운데 글자를 뗌" },
{ 이름: "VALUE", 꼴: "VALUE(글)", 뜻: "숫자 모양 글을 수로 바꿈" },
{
이름: "VLOOKUP",
꼴: "VLOOKUP(찾을값, 표범위, 열번호, [정확히])",
뜻: "표 왼쪽에서 찾아 그 행의 값",
},
{
이름: "HLOOKUP",
꼴: "HLOOKUP(찾을값, 표범위, 행번호, [정확히])",
뜻: "표 위쪽에서 찾아 그 열의 값",
},
{ 이름: "INDEX", 꼴: "INDEX(범위, 행번호, [열번호])", 뜻: "범위 안 자리 값" },
{ 이름: "MATCH", 꼴: "MATCH(찾을값, 범위, [맞춤꼴])", 뜻: "범위 안 자리 번호" },
{ 이름: "CHOOSE", 꼴: "CHOOSE(순번, 값1, [값2], …)", 뜻: "순번에 맞는 값을 고름" },
{ 이름: "COUNT", 꼴: "COUNT(값1, [값2], …)", 뜻: "수가 든 칸 개수" },
{ 이름: "COUNTA", 꼴: "COUNTA(값1, [값2], …)", 뜻: "비어 있지 않은 칸 개수" },
{ 이름: "COUNTIF", 꼴: "COUNTIF(범위, 조건)", 뜻: "조건에 맞는 칸 개수" },
{ 이름: "SUMIF", 꼴: "SUMIF(범위, 조건, [더할범위])", 뜻: "조건에 맞는 칸(또는 짝 칸)의 합" },
];
const BY_NAME = new Map(TABLE.map((f) => [f.이름, f]));
/** `=` 뒤 지금 치는 낱말(대소문자 안 가림)로 시작하는 함수 이름 — 가나다(알파벳)순. */
export function suggestFunctions(prefix: string): FuncHelp[] {
const p = prefix.toUpperCase();
if (!p) return [];
return TABLE.filter((f) => f.이름.startsWith(p));
}
/** 함수 하나의 도움말(없으면 null) — 이름은 대소문자 안 가림. */
export function funcHelp(name: string): FuncHelp | null {
return BY_NAME.get(name.toUpperCase()) ?? null;
}
/** 1단계 함수 48 개 다 있는지(시험용) */
export function knownFunctionCount(): number {
return TABLE.length;
}
@@ -0,0 +1,140 @@
/* =============================================================================
* spreadsheet_paste_special.ts (2단계 · 주인 sub_laptop_1)
* 붙여넣기 골라서 — 값만 · 서식만 · 수식만(Ctrl+Shift+V). `ClipBlock`(0 계약) 을 받아
* 대상 왼위(`at`)에 맞게 명령을 만듦 — 서식은 `서식` 명령(칸마다 하나 · A 가 표 번호를 매김) ·
* 값 · 식은 `칸` 명령(대상 칸의 지금 서식 번호는 그대로 둠). 참조 옮김은 「원점」 이 있을 때만
* `$` 없는 A1 참조를 얕게 옮김(진짜 옮김은 A `spreadsheet_refshift` 몫 — 여긴 메뉴용 편의).
*
* 잇는 법(D · sub7) — 클립보드(E)가 만든 `ClipBlock` 을 들고:
* 1) 우클릭 메뉴 · Ctrl+Shift+V 로 모드 고르는 작은 팝업을 띄움(값만 · 서식만 · 수식만).
* 2) 고른 모드로 `pasteSpecial(ctx, block, ctx.selection.활성, mode, sourceOrigin?)` 호출.
* 3) 「값만」 은 원본이 아직 화면에 있을 때만 계산값을 살릴 수 있음 — `sourceOrigin` 을 줌(없으면 식 칸은 건너뜀).
* ========================================================================== */
import type {
Cell,
CellAddress,
CellRange,
CellStyle,
ClipBlock,
ClipCell,
Command,
Frac,
Scalar,
} from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
export type PasteSpecialMode = "값" | "서식" | "수식";
const a1 = (r: number, c: number): string => {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
};
/** `$` 없는 A1 참조만 (dr, dc) 만큼 옮김 — 절대참조 · 다른 시트 이름은 손대지 않음(안전한 최소). */
export function shiftRelativeRefs(formula: string, dr: number, dc: number): string {
return formula.replace(
/(\$?)([A-Z]{1,3})(\$?)(\d+)/g,
(whole, absC, colLetters, absR, rowDigits) => {
if (absC === "$" && absR === "$") return whole;
let col = 0;
for (const ch of colLetters) col = col * 26 + (ch.charCodeAt(0) - 64);
const nextCol = absC === "$" ? col : col + dc;
const nextRow = absR === "$" ? Number(rowDigits) : Number(rowDigits) + dr;
if (nextCol < 1 || nextRow < 1) return "#REF!";
let letters = "";
let n = nextCol;
while (n > 0) {
const rem = (n - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
n = Math.floor((n - 1) / 26);
}
return `${absC}${letters}${absR}${nextRow}`;
},
);
}
export interface PasteSpecialSource {
시트: string;
r: number;
c: number;
}
/** `block` 을 `at` 에 골라 붙임. `sourceOrigin` 은 「값만」 때 식 칸을 계산값으로 굳히는 데 씀(없으면 식 칸은 건너뜀). */
export function pasteSpecial(
ctx: SpreadsheetContext,
block: ClipBlock,
at: CellAddress,
mode: PasteSpecialMode,
sourceOrigin?: PasteSpecialSource,
): void {
const sheetId = ctx.selection.시트;
const sheet = ctx.sheet();
const dr = at.r - (block.원점?.r ?? at.r);
const dc = at.c - (block.원점?.c ?? at.c);
const commands: Command[] = [];
const 칸: Record<string, Cell | null> = {};
for (const [key, clip] of Object.entries(block.칸)) {
const [rowStr, colStr] = key.split(",");
const rr = at.r + Number(rowStr);
const cc = at.c + Number(colStr);
if (rr < 0 || cc < 0) continue;
const target = a1(rr, cc);
const existing = sheet.칸[target];
if (mode === "서식") {
if (clip.서식) commands.push(styleCommand(sheetId, rr, cc, clip.서식));
continue;
}
if (mode === "수식") {
if (clip.식 === undefined) continue;
const moved = block.원점 ? shiftRelativeRefs(clip.식, dr, dc) : clip.식;
칸[target] = { 식: moved, 서식: existing?.서식 };
continue;
}
// mode === "값"
const value = resolveClipValue(clip, sourceOrigin, Number(rowStr), Number(colStr), ctx);
if (value === undefined) continue;
칸[target] = { 값: value, 서식: existing?.서식 };
}
if (Object.keys(칸).length > 0) commands.push({ 종류: "칸", 시트: sheetId, 칸 });
if (commands.length === 0) return;
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
}
function styleCommand(sheetId: string, r: number, c: number, style: CellStyle): Command {
const 범위: CellRange = { r0: r, c0: c, r1: r, c1: c };
return { 종류: "서식", 시트: sheetId, 범위: [범위], 바꿀: { ...style } };
}
function resolveClipValue(
clip: ClipCell,
origin: PasteSpecialSource | undefined,
dr: number,
dc: number,
ctx: SpreadsheetContext,
): Cell["값"] | undefined {
if (clip.식 !== undefined) {
if (!origin) return undefined;
const scalar = ctx.engine.value(origin.시트, origin.r + dr, origin.c + dc);
return scalarFrom(scalar);
}
return clip.값;
}
function scalarFrom(value: Scalar): Cell["값"] | undefined {
if (value === null) return undefined;
if (typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "object" && "n" in value && "d" in value)
return Number(fracToString(value as Frac));
return undefined;
}
@@ -0,0 +1,32 @@
/* =============================================================================
* test_stage2_autofit.ts — spreadsheet_autofit 시험. 돌리기: node --experimental-strip-types <이 파일>
* ========================================================================== */
import {
autofitColumn,
estimateColumnWidth,
} from "../../../A00_Common/spreadsheet/spreadsheet_autofit.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
const sheet = makeSheet("s1", {
A1: v("짧음"),
A2: v("이것은 조금 더 긴 글자"),
B1: v(123),
});
const book = makeBook([sheet]);
const widthA = estimateColumnWidth(book, "s1", 0);
const widthB = estimateColumnWidth(book, "s1", 1);
assertEqual(widthA > widthB, true, "A 열이 B 열보다 글자가 길어 폭도 큼");
assertEqual(estimateColumnWidth(book, "s1", 5), 4, "빈 열은 최소 폭");
const ctx = makeCtx(book, "s1");
autofitColumn(ctx, 0);
assertEqual(sheet.열?.A.폭, widthA, "autofitColumn 이 열폭 명령으로 반영");
// 재는 함수를 바꿔 끼울 수 있음(C 격자가 실제 글꼴로 잴 때 대비)
const fixedWidth = estimateColumnWidth(book, "s1", 0, () => 7);
assertEqual(fixedWidth, 9, "measure 를 넘기면 그걸로 어림(7 + 여백 2)");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_autofit 시험 끝");
@@ -0,0 +1,60 @@
/* =============================================================================
* test_stage2_find.ts — spreadsheet_find 시험. 돌리기: node --experimental-strip-types <이 파일>
* ========================================================================== */
import {
findAll,
findNext,
replaceAll,
replaceOne,
} from "../../../A00_Common/spreadsheet/spreadsheet_find.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v, f } from "./test_stage2_helpers.ts";
const sheet = makeSheet("s1", {
A1: v("돌뒷길이"),
A2: v("돌뒷길이2"),
B1: v(10),
B2: f('A1&"-"'),
});
const book = makeBook([sheet]);
assertEqual(
findAll(book, "돌뒷길이").map((m) => `${m.r},${m.c}`),
["0,0", "1,0"],
"findAll 기본(값 칸 둘)",
);
assertEqual(
findAll(book, "돌뒷길이", { 전체칸: true }).map((m) => `${m.r},${m.c}`),
["0,0"],
"findAll 전체칸",
);
assertEqual(
findAll(book, "A1", { 수식안: true }).map((m) => `${m.r},${m.c}`),
["1,1"],
"findAll 수식안(식 글 속)",
);
assertEqual(
findAll(book, "돌뒷길이", { 대소문자: true }).length,
2,
"findAll 대소문자 켜도 완전 일치 글자는 그대로 찾음",
);
const next = findNext(book, { 시트: "s1", r: 0, c: 0 }, "돌뒷길이");
assertEqual(next, { 시트: "s1", r: 1, c: 0 }, "findNext 다음 칸으로");
const wrapped = findNext(book, { 시트: "s1", r: 1, c: 0 }, "돌뒷길이");
assertEqual(wrapped, { 시트: "s1", r: 0, c: 0 }, "findNext 한 바퀴 돌아 처음으로");
const ctx = makeCtx(book, "s1");
replaceOne(ctx, { 시트: "s1", r: 0, c: 0 }, "돌뒷길이", "돌앞길이");
assertEqual(book.시트[0].칸.A1.값, "돌앞길이", "replaceOne 값 칸");
const n = replaceAll(ctx, "-", "_", { 수식안: true });
assertEqual(n, 1, "replaceAll 바뀐 칸 수");
assertEqual(book.시트[0].칸.B2.식, 'A1&"_"', "replaceAll 식 칸 속 글자");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_find 시험 끝");
@@ -0,0 +1,46 @@
/* =============================================================================
* test_stage2_format_painter.ts — spreadsheet_format_painter 시험. 돌리기: node --experimental-strip-types <이 파일>
* ========================================================================== */
import {
createFormatPainter,
resolveCellStyle,
} from "../../../A00_Common/spreadsheet/spreadsheet_format_painter.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
const sheet = makeSheet(
"s1",
{ A1: v(1, 1), B1: v(2) },
{ 열: { B: { 서식: 2 } }, 행: { "1": { 서식: 3 } } },
);
const book = makeBook([sheet], [{}, { 굵게: true }, { 글자색: "#ff0000" }, { 밑줄: true }]);
assertEqual(resolveCellStyle(book, "s1", 0, 0), { 굵게: true }, "칸 자기 서식 우선");
// Cell.서식 규칙(0 계약 주석) = 칸 → 행 → 열 → 기본. B1 은 칸 서식 없음 · 행 1 서식(3) · 열 B 서식(2) 둘 다 있음 → 행이 이김.
assertEqual(
resolveCellStyle(book, "s1", 0, 1),
{ 밑줄: true },
"칸 서식 없으면 행 서식(열보다 앞섬)",
);
delete sheet.행!["1"];
assertEqual(resolveCellStyle(book, "s1", 0, 1), { 글자색: "#ff0000" }, "행 서식도 없으면 열 서식");
assertEqual(resolveCellStyle(book, "s1", 5, 5), {}, "아무 서식도 없으면 기본(0)");
const ctx = makeCtx(book, "s1");
const painter = createFormatPainter(ctx);
ctx.selection.활성 = { r: 0, c: 0 }; // A1 = 굵게
painter.pick();
assertEqual(painter.active(), true, "pick 뒤 active");
painter.paint({ r0: 4, c0: 4, r1: 4, c1: 4 });
assertEqual(resolveCellStyle(book, "s1", 4, 4), { 굵게: true }, "paint 로 서식이 옮음");
assertEqual(painter.active(), false, "한 번 쓰면 꺼짐(sticky 아님)");
painter.pick();
painter.paint({ r0: 6, c0: 6, r1: 6, c1: 6 }, true);
assertEqual(painter.active(), true, "sticky 는 계속 켜짐");
painter.clear();
assertEqual(painter.active(), false, "clear 로 끔");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_format_painter 시험 끝");
@@ -0,0 +1,48 @@
/* =============================================================================
* test_stage2_func_help.ts — spreadsheet_func_help 시험. 돌리기: node --experimental-strip-types <이 파일>
* ========================================================================== */
import {
funcHelp,
knownFunctionCount,
suggestFunctions,
} from "../../../A00_Common/spreadsheet/spreadsheet_func_help.ts";
import { assertEqual } from "./test_stage2_helpers.ts";
assertEqual(knownFunctionCount(), 48, "1단계 함수 48 개 다 있음");
assertEqual(
suggestFunctions("ROU").map((f) => f.이름),
["ROUND", "ROUNDUP", "ROUNDDOWN"],
"접두사로 자동 완성 후보",
);
assertEqual(suggestFunctions("rou").map((f) => f.이름).length, 3, "대소문자 안 가림");
assertEqual(suggestFunctions("").length, 0, "빈 접두사는 후보 없음");
assertEqual(suggestFunctions("ZZZ").length, 0, "안 맞으면 없음");
const help = funcHelp("vlookup");
assertEqual(help?.이름, "VLOOKUP", "이름은 대소문자 안 가림");
assertEqual(help?.꼴.includes("("), true, "꼴에 괄호가 있음");
assertEqual(funcHelp("없는함수"), null, "없는 함수는 null");
// 실무 13 가지가 모두 표에 있는지(9_엑셀검토 1장)
const 실무13 = [
"ROUND",
"SUM",
"ROUNDDOWN",
"INT",
"SQRT",
"IF",
"FIXED",
"VLOOKUP",
"ROUNDUP",
"CONCATENATE",
"TAN",
"PI",
"COUNTA",
];
for (const name of 실무13) assertEqual(funcHelp(name) !== null, true, `실무 함수 ${name} 있음`);
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_func_help 시험 끝");
@@ -0,0 +1,166 @@
/* =============================================================================
* test_stage2_helpers.ts
* PLAN 11-8 2단계 시험 공용 — 최소 통합문서 견본 · 가짜 SpreadsheetContext(dispatch 가 「칸」·
* 「서식」·「묶음」·「열폭」 명령만 그대로 적용 — A(spreadsheet_commands)가 아직 없어도 2단계
* 파일 하나하나를 검증하기 위한 최소한). 돌리기 — `node --experimental-strip-types <시험 파일>`.
* ========================================================================== */
import type {
Cell,
CellStyle,
Command,
Frac,
Scalar,
Sheet,
Workbook,
} from "../../../A00_Common/spreadsheet/spreadsheet_types";
import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreadsheet_view_types";
export function assertEqual<T>(actual: T, expected: T, label: string): void {
const a = JSON.stringify(actual);
const b = JSON.stringify(expected);
if (a !== b) {
console.error(`FAIL ${label}\n 실제: ${a}\n 기대: ${b}`);
process.exitCode = 1;
} else {
console.log(`ok ${label}`);
}
}
export function makeSheet(id: string, 칸: Record<string, Cell>, extra: Partial<Sheet> = {}): Sheet {
return { id, 이름: id, 칸, ...extra };
}
export function makeBook(sheets: Sheet[], 서식: CellStyle[] = [{}]): Workbook {
return { 종류: "통합문서", 판: 1, 열: "test", 서식, 시트: sheets };
}
/** 값 칸 하나 짜기(수는 그대로 넣음 — Frac 아님, 이 시험 계층에선 안 씀). */
export const v = (값: Cell["값"], 서식?: number): Cell =>
서식 === undefined ? { 값 } : { 값, 서식 };
export const f = (식: string, 서식?: number): Cell => (서식 === undefined ? { 식 } : { 식, 서식 });
export interface FakeEngineValues {
[sheetId: string]: { [a1: string]: Scalar };
}
function toFrac(n: number): Frac {
return { n: BigInt(Math.round(n * 1_000_000)), d: 1_000_000n } as unknown as Frac;
}
export function makeCtx(
book: Workbook,
sheetId: string,
values: FakeEngineValues = {},
): SpreadsheetContext & { applied: Command[] } {
const applied: Command[] = [];
const apply = (cmd: Command): void => {
applied.push(cmd);
if (cmd.종류 === "묶음") {
for (const c of cmd.명령) apply(c);
return;
}
if (cmd.종류 === "칸") {
const sheet = book.시트.find((s) => s.id === cmd.시트);
if (!sheet) return;
for (const [key, cell] of Object.entries(cmd.칸)) {
if (cell === null) delete sheet.칸[key];
else sheet.칸[key] = cell;
}
} else if (cmd.종류 === "서식") {
const sheet = book.시트.find((s) => s.id === cmd.시트);
if (!sheet) return;
let idx = book.서식.findIndex((s) => JSON.stringify(s) === JSON.stringify(cmd.바꿀));
if (idx < 0) {
idx = book.서식.length;
book.서식.push(cmd.바꿀 as CellStyle);
}
for (const 범위 of cmd.범위) {
for (let r = 범위.r0; r <= 범위.r1; r++) {
for (let c = 범위.c0; c <= 범위.c1; c++) {
const key = a1(r, c);
sheet.칸[key] = { ...sheet.칸[key], 서식: idx };
}
}
}
} else if (cmd.종류 === "열폭") {
const sheet = book.시트.find((s) => s.id === cmd.시트);
if (!sheet) return;
sheet.열 = sheet.열 ?? {};
for (const c of cmd.열)
sheet.열[colLetters(c)] = { ...sheet.열[colLetters(c)], 폭: cmd.폭 ?? undefined };
}
};
return {
root: {} as HTMLElement,
book,
readOnly: false,
engine: {
update: () => [],
rebuild: () => {},
value: (sheet: string, r: number, c: number): Scalar => values[sheet]?.[a1(r, c)] ?? null,
precedents: () => [],
snapshot: () => ({}),
},
history: {
push: () => {},
undo: () => null,
redo: () => null,
canUndo: () => false,
canRedo: () => false,
clear: () => {},
},
selection: {
시트: sheetId,
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
활성: { r: 0, c: 0 },
기준: { r: 0, c: 0 },
},
sheet: () => book.시트.find((s) => s.id === sheetId)!,
dispatch: (cmd: Command) => apply(cmd),
undo: () => {},
redo: () => {},
select: () => {},
showSheet: () => {},
grid: {
root: {} as HTMLElement,
render: () => {},
invalidate: () => {},
renderSelection: () => {},
setRefHighlights: () => {},
cellBox: () => ({ x: 0, y: 0, w: 0, h: 0 }),
hitTest: () => null,
reveal: () => {},
visibleRange: () => ({ r0: 0, c0: 0, r1: 0, c1: 0 }),
editorSlot: () => ({ layer: {} as HTMLElement, box: { x: 0, y: 0, w: 0, h: 0 } }),
destroy: () => {},
},
editor: {
editing: () => false,
begin: () => {},
commit: () => {},
cancel: () => {},
sync: () => {},
},
applied,
};
}
export function a1(r: number, c: number): string {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
}
function colLetters(c: number): string {
return a1(0, c).replace(/\d+$/, "");
}
export const fracLiteral = toFrac;
@@ -0,0 +1,51 @@
/* =============================================================================
* test_stage2_paste_special.ts — spreadsheet_paste_special 시험.
* 돌리기: npx tsx <이 파일> (`@ui/...` 별칭을 씀 — node 만으로는 안 풀림. F 가 진짜 시험
* 틀을 놓으면 그걸로 옮김.)
* ========================================================================== */
import {
pasteSpecial,
shiftRelativeRefs,
} from "../../../A00_Common/spreadsheet/spreadsheet_paste_special.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v, f } from "./test_stage2_helpers.ts";
import type { ClipBlock } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
assertEqual(shiftRelativeRefs("A1+B2", 1, 1), "B2+C3", "옮김 기본");
assertEqual(shiftRelativeRefs("$A$1+B2", 1, 1), "$A$1+C3", "절대참조는 안 옮김");
assertEqual(shiftRelativeRefs("A1", -5, 0), "#REF!", "행 밖(1 보다 앞)으로 나가면 #REF!");
const sheet = makeSheet("s1", { A1: v(10, 1), B1: v("글") });
const book = makeBook([sheet], [{}, { 굵게: true }]);
const ctx = makeCtx(book, "s1");
const block: ClipBlock = {
rows: 1,
cols: 1,
칸: { "0,0": { 값: 99, 서식: { 밑줄: true } } },
병합: [],
원점: { r: 0, c: 0 },
출처: "aislo",
};
pasteSpecial(ctx, block, { r: 4, c: 4 }, "값");
assertEqual(sheet.칸.E5?.값, 99, "값만 — 값 붙음");
assertEqual(sheet.칸.E5?.서식, undefined, "값만 — 서식은 안 옮음(대상 것 그대로)");
pasteSpecial(ctx, block, { r: 6, c: 6 }, "서식");
assertEqual(book.서식[sheet.칸.G7!.서식!], { 밑줄: true }, "서식만 — 서식이 옮음");
assertEqual(sheet.칸.G7?.값, undefined, "서식만 — 값은 안 옮음");
const formulaBlock: ClipBlock = {
rows: 1,
cols: 1,
칸: { "0,0": { 식: "A1+1" } },
병합: [],
원점: { r: 0, c: 0 },
출처: "aislo",
};
pasteSpecial(ctx, formulaBlock, { r: 2, c: 2 }, "수식");
assertEqual(sheet.칸.C3?.식, "C3+1", "수식만 — 상대참조를 옮겨 붙임(원점 0,0 → 대상 2,2 만큼)");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_paste_special 시험 끝");