- 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
180 lines
7.3 KiB
TypeScript
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 },
|
|
});
|
|
},
|
|
);
|
|
}
|