Files
Aislo/A00_Common/spreadsheet/spreadsheet_refshift.ts
T
eomsangdonandClaude Opus 5.5 736dac4eb5 feat(spreadsheet): 산출근거 스프레드시트 A 엔진 핵심 — 주소 · 파서 · 풀이 · 의존 재계산 · 참조 옮김 · 명령 · 되돌리기
- address · parser — A1 ↔ 행열 · 낱말(편집기 색칠용 자리) · 엑셀 우선순위 나무(-2^2=4 · % · & · 비교) · 나무 → 글
- eval — 분수 풀이 · 오류 값 전파(왼쪽 먼저) · 형 바꾸기(빈 칸 · 수 글 · 참거짓) · 일반 형식 글 15 자리 · 무리수 15 자리
- graph — 의존 그래프 · 고친 칸에 딸린 칸만 재계산 · 순환 = 고리 칸마다 #CYCLE! + 까닭
- refshift — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 · 시트 지우기 참조 옮김(지운 칸 #REF!) · 복사 상대 옮김 · 구글 R1C1 → A1
- commands · history · recalc — 명령마다 되돌림 짝 · 병합 규칙 · 서식 한 벌 표 · 묶음 원자 · Node 한 벌 풀이
- 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py — 엑셀 규칙표 9 묶음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:14:31 +09:00

180 lines
7.3 KiB
TypeScript

/* =============================================================================
* 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(
/("(?:[^"]|"")*"|'(?:[^']|'')*')|(?<![\p{L}\p{N}_.$])R(\[-?\d+\]|\d+)?C(\[-?\d+\]|\d+)?(?![\p{L}\p{N}_.(])/gu,
(whole, quoted: string | undefined, rp: string | undefined, cp: string | undefined) => {
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 },
});
},
);
}