/* ============================================================================= * spreadsheet_refshift.ts (주인 A) * 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 · 시트 지우기 때 식 글을 고쳐 적음 * (엑셀 규칙) · 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1. * 지운 칸 · 격자 밖으로 나간 참조는 `#REF!` · 범위는 남은 만큼 줄어듦(끝까지 지우면 `#REF!`). * 낱말 단위로 참조만 바꿔 끼움 — 빈칸 · 괄호 · 수 글은 적힌 그대로 둠. * * 엑셀 규칙표: * 넣기 — 넣는 자리 이후를 가리키면 밀림 · 범위가 넣는 자리를 품으면 늘어남(첫 행 앞 넣기는 통째 밀림) * 지우기 — 지운 칸 하나 = `#REF!` · 범위는 남은 만큼 · 전부 지우면 `#REF!` · `$` 도 똑같이 옮김 * 옮기기 — 옮긴 덩어리 안을 가리키던 참조(범위는 통째 들 때만)는 따라감 · 덮어쓴 자리를 가리키면 `#REF!` * 복사 — 상대만 (dr, dc) · 행 · 열 전체 범위는 그 축을 안 옮김 * ========================================================================== */ import { quoteSheet, rangeInside } from "./spreadsheet_address"; import { parseRef, renderRef, tokenize } from "./spreadsheet_parser"; import type { RefParts } from "./spreadsheet_parser"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellAddress, CellRange, Command, Workbook } from "./spreadsheet_types"; const REF_ERR = "#REF!"; /** 참조 낱말만 `fn` 으로 바꿔 끼움(null = `#REF!`) */ function mapRefs(formula: string, fn: (p: RefParts) => RefParts | string | null): string { let out = ""; for (const t of tokenize(formula)) { const p = t.kind === "ref" ? parseRef(t.text) : null; if (!p) { out += t.text; continue; } const q = fn(p); out += q === null ? REF_ERR : typeof q === "string" ? q : renderRef(q); } return out; } /** 넣기 · 지우기 한 축 — [lo, hi] → 새 [lo, hi] · 다 지워지면 null */ export function shiftSpan( lo: number, hi: number, insert: boolean, at: number, n: number, max: number, ): [number, number] | null { if (insert) { if (lo >= at) [lo, hi] = [lo + n, hi + n]; else if (hi >= at) hi += n; if (lo >= max) return null; return [lo, Math.min(hi, max - 1)]; } const end = at + n - 1; if (lo >= at && hi <= end) return null; return [lo < at ? lo : lo > end ? lo - n : at, hi < at ? hi : hi > end ? hi - n : at - 1]; } const sheetIdOf = (book: Workbook, name: string) => book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null; const box = (p: RefParts): CellRange => ({ r0: Math.min(p.a.r, p.b.r), c0: Math.min(p.a.c, p.b.c), r1: Math.max(p.a.r, p.b.r), c1: Math.max(p.a.c, p.b.c), }); /** 행열 · 옮기기 · 시트이름 · 시트지우기 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글. * `book` 은 명령 적용 **전** 문서(시트 이름으로 id 를 찾음). `newHost` = 옮기기로 식 칸 자체가 간 시트. */ export function shiftForCommand( formula: string, host: string, command: Command, book: Workbook, newHost: string = host, ): string { if (command.종류 === "묶음") return command.명령.reduce((f, c) => shiftForCommand(f, host, c, book, newHost), formula); const target = (p: RefParts) => (p.sheet === null ? host : sheetIdOf(book, p.sheet)); const nameOf = (id: string) => book.시트.find((s) => s.id === id)?.이름 ?? ""; switch (command.종류) { case "행넣기": case "행지우기": case "열넣기": case "열지우기": { const rows = command.종류.startsWith("행"); const insert = command.종류.endsWith("넣기"); return mapRefs(formula, (p) => { if (target(p) !== command.시트) return p; if (rows ? p.kind === "cols" : p.kind === "rows") return p; // 다른 축 전체 범위 const [lo, hi] = rows ? [p.a.r, p.b.r].sort((x, y) => x - y) : [p.a.c, p.b.c].sort((x, y) => x - y); const span = shiftSpan(lo, hi, insert, command.at, command.수, rows ? MAX_ROWS : MAX_COLS); if (!span) return null; const q = structuredClone(p); // 적힌 차례(뒤집힌 범위)는 바로 세움 — 엑셀도 다시 적을 때 바로 세움 if (rows) [q.a.r, q.b.r] = span; else [q.a.c, q.b.c] = span; return q; }); } case "옮기기": { const src = command.범위; const dr = command.r - src.r0; const dc = command.c - src.c0; const dst: CellRange = { r0: command.r, c0: command.c, r1: src.r1 + dr, c1: src.c1 + dc }; return mapRefs(formula, (p) => { const t = target(p); if (t === null) return p; const b = box(p); let to = t; const q = structuredClone(p); if (t === command.시트 && p.kind !== "cols" && p.kind !== "rows" && rangeInside(b, src)) { to = command.대상시트; for (const pt of [q.a, q.b]) { pt.r += dr; pt.c += dc; } } else if (t === command.대상시트 && rangeInside(b, dst)) return null; if (to === t && newHost === host) return q; q.prefix = to === newHost ? "" : `${quoteSheet(nameOf(to))}!`; return q; }); } case "시트이름": return mapRefs(formula, (p) => p.sheet !== null && target(p) === command.시트 ? { ...p, prefix: `${quoteSheet(command.이름)}!` } : p, ); case "시트지우기": return mapRefs(formula, (p) => (p.sheet !== null && target(p) === command.시트 ? null : p)); default: return formula; } } /** 복사 · 채우기 — 상대 참조만 (dr, dc) 옮김 · 격자 밖은 `#REF!` */ export function moveFormula(formula: string, dr: number, dc: number): string { return mapRefs(formula, (p) => { const q = structuredClone(p); for (const pt of [q.a, q.b]) { if (!pt.absR && p.kind !== "cols") pt.r += dr; if (!pt.absC && p.kind !== "rows") pt.c += dc; if (pt.r < 0 || pt.r >= MAX_ROWS || pt.c < 0 || pt.c >= MAX_COLS) return null; } return q; }); } /** 구글 `data-sheets-formula`(`=2*R[0]C[-1]`) → 붙일 자리 기준 A1 식(앞 `=` 뺌) */ export function r1c1ToA1(formula: string, at: CellAddress): string { const text = formula.startsWith("=") ? formula.slice(1) : formula; const axis = (part: string | undefined, base: number): [number, boolean] => part === undefined || part === "" ? [base, false] : part.startsWith("[") ? [base + Number(part.slice(1, -1)), false] : [Number(part) - 1, true]; // 글 · 따옴표 시트 이름은 건너뜀 · 이름 한가운데(`ROUND`)는 안 건드림 return text.replace( /("(?:[^"]|"")*"|'(?:[^']|'')*')|(? { if (quoted) return whole; const [r, absR] = axis(rp, at.r); const [c, absC] = axis(cp, at.c); if (r < 0 || r >= MAX_ROWS || c < 0 || c >= MAX_COLS) return REF_ERR; return renderRef({ prefix: "", sheet: null, kind: "cell", a: { r, c, absR, absC }, b: { r, c, absR, absC }, }); }, ); }