/* ============================================================================= * spreadsheet_commands.ts (주인 A) * 명령 적용 — 문서를 그 자리에서 바꾸고 되돌림 짝 · 바뀐 칸 · 통째 다시 여부를 돌려줌. * 병합 규칙(엑셀): 병합 일부를 가르는 칸 쓰기 · 옮기기는 막음(오류 던짐 → 화면이 알림) · * 병합 안에 행열을 넣으면 병합이 늘고 지우면 줆 · 병합하면 왼위 칸 값만 남김. * 서식 표는 같은 서식을 한 번만 둠(칸은 번호) · 안 쓰는 서식 정리는 저장 전에(`compactStyles`). * 행열 · 옮기기 · 시트 지우기는 바뀐 시트를 통째로 되돌림(`시트통째`) — 다른 시트 식 글까지 한 번에. * ========================================================================== */ import { colName, inRange, normRange, parseA1, parseRange, rangeInside, rangesOverlap, rangeToA1, toA1, } from "./spreadsheet_address"; import { addSheet, moveSheet, removeSheet, renameSheet, replaceSheet, } from "./spreadsheet_commands_sheet"; import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift"; import { applyFilter, applySort, shiftFilter } from "./spreadsheet_sortfilter"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { Cell, CellRange, CellStyle, Command, CommandEffect, Sheet, SheetCellAddress, Workbook, } from "./spreadsheet_types"; /** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */ export class CommandError extends Error {} export type Of = Extract; export const clone = (x: T): T => structuredClone(x); export function sheetOf(book: Workbook, id: string): Sheet { const s = book.시트.find((x) => x.id === id); if (!s) throw new CommandError(`없는 시트: ${id}`); return s; } const mergesOf = (s: Sheet): CellRange[] => (s.병합 ?? []).map((t) => parseRange(t)).filter((r): r is CellRange => r !== null); function setMerges(s: Sheet, list: CellRange[]) { if (list.length) s.병합 = list.map(rangeToA1); else delete s.병합; } /** 칸 열쇠 중 범위 안 것 */ function cellsIn(s: Sheet, ranges: CellRange[]): [string, number, number][] { const out: [string, number, number][] = []; for (const a1 of Object.keys(s.칸)) { const at = parseA1(a1); if (at && ranges.some((rg) => inRange(rg, at.r, at.c))) out.push([a1, at.r, at.c]); } return out; } const isFull = (rg: CellRange, rows: boolean) => rows ? rg.c0 === 0 && rg.c1 === MAX_COLS - 1 : rg.r0 === 0 && rg.r1 === MAX_ROWS - 1; function sortKeys(v: unknown): unknown { if (!v || typeof v !== "object" || Array.isArray(v)) return v; const o = v as Record; return Object.fromEntries( Object.keys(o) .sort() .map((k) => [k, sortKeys(o[k])]), ); } const styleKey = (s: CellStyle) => JSON.stringify(sortKeys(s)); function internStyle(book: Workbook, style: CellStyle): number { const key = styleKey(style); const i = book.서식.findIndex((s) => styleKey(s) === key); if (i >= 0) return i; book.서식.push(style); return book.서식.length - 1; } function setComments(s: Sheet, notes: Record) { if (Object.keys(notes).length) s.comments = notes; else delete s.comments; } /** 시트 통째 서식 */ const sheetStyle = (s: Sheet) => s.기본?.서식 ?? 0; /** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 시트 · 0 */ const inherited = (s: Sheet, r: number, c: number) => s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? sheetStyle(s); /** 시트 `기본` 한 칸 — undefined 면 지움 · 빈 `기본` 도 지움 */ function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value: number | undefined) { const d = (s.기본 ??= {}); if (value === undefined) delete d[key]; else d[key] = value; if (!Object.keys(d).length) delete s.기본; } const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c }); /** 바뀐 시트만 통째 되돌림 짝 */ export function restoreChanged(book: Workbook, old: Sheet[]): Command[] { const out: Command[] = []; for (const o of old) { const now = book.시트.find((x) => x.id === o.id); if (now && JSON.stringify(now) !== JSON.stringify(o)) out.push({ 종류: "시트통째", 시트: o }); } return out; } /** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */ export function rewriteAll(book: Workbook, command: Command) { for (const s of book.시트) for (const cell of Object.values(s.칸)) if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book); } // ── 칸 ──────────────────────────────────────────────────────────────────── function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect { const s = sheetOf(book, cmd.시트); const merges = mergesOf(s); const plan: [string, number, number, Cell | null][] = []; for (const [key, cell] of Object.entries(cmd.칸)) { const at = parseA1(key); if (!at) throw new CommandError(`칸 주소 틀림: ${key}`); if (cell && (cell.값 !== undefined || cell.식 !== undefined)) if (merges.some((m) => inRange(m, at.r, at.c) && (m.r0 !== at.r || m.c0 !== at.c))) throw new CommandError("병합한 칸은 왼위 칸에만 값을 넣음"); plan.push([toA1(at.r, at.c), at.r, at.c, cell]); } const prev: Record = {}; for (const [a1, , , cell] of plan) { prev[a1] = s.칸[a1] ? clone(s.칸[a1]) : null; if (cell) s.칸[a1] = clone(cell); else delete s.칸[a1]; } return { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells: plan.map(([, r, c]) => addr(s.id, r, c)), rebuild: false, }; } function clearContents(book: Workbook, cmd: Of<"내용지움">): CommandEffect { const s = sheetOf(book, cmd.시트); const prev: Record = {}; const cells: SheetCellAddress[] = []; for (const [a1, r, c] of cellsIn(s, cmd.범위)) { const cell = s.칸[a1]; if (cell.값 === undefined && cell.식 === undefined) continue; prev[a1] = clone(cell); if (cell.서식 === undefined) delete s.칸[a1]; else s.칸[a1] = { 서식: cell.서식 }; cells.push(addr(s.id, r, c)); } return { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false }; } // ── 서식 ──────────────────────────────────────────────────────────────────── /** 범위 서식 바꿈 — `change(지금 번호) → 새 번호`. 행 · 열 전체는 행 · 열 서식 + 든 칸. */ function restyle( book: Workbook, sheet: string, ranges: CellRange[], change: (i: number) => number, ): CommandEffect { const s = sheetOf(book, sheet); const before = clone(s); const memo = new Map(); const f = (i: number) => { if (!memo.has(i)) memo.set(i, change(i)); return memo.get(i)!; }; const prev: Record = {}; const cells: SheetCellAddress[] = []; let whole = false; const setInfo = (axis: "행" | "열", key: string) => { const table = (s[axis] ??= {}); const info = (table[key] ??= {}); const idx = f(info.서식 ?? sheetStyle(s)); if (idx !== sheetStyle(s)) info.서식 = idx; else delete info.서식; if (!Object.keys(info).length) delete table[key]; }; const touch = (r: number, c: number) => { const a1 = toA1(r, c); if (a1 in prev) return; const cell = s.칸[a1]; prev[a1] = cell ? clone(cell) : null; const next: Cell = { ...cell }; const idx = f(cell?.서식 ?? inherited(s, r, c)); if (idx === inherited(s, r, c)) delete next.서식; else next.서식 = idx; if (Object.keys(next).length) s.칸[a1] = next; else delete s.칸[a1]; cells.push(addr(s.id, r, c)); }; for (const rg of ranges) { const fullRows = isFull(rg, true); const fullCols = isFull(rg, false); if (fullRows && fullCols) { whole = true; const idx = f(sheetStyle(s)); setSheetDefault(s, "서식", idx || undefined); for (const k of Object.keys(s.행 ?? {})) setInfo("행", k); for (const k of Object.keys(s.열 ?? {})) setInfo("열", k); } else if (fullRows || fullCols) { whole = true; for (let i = fullRows ? rg.r0 : rg.c0; i <= (fullRows ? rg.r1 : rg.c1); i++) setInfo(fullRows ? "행" : "열", fullRows ? String(i + 1) : colName(i)); } else { if ((rg.r1 - rg.r0 + 1) * (rg.c1 - rg.c0 + 1) > 1_000_000) throw new CommandError("범위가 너무 큼"); for (let r = rg.r0; r <= rg.r1; r++) for (let c = rg.c0; c <= rg.c1; c++) touch(r, c); continue; } for (const [, r, c] of cellsIn(s, [rg])) touch(r, c); } if (s.행 && !Object.keys(s.행).length) delete s.행; if (s.열 && !Object.keys(s.열).length) delete s.열; return whole ? { undo: { 종류: "시트통째", 시트: before }, cells, rebuild: true } : { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false }; } function patchStyle(book: Workbook, cmd: Of<"서식">): CommandEffect { return restyle(book, cmd.시트, cmd.범위, (i) => { const next: Record = { ...book.서식[i] }; for (const [k, v] of Object.entries(cmd.바꿀)) { if (v === null) delete next[k]; else if (v !== undefined) next[k] = clone(v); } return internStyle(book, next as CellStyle); }); } // ── 행열 넣기 · 지우기 ────────────────────────────────────────────────────── function insertDelete( book: Workbook, cmd: Of<"행넣기" | "행지우기" | "열넣기" | "열지우기">, ): CommandEffect { const s = sheetOf(book, cmd.시트); const rows = cmd.종류.startsWith("행"); const insert = cmd.종류.endsWith("넣기"); const max = rows ? MAX_ROWS : MAX_COLS; const { at, 수: n } = cmd; if (n < 1 || at < 0 || at >= max || (!insert && at + n > max)) throw new CommandError("넣기 · 지우기 자리가 틀림"); const pos = (a1: string) => { const p = parseA1(a1)!; return rows ? p.r : p.c; }; if (insert && Object.keys(s.칸).some((a1) => pos(a1) + n >= max)) throw new CommandError("격자 끝 칸이 밀려 나감 — 끝 쪽 칸을 먼저 지움"); const old = book.시트.map(clone); rewriteAll(book, cmd); const span = (i: number) => shiftSpan(i, i, insert, at, n, max)?.[0]; const cells: Record = {}; for (const [a1, cell] of Object.entries(s.칸)) { const p = parseA1(a1)!; const i = span(rows ? p.r : p.c); if (i !== undefined) cells[rows ? toA1(i, p.c) : toA1(p.r, i)] = cell; } s.칸 = cells; if (s.comments) { const notes: Record = {}; for (const [a1, text] of Object.entries(s.comments)) { const p = parseA1(a1); const i = p && span(rows ? p.r : p.c); if (p && i !== undefined && i !== null) notes[rows ? toA1(i, p.c) : toA1(p.r, i)] = text; } setComments(s, notes); } const shiftRange = (m: CellRange) => { const sp = rows ? shiftSpan(m.r0, m.r1, insert, at, n, max) : shiftSpan(m.c0, m.c1, insert, at, n, max); return sp && (rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] }); }; const merges: CellRange[] = []; for (const m of mergesOf(s)) { const next = shiftRange(m); if (next && (next.r0 !== next.r1 || next.c0 !== next.c1)) merges.push(next); } setMerges(s, merges); // 이름 범위도 따라감 · 범위가 통째로 지워진 이름은 뺌(엑셀은 `#REF!` 이름으로 남김 — 여기선 식이 `#NAME?`) if (s.이름정의) s.이름정의 = s.이름정의.flatMap((d) => { const 범위 = shiftRange(d.범위); return 범위 ? [{ ...d, 범위 }] : []; }); const axis = rows ? "행" : "열"; const table = s[axis]; if (table) { const next: Record = {}; for (const [k, v] of Object.entries(table)) { const i = span(rows ? Number(k) - 1 : parseA1(`${k}1`)!.c); if (i !== undefined) next[rows ? String(i + 1) : colName(i)] = v; } if (Object.keys(next).length) s[axis] = next; else delete s[axis]; } // 넣은 줄은 위(왼) 줄 서식 · 폭 · 높이를 따라감(엑셀 「위와 같은 서식」) — 첫 줄 앞 넣기는 안 따라감 if (insert && at > 0) { for (const [a1, cell] of Object.entries(s.칸)) { const p = parseA1(a1)!; if ((rows ? p.r : p.c) !== at - 1 || cell.서식 === undefined) continue; for (let k = 0; k < n; k++) s.칸[rows ? toA1(at + k, p.c) : toA1(p.r, at + k)] = { 서식: cell.서식 }; } const info = s[axis]?.[rows ? String(at) : colName(at - 1)]; if (info) { const { 숨김: _hidden, ...look } = info; if (Object.keys(look).length) for (let k = 0; k < n; k++) s[axis]![rows ? String(at + k + 1) : colName(at + k)] = { ...look }; } } shiftFilter(s, rows, insert, at, n); const key = rows ? "행" : "열"; const frozen = s.틀고정?.[key]; if (frozen && at < frozen) s.틀고정![key] = insert ? frozen + n : frozen - (Math.min(frozen, at + n) - at); return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true }; } // ── 옮기기 (잘라 붙이기) ───────────────────────────────────────────────────── function move(book: Workbook, cmd: Of<"옮기기">): CommandEffect { const s = sheetOf(book, cmd.시트); const t = sheetOf(book, cmd.대상시트); const src = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1); const dr = cmd.r - src.r0; const dc = cmd.c - src.c0; const dst: CellRange = { r0: cmd.r, c0: cmd.c, r1: src.r1 + dr, c1: src.c1 + dc }; if (dst.r0 < 0 || dst.c0 < 0 || dst.r1 >= MAX_ROWS || dst.c1 >= MAX_COLS) throw new CommandError("옮길 자리가 격자 밖"); const cuts = (list: CellRange[], rg: CellRange) => list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg)); if (cuts(mergesOf(s), src) || cuts(mergesOf(t), dst)) throw new CommandError("병합 일부를 가르는 옮기기는 못 함"); if (s === t && dr === 0 && dc === 0) return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false }; const old = book.시트.map(clone); const moved: [number, number, Cell][] = []; for (const [a1, r, c] of cellsIn(s, [src])) { const cell = s.칸[a1]; if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, cmd, book, t.id); moved.push([r + dr, c + dc, cell]); delete s.칸[a1]; } rewriteAll(book, cmd); for (const [a1] of cellsIn(t, [dst])) delete t.칸[a1]; for (const [r, c, cell] of moved) t.칸[toA1(r, c)] = cell; // 메모도 칸과 같이 — 덮은 자리 메모는 지움 const carriedNotes: [string, string][] = []; const srcNotes = { ...s.comments }; for (const [a1, text] of Object.entries(srcNotes)) { const p = parseA1(a1); if (p && inRange(src, p.r, p.c)) { carriedNotes.push([toA1(p.r + dr, p.c + dc), text]); delete srcNotes[a1]; } } setComments(s, srcNotes); const dstNotes = { ...t.comments }; for (const a1 of Object.keys(dstNotes)) { const p = parseA1(a1); if (p && inRange(dst, p.r, p.c)) delete dstNotes[a1]; } setComments(t, { ...dstNotes, ...Object.fromEntries(carriedNotes) }); const sm = mergesOf(s); const carried = sm.filter((m) => rangeInside(m, src)); setMerges( s, sm.filter((m) => !rangeInside(m, src)), ); setMerges(t, [ ...mergesOf(t).filter((m) => !rangeInside(m, dst)), ...carried.map((m) => ({ r0: m.r0 + dr, c0: m.c0 + dc, r1: m.r1 + dr, c1: m.c1 + dc })), ]); return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true }; } // ── 병합 ──────────────────────────────────────────────────────────────────── function merge(book: Workbook, cmd: Of<"병합">): CommandEffect { const s = sheetOf(book, cmd.시트); const rg = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1); if (rg.r0 === rg.r1 && rg.c0 === rg.c1) return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false }; const list = mergesOf(s); if (list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg))) throw new CommandError("다른 병합과 걸침"); const absorbed = list.filter((m) => rangeInside(m, rg)); const prev: Record = {}; const cells: SheetCellAddress[] = []; for (const [a1, r, c] of cellsIn(s, [rg])) { const cell = s.칸[a1]; if ((r === rg.r0 && c === rg.c0) || (cell.값 === undefined && cell.식 === undefined)) continue; prev[a1] = clone(cell); if (cell.서식 === undefined) delete s.칸[a1]; else s.칸[a1] = { 서식: cell.서식 }; cells.push(addr(s.id, r, c)); } setMerges(s, [...list.filter((m) => !rangeInside(m, rg)), rg]); const undo: Command[] = [ { 종류: "병합풀기", 시트: s.id, 범위: rg }, ...absorbed.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m })), { 종류: "칸", 시트: s.id, 칸: prev }, ]; return { undo: { 종류: "묶음", 명령: undo }, cells, rebuild: false }; } function unmerge(book: Workbook, cmd: Of<"병합풀기">): CommandEffect { const s = sheetOf(book, cmd.시트); const list = mergesOf(s); const gone = list.filter((m) => rangesOverlap(m, cmd.범위)); setMerges( s, list.filter((m) => !rangesOverlap(m, cmd.범위)), ); const undo = gone.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m })); return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: false }; } // ── 행 · 열 모양 ───────────────────────────────────────────────────────────── /** 행 · 열 정보 한 속성 바꿈 → 원래 값끼리 묶은 되돌림 짝 */ function setAxis( s: Sheet, axis: "행" | "열", idx: number[], prop: "폭" | "높이" | "숨김" | "묶음", value: V | null, undoOf: (idx: number[], prev: V | null) => Command, ): Command { const table = ((s as unknown as Record>>)[axis] ??= {}); const groups = new Map(); for (const i of idx) { const k = axis === "열" ? colName(i) : String(i + 1); const prev = table[k]?.[prop]; const g = JSON.stringify(prev ?? null); groups.set(g, [...(groups.get(g) ?? []), i]); const info = (table[k] ??= {}); if (value === null || value === false) delete info[prop]; else info[prop] = value; if (!Object.keys(info).length) delete table[k]; } if (!Object.keys(table).length) delete s[axis]; return { 종류: "묶음", 명령: [...groups].map(([g, list]) => undoOf(list, JSON.parse(g))) }; } /** 모두 고르기 폭 · 높이 — 시트 `기본` 에 두고 줄마다 값은 지움(엑셀 기본 폭 · 높이) */ function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): CommandEffect { const before = clone(s); const prop = axis === "열" ? "폭" : "높이"; setSheetDefault(s, axis === "열" ? "열폭" : "행높이", value ?? undefined); const table = s[axis] as Record> | undefined; for (const [k, info] of Object.entries(table ?? {})) { delete info[prop]; if (!Object.keys(info).length) delete table![k]; } if (table && !Object.keys(table).length) delete s[axis]; return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false }; } function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect { const done: CommandEffect[] = []; try { for (const c of cmd.명령) done.push(applyCommand(book, c)); } catch (e) { for (const d of done.reverse()) applyCommand(book, d.undo); throw e; } return { undo: { 종류: "묶음", 명령: done.map((d) => d.undo).reverse() }, cells: done.flatMap((d) => d.cells), rebuild: done.some((d) => d.rebuild), }; } // ── 들머리 ────────────────────────────────────────────────────────────────── export function applyCommand(book: Workbook, command: Command): CommandEffect { const plain = (undo: Command): CommandEffect => ({ undo, cells: [], rebuild: false }); switch (command.종류) { case "칸": return setCells(book, command); case "내용지움": return clearContents(book, command); case "서식": return patchStyle(book, command); case "서식지움": return restyle(book, command.시트, command.범위, () => 0); case "행넣기": case "행지우기": case "열넣기": case "열지우기": return insertDelete(book, command); case "옮기기": return move(book, command); case "병합": return merge(book, command); case "병합풀기": return unmerge(book, command); case "열폭": { const s = sheetOf(book, command.시트); if (new Set(command.열).size >= MAX_COLS) return sheetSize(s, "열", command.폭); return plain( setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({ 종류: "열폭", 시트: s.id, 열, 폭, })), ); } case "행높이": { const s = sheetOf(book, command.시트); if (new Set(command.행).size >= MAX_ROWS) return sheetSize(s, "행", command.높이); return plain( setAxis(s, "행", command.행, "높이", command.높이, (행, 높이) => ({ 종류: "행높이", 시트: s.id, 행, 높이, })), ); } case "숨김": { const s = sheetOf(book, command.시트); const { 축 } = command; return plain( setAxis(s, 축, command.번호, "숨김", command.숨김, (번호, prev) => ({ 종류: "숨김", 시트: s.id, 축, 번호, 숨김: prev ?? false, })), ); } case "묶음설정": { const s = sheetOf(book, command.시트); const { 축 } = command; return plain( setAxis(s, 축, command.번호, "묶음", command.묶음, (번호, prev) => ({ 종류: "묶음설정", 시트: s.id, 축, 번호, 묶음: prev, })), ); } case "틀고정": { const s = sheetOf(book, command.시트); const prev = { 행: s.틀고정?.행 ?? 0, 열: s.틀고정?.열 ?? 0 }; const next: Sheet["틀고정"] = {}; if (command.행 > 0) next.행 = command.행; if (command.열 > 0) next.열 = command.열; if (Object.keys(next).length) s.틀고정 = next; else delete s.틀고정; return plain({ 종류: "틀고정", 시트: s.id, ...prev }); } case "눈금선": { const s = sheetOf(book, command.시트); const prev = s.보기?.눈금선 ?? true; if (command.보임) { delete s.보기?.눈금선; if (s.보기 && !Object.keys(s.보기).length) delete s.보기; } else (s.보기 ??= {}).눈금선 = false; return plain({ 종류: "눈금선", 시트: s.id, 보임: prev }); } case "시트더하기": return addSheet(book, command); case "시트지우기": return removeSheet(book, command); case "시트이름": return renameSheet(book, command); case "시트옮기기": return moveSheet(book, command); case "시트통째": return replaceSheet(book, command); case "정렬": return applySort(book, command); case "필터": return applyFilter(book, command); case "묶음": return batch(book, command); } } /** 빈 통합문서 — 시트 하나(`s1` · 이름은 부른 쪽) · 서식 [{}] */ export function emptyWorkbook(column: string, sheetName: string): Workbook { return { 종류: "통합문서", 판: 1, 열: column, 서식: [{}], 시트: [emptySheet("s1", sheetName)], 활성: "s1", }; } export function emptySheet(id: string, name: string): Sheet { return { id, 이름: name, 칸: {} }; } /** 안 쓰는 서식을 빼고 번호를 다시 매김 — 저장 직전(되돌리기 짝은 안 만듦) */ export function compactStyles(book: Workbook): void { const next: CellStyle[] = []; const byKey = new Map(); const remap = new Map(); const map = (i: number) => { let j = remap.get(i); if (j === undefined) { const style = book.서식[i] ?? {}; const k = styleKey(style); j = byKey.get(k); if (j === undefined) { j = next.push(style) - 1; byKey.set(k, j); } remap.set(i, j); } return j; }; map(0); for (const s of book.시트) { for (const cell of Object.values(s.칸)) if (cell.서식 !== undefined) cell.서식 = map(cell.서식); for (const info of [...Object.values(s.행 ?? {}), ...Object.values(s.열 ?? {}), s.기본 ?? {}]) if (info.서식 !== undefined) info.서식 = map(info.서식); } book.서식 = next; }