diff --git a/A00_Common/spreadsheet/spreadsheet_autofit.ts b/A00_Common/spreadsheet/spreadsheet_autofit.ts new file mode 100644 index 00000000..aea51e49 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_autofit.ts @@ -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 }); +} diff --git a/A00_Common/spreadsheet/spreadsheet_find.ts b/A00_Common/spreadsheet/spreadsheet_find.ts new file mode 100644 index 00000000..40587200 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_find.ts @@ -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 { + 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(); + 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 = {}; + 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; +} diff --git a/A00_Common/spreadsheet/spreadsheet_format_painter.ts b/A00_Common/spreadsheet/spreadsheet_format_painter.ts new file mode 100644 index 00000000..d682b17c --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_format_painter.ts @@ -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; + 열?: Record; + 행?: Record; + }, + 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; + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_func_help.ts b/A00_Common/spreadsheet/spreadsheet_func_help.ts new file mode 100644 index 00000000..21e6d260 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_func_help.ts @@ -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; +} diff --git a/A00_Common/spreadsheet/spreadsheet_paste_special.ts b/A00_Common/spreadsheet/spreadsheet_paste_special.ts new file mode 100644 index 00000000..dd89e7c0 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_paste_special.ts @@ -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 = {}; + + 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; +} diff --git a/resources/tester/spreadsheet/test_stage2_autofit.ts b/resources/tester/spreadsheet/test_stage2_autofit.ts new file mode 100644 index 00000000..ec62db19 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_autofit.ts @@ -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 시험 끝"); diff --git a/resources/tester/spreadsheet/test_stage2_find.ts b/resources/tester/spreadsheet/test_stage2_find.ts new file mode 100644 index 00000000..abe57baa --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_find.ts @@ -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 시험 끝"); diff --git a/resources/tester/spreadsheet/test_stage2_format_painter.ts b/resources/tester/spreadsheet/test_stage2_format_painter.ts new file mode 100644 index 00000000..7a50b5e9 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_format_painter.ts @@ -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 시험 끝"); diff --git a/resources/tester/spreadsheet/test_stage2_func_help.ts b/resources/tester/spreadsheet/test_stage2_func_help.ts new file mode 100644 index 00000000..4a21be84 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_func_help.ts @@ -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 시험 끝"); diff --git a/resources/tester/spreadsheet/test_stage2_helpers.ts b/resources/tester/spreadsheet/test_stage2_helpers.ts new file mode 100644 index 00000000..df5f1190 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_helpers.ts @@ -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(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, extra: Partial = {}): 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; diff --git a/resources/tester/spreadsheet/test_stage2_paste_special.ts b/resources/tester/spreadsheet/test_stage2_paste_special.ts new file mode 100644 index 00000000..ebab1f73 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_paste_special.ts @@ -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 시험 끝");