diff --git a/.gitignore b/.gitignore index 0f615447..3853c111 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,8 @@ config/corridor_node/ config/server_calc_node/ # 구조물도 식 풀이 번들 — `npm run build:formula` 산출물(2026-09-13). config/formula_node/ +# 산출근거 스프레드시트 재계산 번들 — `npm run build:spreadsheet` 산출물. +config/spreadsheet_node/ # graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름 # (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」). diff --git a/A00_Common/spreadsheet/spreadsheet.css b/A00_Common/spreadsheet/spreadsheet.css new file mode 100644 index 00000000..1266725d --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet.css @@ -0,0 +1,111 @@ +/* spreadsheet.css (주인 D) — 부품 틀 · 주소 상자 · 수식 입력줄 · 칸 편집기 · 채우기 미리보기 · 알림 */ + +.ss { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + font-size: 13px; + color: #1f2328; + background: #fff; +} + +.ss-grid-host { + flex: 1 1 auto; + min-height: 0; +} + +.ss-bar { + display: flex; + flex: none; + align-items: stretch; + height: 26px; + border-bottom: 1px solid #d0d7de; +} + +.ss-addr { + width: 96px; + padding: 0 6px; + border: none; + border-right: 1px solid #d0d7de; + font: inherit; + outline: none; +} + +.ss-addr:focus { + box-shadow: inset 0 0 0 2px #217346; +} + +.ss-fx { + align-self: center; + padding: 0 8px; + color: #6e7781; + font-style: italic; + user-select: none; +} + +.ss-formula { + flex: 1; + padding: 4px 6px; + border: none; + border-left: 1px solid #d0d7de; + font: inherit; + line-height: 18px; + white-space: pre; + overflow: hidden; + resize: none; + outline: none; +} + +.ss--readonly .ss-formula { + color: #57606a; +} + +/* 칸 편집기 = 숨은 textarea — 쉴 때는 활성 칸 자리에 투명하게(한글 조합 창 자리) */ +.ss-input { + position: absolute; + z-index: 5; + box-sizing: border-box; + margin: 0; + padding: 1px 3px; + border: 2px solid #217346; + background: #fff; + color: #000; + font: inherit; + line-height: 1.25; + white-space: pre; + overflow: hidden; + resize: none; + outline: none; +} + +.ss-input--idle { + width: 1px; + height: 1px; + min-width: 0 !important; + min-height: 0 !important; + padding: 0; + border: 0; + opacity: 0; + pointer-events: none; +} + +.ss-fill-preview { + position: absolute; + z-index: 4; + box-sizing: border-box; + border: 1px dashed #57606a; + pointer-events: none; +} + +.ss-toast { + position: absolute; + right: 12px; + bottom: 40px; + z-index: 20; + padding: 6px 10px; + border-radius: 4px; + background: #24292f; + color: #fff; +} diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 8374051b..061d08b7 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -6,10 +6,38 @@ * 키 · 마우스)을 잇음. 한글 조합 = 숨은 textarea 에 늘 초점 · compositionstart 로 편집 시작(keydown 글자 방식 금지). * ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게). * 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂). - * 0 계약 머리 — 몸은 D 가 채움. + * 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E). * ========================================================================== */ -import type { CalcValues, Workbook } from "./spreadsheet_types"; +import "./spreadsheet.css"; +import { el } from "@ui/ui_template_elements"; +import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address"; +import { attachClipboard } from "./spreadsheet_clipboard"; +import { applyCommand, CommandError } from "./spreadsheet_commands"; +import { createEditor } from "./spreadsheet_editor"; +import { createCalcEngine } from "./spreadsheet_graph"; +import { createGrid } from "./spreadsheet_grid"; +import { createHistory } from "./spreadsheet_history"; +import { attachKeys } from "./spreadsheet_keys"; +import { attachMenu } from "./spreadsheet_menu"; +import { attachMouse } from "./spreadsheet_mouse"; +import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection"; +import { mountTabs } from "./spreadsheet_tabs"; +import { mountToolbar } from "./spreadsheet_toolbar"; +import type { + CalcValues, + Command, + CommandEffect, + SheetCellAddress, + Workbook, +} from "./spreadsheet_types"; +import type { + EditorHandle, + GridHandle, + PartHandle, + Selection, + SpreadsheetContext, +} from "./spreadsheet_view_types"; export type { Workbook, CalcValues } from "./spreadsheet_types"; @@ -32,14 +60,197 @@ export interface SpreadsheetHandle { destroy(): void; } -const todo = (): never => { - throw new Error("spreadsheet: 아직 없음(D)"); +/** 이어 붙이는 남의 부품 — 시험 틀이 빈 몸 부품을 가짜로 바꿔 끼우는 자리(화면 코드는 그대로 씀) */ +export const parts = { + createCalcEngine, + createHistory, + applyCommand, + createGrid, + mountToolbar, + mountTabs, + attachMenu, + attachClipboard, }; +/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */ +function farCorner(sel: Selection): { r: number; c: number } { + const g = sel.범위[0]; + const a = sel.기준; + const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last; + return { + r: whole(g.r0, g.r1, LAST_R) + ? sel.활성.r + : Math.abs(g.r1 - a.r) >= Math.abs(a.r - g.r0) + ? g.r1 + : g.r0, + c: whole(g.c0, g.c1, LAST_C) + ? sel.활성.c + : Math.abs(g.c1 - a.c) >= Math.abs(a.c - g.c0) + ? g.c1 + : g.c0, + }; +} + export function createSpreadsheet( - _host: HTMLElement, - _book: Workbook, - _opts: SpreadsheetOptions, + host: HTMLElement, + book: Workbook, + opts: SpreadsheetOptions = {}, ): SpreadsheetHandle { - return todo(); + const readOnly = !!opts.readOnly; + const root = el("div", { className: readOnly ? "ss ss--readonly" : "ss" }); + host.append(root); + const firstSheet = (b: Workbook) => b.시트.find((s) => s.id === b.활성) ?? b.시트[0]; + /** 시트마다 마지막 고름(탭을 오가도 그대로) */ + const memory = new Map(); + + const ctx: SpreadsheetContext = { + root, + book, + readOnly, + engine: parts.createCalcEngine(book), + history: parts.createHistory(), + selection: selectCell(firstSheet(book), 0, 0), + sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0], + dispatch, + undo: () => replay(ctx.history.undo()), + redo: () => replay(ctx.history.redo()), + select, + showSheet, + grid: null as unknown as GridHandle, + editor: null as unknown as EditorHandle, + }; + + const grid = parts.createGrid(ctx); + ctx.grid = grid; + const ed = createEditor(ctx); + ctx.editor = ed; + const toolbar: PartHandle | null = readOnly ? null : parts.mountToolbar(ctx); + const tabs = parts.mountTabs(ctx); + const menu: PartHandle | null = readOnly ? null : parts.attachMenu(ctx); + const clip = parts.attachClipboard(ctx); + const others = [toolbar, tabs, menu, clip].filter((p): p is PartHandle => !!p); + + grid.root.classList.add("ss-grid-host"); + for (const node of [toolbar?.root, ed.bar, grid.root, tabs.root]) if (node) root.append(node); + const detachKeys = attachKeys(ctx, ed); + const detachMouse = attachMouse(ctx, ed); + const toast = el("div", { className: "ss-toast" }); + let toastTimer = 0; + + grid.render(); + select(ctx.selection); + + function notify(message: string): void { + toast.textContent = message; + root.append(toast); + clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => toast.remove(), 3000); + } + + function apply(command: Command): CommandEffect | null { + try { + return parts.applyCommand(ctx.book, command); + } catch (e) { + if (e instanceof CommandError) return (notify(e.message), null); + throw e; + } + } + + function dispatch(command: Command): void { + if (readOnly) return; + const eff = apply(command); + if (!eff) return; + ctx.history.push(command, eff.undo); + after(command, eff); + } + + function replay(command: Command | null): void { + if (readOnly || !command || ed.editing()) return; + const eff = apply(command); + if (eff) after(command, eff); + } + + function after(command: Command, eff: CommandEffect): void { + if (eff.rebuild) { + ctx.engine.rebuild(ctx.book); + if (!ctx.book.시트.some((s) => s.id === ctx.selection.시트)) { + const s = firstSheet(ctx.book); + ctx.selection = memory.get(s.id) ?? selectCell(s, 0, 0); + } + grid.render(); + } else { + const again = ctx.engine.update(eff.cells); + if (command.종류 === "칸") grid.invalidate([...again, ...touched(command.시트, command.칸)]); + else grid.render(); + } + select(ctx.selection); + opts.onChange?.(structuredClone(ctx.book)); + } + + /** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */ + function touched(sheet: string, cells: Record): SheetCellAddress[] { + const out: SheetCellAddress[] = []; + for (const key of Object.keys(cells)) { + const a = parseA1(key); + if (a) out.push({ 시트: sheet, ...a }); + } + return out; + } + + function select(selection: Selection, reveal = false): void { + ctx.selection = selection; + memory.set(selection.시트, selection); + grid.renderSelection(); + if (reveal) { + const far = farCorner(selection); + grid.reveal(far.r, far.c); + } + ed.sync(); + for (const p of others) p.refresh(); + const g = selection.범위[0]; + opts.onSelect?.({ + 시트: selection.시트, + 칸: toA1(selection.활성.r, selection.활성.c), + 범위: g ? rangeToA1(g) : toA1(selection.활성.r, selection.활성.c), + }); + } + + function showSheet(sheetId: string): void { + const sheet = ctx.book.시트.find((s) => s.id === sheetId); + if (!sheet || sheetId === ctx.selection.시트) return; + ed.commit(); + ctx.book.활성 = sheetId; + ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0); + grid.render(); + select(ctx.selection, true); + ed.focus(); + } + + return { + getDoc: () => structuredClone(ctx.book), + setDoc(next: Workbook) { + ed.cancel(); + ctx.book = next; + ctx.engine.rebuild(next); + ctx.history.clear(); + memory.clear(); + ctx.selection = selectCell(firstSheet(next), 0, 0); + grid.render(); + select(ctx.selection); + }, + recalc() { + ctx.engine.rebuild(ctx.book); + grid.render(); + return ctx.engine.snapshot(); + }, + destroy() { + clearTimeout(toastTimer); + detachKeys(); + detachMouse(); + for (const p of others) p.destroy(); + ed.destroy(); + grid.destroy(); + root.remove(); + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_address.ts b/A00_Common/spreadsheet/spreadsheet_address.ts index fbe436f5..8e8c38bd 100644 --- a/A00_Common/spreadsheet/spreadsheet_address.ts +++ b/A00_Common/spreadsheet/spreadsheet_address.ts @@ -1,55 +1,115 @@ /* ============================================================================= * spreadsheet_address.ts (주인 A) * 주소 — A1 ↔ 행열(0 부터) · 범위 글 · 열 글자 · 시트 이름 따옴표 · Map 열쇠. - * 0 계약 머리 — 몸은 A 가 채움. 계약은 `spreadsheet_types.ts`. + * 계약은 `spreadsheet_types.ts`. * ========================================================================== */ +import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellAddress, CellRange } from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_address: 아직 없음(A)"); -}; - /** 0 → `A` · 27 → `AB` */ -export function colName(_c: number): string { - return todo(); +export function colName(c: number): string { + let s = ""; + for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) { + s = String.fromCharCode(65 + ((n - 1) % 26)) + s; + } + return s; } /** `AB` → 27 · 못 읽으면 -1 (대소문자 가리지 않음) */ -export function colIndex(_letters: string): number { - return todo(); +export function colIndex(letters: string): number { + if (!/^[A-Za-z]{1,3}$/.test(letters)) return -1; + let n = 0; + for (const ch of letters.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64; + return n <= MAX_COLS ? n - 1 : -1; +} + +/** 행 글(1 부터) → 0 부터 · 못 읽으면 -1 */ +export function rowIndex(digits: string): number { + const n = /^\d{1,7}$/.test(digits) ? Number(digits) : 0; + return n >= 1 && n <= MAX_ROWS ? n - 1 : -1; } /** (30, 4) → `E31` */ -export function toA1(_r: number, _c: number): string { - return todo(); +export function toA1(r: number, c: number): string { + return colName(c) + (r + 1); } /** `E31` · `$E$31` → {r:30, c:4} · 못 읽으면 null */ -export function parseA1(_text: string): CellAddress | null { - return todo(); +export function parseA1(text: string): CellAddress | null { + const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(text.trim()); + if (!m) return null; + const c = colIndex(m[1]); + const r = rowIndex(m[2]); + return c < 0 || r < 0 ? null : { r, c }; } /** 칸 하나면 `E31` · 아니면 `C3:AD3` · 행 전체 `3:3` · 열 전체 `A:A` */ -export function rangeToA1(_range: CellRange): string { - return todo(); +export function rangeToA1(range: CellRange): string { + const { r0, c0, r1, c1 } = range; + if (c0 === 0 && c1 === MAX_COLS - 1) return `${r0 + 1}:${r1 + 1}`; + if (r0 === 0 && r1 === MAX_ROWS - 1) return `${colName(c0)}:${colName(c1)}`; + const a = toA1(r0, c0); + return r0 === r1 && c0 === c1 ? a : `${a}:${toA1(r1, c1)}`; +} + +/** 닫힌 범위로 세움(뒤집힌 것 바로) */ +export function normRange(r0: number, c0: number, r1: number, c1: number): CellRange { + return { + r0: Math.min(r0, r1), + c0: Math.min(c0, c1), + r1: Math.max(r0, r1), + c1: Math.max(c0, c1), + }; } /** `C3:AD3` · `E31` · `A:A` · `3:5` → 범위(뒤집힌 글은 바로 세움) · 못 읽으면 null */ -export function parseRange(_text: string): CellRange | null { - return todo(); +export function parseRange(text: string): CellRange | null { + const parts = text.trim().split(":"); + if (parts.length > 2) return null; + const [a, b = a] = parts; + const p = parseA1(a); + const q = parseA1(b); + if (p && q) return normRange(p.r, p.c, q.r, q.c); + if (parts.length !== 2) return null; + const strip = (s: string) => s.replace(/^\$/, ""); + const ca = colIndex(strip(a)); + const cb = colIndex(strip(b)); + if (ca >= 0 && cb >= 0) return normRange(0, ca, MAX_ROWS - 1, cb); + const ra = rowIndex(strip(a)); + const rb = rowIndex(strip(b)); + if (ra >= 0 && rb >= 0) return normRange(ra, 0, rb, MAX_COLS - 1); + return null; +} + +export function inRange(range: CellRange, r: number, c: number): boolean { + return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1; +} + +export function rangesOverlap(a: CellRange, b: CellRange): boolean { + return a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1; +} + +/** `a` 가 `b` 안에 통째로 듦 */ +export function rangeInside(a: CellRange, b: CellRange): boolean { + return a.r0 >= b.r0 && a.r1 <= b.r1 && a.c0 >= b.c0 && a.c1 <= b.c1; } /** 식에 넣을 시트 이름 — 글자 · 숫자 · _ 밖이 있으면 `'…'`(안의 `'` 는 `''`) */ -export function quoteSheet(_name: string): string { - return todo(); +export function quoteSheet(name: string): string { + const plain = + /^[\p{L}_][\p{L}\p{N}_]*$/u.test(name) && + !/^[A-Za-z]{1,3}\d+$/.test(name) && // 칸 주소처럼 보이는 이름 + !/^(TRUE|FALSE)$/i.test(name) && + !/^R\d*C\d*$/i.test(name); // R1C1 처럼 보이는 이름 + return plain ? name : `'${name.replace(/'/g, "''")}'`; } /** Map 열쇠 — r × MAX_COLS + c */ -export function cellId(_r: number, _c: number): number { - return todo(); +export function cellId(r: number, c: number): number { + return r * MAX_COLS + c; } -export function fromCellId(_id: number): CellAddress { - return todo(); +export function fromCellId(id: number): CellAddress { + return { r: Math.floor(id / MAX_COLS), c: id % MAX_COLS }; } 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_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index a646b15d..1d3606b4 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -4,32 +4,615 @@ * 병합 규칙(엑셀): 병합 일부를 가르는 칸 쓰기 · 옮기기는 막음(오류 던짐 → 화면이 알림) · * 병합 안에 행열을 넣으면 병합이 늘고 지우면 줆 · 병합하면 왼위 칸 값만 남김. * 서식 표는 같은 서식을 한 번만 둠(칸은 번호) · 안 쓰는 서식 정리는 저장 전에(`compactStyles`). - * 0 계약 머리 — 몸은 A 가 채움. + * 행열 · 옮기기 · 시트 지우기는 바뀐 시트를 통째로 되돌림(`시트통째`) — 다른 시트 식 글까지 한 번에. * ========================================================================== */ -import type { Command, CommandEffect, Sheet, Workbook } from "./spreadsheet_types"; - -const todo = (): never => { - throw new Error("spreadsheet_commands: 아직 없음(A)"); -}; +import { + colName, + inRange, + normRange, + parseA1, + parseRange, + rangeInside, + rangesOverlap, + rangeToA1, + toA1, +} from "./spreadsheet_address"; +import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift"; +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 function applyCommand(_book: Workbook, _command: Command): CommandEffect { - return todo(); +type Of = Extract; + +const clone = (x: T): T => structuredClone(x); + +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; +} + +/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 0 */ +const inherited = (s: Sheet, r: number, c: number) => + s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? 0; + +const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c }); + +/** 바뀐 시트만 통째 되돌림 짝 */ +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; +} + +/** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */ +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 checkSheetName(book: Workbook, name: string, self?: string) { + if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자"); + if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'")) + throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '"); + if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase())) + throw new CommandError(`같은 이름 시트가 있음: ${name}`); +} + +// ── 칸 ──────────────────────────────────────────────────────────────────── + +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.서식 ?? 0); + if (idx) 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) { + // ponytail: 시트 통째 서식은 든 칸 · 있는 행 · 열 서식만 — 시트 기본 서식 칸이 계약에 없음 + whole = true; + 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; + + const merges: CellRange[] = []; + for (const m of mergesOf(s)) { + const sp = rows + ? shiftSpan(m.r0, m.r1, insert, at, n, max) + : shiftSpan(m.c0, m.c1, insert, at, n, max); + if (!sp) continue; + const next = rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] }; + if (next.r0 !== next.r1 || next.c0 !== next.c1) merges.push(next); + } + setMerges(s, merges); + + 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]; + } + + 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 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 addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect { + if (book.시트.some((s) => s.id === cmd.시트.id)) + throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`); + checkSheetName(book, cmd.시트.이름); + book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트)); + return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true }; +} + +function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect { + const s = sheetOf(book, cmd.시트); + if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움"); + const at = book.시트.indexOf(s); + const old = book.시트.map(clone); + rewriteAll(book, cmd); + book.시트.splice(at, 1); + if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id; + const undo: Command[] = [ + { 종류: "시트더하기", 시트: old[at], 자리: at }, + ...restoreChanged(book, old), + ]; + return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true }; +} + +function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect { + const s = sheetOf(book, cmd.시트); + checkSheetName(book, cmd.이름, s.id); + const prev = s.이름; + rewriteAll(book, cmd); + s.이름 = cmd.이름; + return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true }; +} + +function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const from = book.시트.indexOf(s); + book.시트.splice(from, 1); + book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s); + return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false }; +} + +function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect { + const at = book.시트.findIndex((x) => x.id === cmd.시트.id); + if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`); + const prev = book.시트[at]; + book.시트[at] = clone(cmd.시트); + const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev }; + if (cmd.서식) { + undo.서식 = book.서식; + book.서식 = clone(cmd.서식); + } + return { undo, cells: [], rebuild: true }; +} + +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.시트); + return plain( + setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({ + 종류: "열폭", + 시트: s.id, + 열, + 폭, + })), + ); + } + case "행높이": { + const s = sheetOf(book, 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 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 batch(book, command); + } } /** 빈 통합문서 — 시트 하나(`s1` · 이름은 부른 쪽) · 서식 [{}] */ -export function emptyWorkbook(_column: string, _sheetName: string): Workbook { - return todo(); +export function emptyWorkbook(column: string, sheetName: string): Workbook { + return { + 종류: "통합문서", + 판: 1, + 열: column, + 서식: [{}], + 시트: [emptySheet("s1", sheetName)], + 활성: "s1", + }; } -export function emptySheet(_id: string, _name: string): Sheet { - return todo(); +export function emptySheet(id: string, name: string): Sheet { + return { id, 이름: name, 칸: {} }; } /** 안 쓰는 서식을 빼고 번호를 다시 매김 — 저장 직전(되돌리기 짝은 안 만듦) */ -export function compactStyles(_book: Workbook): void { - return todo(); +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.열 ?? {})]) + if (info.서식 !== undefined) info.서식 = map(info.서식); + } + book.서식 = next; } diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts new file mode 100644 index 00000000..08411ba7 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -0,0 +1,401 @@ +/* ============================================================================= + * spreadsheet_editor.ts (주인 D) + * 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기(식 편집 중 칸 · 범위를 눌러 넣기) · + * 참조 칸 색 테두리. + * 한글: 숨은 textarea(`ta`) 가 늘 활성 칸 자리에서 초점을 쥠 → compositionstart · input 에서 편집을 + * 시작하고 같은 textarea 가 그대로 칸 편집기가 됨(첫 글자 조합이 안 끊김 · keydown 글자 방식 금지). + * 방식(엑셀): enter = 바로 쳐서 시작(방향키가 확정 · 식이면 가리키기) · edit = F2 · 두 번 누르기 · + * 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { moveFormula } from "./spreadsheet_refshift"; +import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address"; +import { tokenize } from "./spreadsheet_parser"; +import { cellsOf, move, selectCell } from "./spreadsheet_selection"; +import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types"; +import type { + CellBox, + EditorHandle, + RefHighlight, + Selection, + SpreadsheetContext, +} from "./spreadsheet_view_types"; + +export interface Editor extends EditorHandle { + /** 숨은 textarea = 칸 편집기(늘 초점) */ + ta: HTMLTextAreaElement; + /** 주소 상자 + 수식 입력줄 한 줄 */ + bar: HTMLElement; + formula: HTMLTextAreaElement; + mode(): "enter" | "edit"; + toggleMode(): void; + /** 지금 글자 자리에 참조를 넣을 수 있나(식 · 연산자 뒤) */ + pointable(): boolean; + /** 가리킨 고름을 식 글에 넣음(같은 가리키기면 바꿔 적음) */ + point(sel: Selection): void; + /** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */ + pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void; + /** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */ + commitAll(): void; + /** 초점 되찾기(스크롤 없이) */ + focus(): void; + /** 편집 중이면 칸 자리 다시(스크롤 · 폭 바뀜) */ + place(): void; + destroy(): void; +} + +interface EditState { + sheet: string; + at: CellAddress; + mode: "enter" | "edit"; + original: string; + /** 가리키기 중 — 식 글 [start, end) 가 가리킨 참조 */ + point: { start: number; end: number; sel: Selection } | null; +} + +/** 수 입력 — 1,234 · -1.5e3 · 50% (엑셀 입력 규칙) · 아니면 null */ +export function parseNumber(text: string): number | null { + let t = text.trim(); + const pct = t.endsWith("%"); + if (pct) t = t.slice(0, -1); + if (!/\d/.test(t) || !/^[+-]?(\d{1,3}(,\d{3})+|\d*)(\.\d*)?([eE][+-]?\d+)?$/.test(t)) return null; + const n = Number(t.replace(/,/g, "")); + if (!Number.isFinite(n)) return null; + return pct ? n / 100 : n; +} + +const looksTyped = (s: string): boolean => + (s.length > 1 && s[0] === "=") || + s[0] === "'" || + parseNumber(s) !== null || + /^(true|false)$/i.test(s); + +/** 친 글 → 칸(서식은 두고) · 지울 칸이면 null */ +export function textToCell(text: string, old?: Cell): Cell | null { + const base: Cell = old?.서식 !== undefined ? { 서식: old.서식 } : {}; + if (text === "") return old?.서식 !== undefined ? base : null; + if (text.length > 1 && text[0] === "=") return { ...base, 식: text.slice(1) }; + if (text[0] === "'") return { ...base, 값: text.slice(1) }; + const n = parseNumber(text); + if (n !== null) return { ...base, 값: n }; + if (/^(true|false)$/i.test(text)) return { ...base, 값: text.toUpperCase() === "TRUE" }; + return { ...base, 값: text }; +} + +/** 칸 → 편집 글(식은 `=` 붙임 · 수처럼 읽힐 글은 `'` 붙임) */ +export function cellToText(cell: Cell | undefined): string { + if (!cell) return ""; + if (cell.식 !== undefined) return "=" + cell.식; + const v = cell.값; + if (v === undefined) return ""; + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + if (typeof v === "number") return String(v); + return looksTyped(v) ? "'" + v : v; +} + +/** 참조 넣을 자리 — 식이고 caret 앞(빈칸 건너)이 연산자 · 괄호 · 쉼표 · `=` */ +export function insertableAt(text: string, pos: number): boolean { + if (text[0] !== "=") return false; + if (pos < text.length && /[A-Za-z0-9$_.!']/.test(text[pos])) return false; + let i = pos - 1; + while (i >= 0 && text[i] === " ") i--; + return i >= 0 && "=(,+-*/^&<>:;".includes(text[i]); +} + +/** 식 글 참조 → 색 테두리 목록(같은 글이면 같은 색) */ +export function refHighlights( + text: string, + host: string, + sheetId: (name: string) => string | null, +): RefHighlight[] { + if (text[0] !== "=") return []; + const out: RefHighlight[] = []; + const colors = new Map(); + for (const t of tokenize(text.slice(1))) { + if (t.kind !== "ref") continue; + const bang = t.text.lastIndexOf("!"); + let sheet: string | null = host; + if (bang >= 0) { + const raw = t.text.slice(0, bang); + const name = raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw; + sheet = sheetId(name); + } + const range = parseRange(t.text.slice(bang + 1).replace(/\$/g, "")); + if (!sheet || !range) continue; + const key = t.text.toUpperCase(); + if (!colors.has(key)) colors.set(key, colors.size); + out.push({ 시트: sheet, 범위: range, 색번호: colors.get(key)! }); + } + return out; +} + +/** 칸 서식(칸 → 행 → 열 → 기본) */ +function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): CellStyle { + const idx = + sheet.칸[toA1(r, c)]?.서식 ?? + sheet.행?.[String(r + 1)]?.서식 ?? + sheet.열?.[colName(c)]?.서식 ?? + 0; + return ctx.book.서식[idx] ?? {}; +} + +/** editorSlot 칸 자리 → 층 안 자리 */ +export function boxStyle(node: HTMLElement, box: CellBox): void { + node.style.left = `${box.x}px`; + node.style.top = `${box.y}px`; + node.style.minWidth = `${box.w}px`; + node.style.minHeight = `${box.h}px`; +} + +export function createEditor(ctx: SpreadsheetContext): Editor { + const ta = el("textarea", { + className: "ss-input ss-input--idle", + attrs: { spellcheck: "false", autocomplete: "off", rows: "1", "aria-label": "칸 입력" }, + }); + const addr = el("input", { + className: "ss-addr", + attrs: { spellcheck: "false", "aria-label": "주소 상자" }, + }); + const formula = el("textarea", { + className: "ss-formula", + attrs: { spellcheck: "false", rows: "1", "aria-label": "수식 입력줄" }, + }); + const bar = el("div", { + className: "ss-bar", + children: [addr, el("span", { className: "ss-fx", text: "fx" }), formula], + }); + formula.readOnly = ctx.readOnly; + ta.readOnly = ctx.readOnly; + + let st: EditState | null = null; + + const src = (): HTMLTextAreaElement => (document.activeElement === formula ? formula : ta); + const text = (): string => src().value; + const sheetOf = (id: string): Sheet => ctx.book.시트.find((s) => s.id === id) ?? ctx.sheet(); + const sheetId = (name: string): string | null => + ctx.book.시트.find((s) => s.이름.toUpperCase() === name.toUpperCase())?.id ?? null; + + function place(): void { + const at = st?.at ?? ctx.selection.활성; + const { layer, box } = ctx.grid.editorSlot(at.r, at.c); + if (ta.parentElement !== layer) { + const had = document.activeElement === ta; + layer.append(ta); + if (had) focus(); + } + boxStyle(ta, box); + if (!st) return; + const s = styleAt(ctx, sheetOf(st.sheet), at.r, at.c); + ta.style.fontFamily = s.글꼴 ?? ""; + ta.style.fontSize = s.크기 ? `${s.크기}pt` : ""; + ta.style.fontWeight = s.굵게 ? "bold" : ""; + ta.style.fontStyle = s.기울임 ? "italic" : ""; + ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : ""; + // 글이 길면 오른쪽 · 아래로 늘림(엑셀) + ta.style.width = "0"; + ta.style.height = "0"; + ta.style.width = `${Math.max(box.w, ta.scrollWidth + 4)}px`; + ta.style.height = `${Math.max(box.h, ta.scrollHeight)}px`; + } + + function focus(): void { + if (document.activeElement !== ta) ta.focus({ preventScroll: true }); + } + + function highlight(): void { + const t = st ? text() : ""; + ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []); + } + + /** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */ + function start(mode: "enter" | "edit", value?: string, from: HTMLTextAreaElement = ta): void { + if (ctx.readOnly || st) return; + const sel = ctx.selection; + const sheet = ctx.sheet(); + const original = cellToText(sheet.칸[toA1(sel.활성.r, sel.활성.c)]); + st = { sheet: sheet.id, at: { ...sel.활성 }, mode, original, point: null }; + if (value !== undefined) from.value = value; + (from === ta ? formula : ta).value = from.value; + ta.classList.remove("ss-input--idle"); + ctx.root.classList.add("ss--editing"); + place(); + highlight(); + if (from === ta && value !== undefined) ta.setSelectionRange(ta.value.length, ta.value.length); + } + + function end(): void { + st = null; + ta.value = ""; + ta.classList.add("ss-input--idle"); + ta.removeAttribute("style"); + ctx.root.classList.remove("ss--editing"); + ctx.grid.setRefHighlights([]); + sync(); + focus(); + } + + function write(cells: CellAddress[], value: string, from: CellAddress): void { + if (!st) return; + const sheet = sheetOf(st.sheet); + const out: Record = {}; + for (const a of cells) { + let v = value; + if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c)) + v = "=" + moveFormula(v.slice(1), a.r - from.r, a.c - from.c); + const key = toA1(a.r, a.c); + const next = textToCell(v, sheet.칸[key]); + if (next === null && !sheet.칸[key]) continue; + out[key] = next; + } + if (Object.keys(out).length) ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: out }); + } + + function commit(): void { + if (!st) return; + const value = text(); + if (value !== st.original) write([st.at], value, st.at); + end(); + } + + function commitAll(): void { + if (!st) return; + write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at); + end(); + } + + function cancel(): void { + if (!st) return; + end(); + } + + function sync(): void { + const sel = ctx.selection; + addr.value = toA1(sel.활성.r, sel.활성.c); + if (st) return; + formula.value = cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]); + place(); + } + + function pointable(): boolean { + if (!st) return false; + const node = src(); + if (node.selectionStart !== node.selectionEnd) return false; + const pos = node.selectionEnd; + if (st.point && st.point.end === pos) return true; + st.point = null; + return insertableAt(node.value, pos); + } + + function point(sel: Selection): void { + if (!st) return; + const node = src(); + const other = node === ta ? formula : ta; + const pos = node.selectionEnd; + const p = st.point ?? { start: pos, end: pos, sel }; + const g = sel.범위[0]; + const ref = rangeToA1(g); + node.value = node.value.slice(0, p.start) + ref + node.value.slice(p.end); + other.value = node.value; + st.point = { start: p.start, end: p.start + ref.length, sel }; + node.setSelectionRange(st.point.end, st.point.end); + place(); + highlight(); + ctx.grid.reveal(sel.활성.r === g.r0 ? g.r1 : g.r0, sel.활성.c === g.c0 ? g.c1 : g.c0); + } + + function pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void { + if (!st) return; + const sheet = sheetOf(st.sheet); + const from = st.point?.sel ?? selectCell(sheet, st.at.r, st.at.c); + point(move(sheet, from, dr, dc, { extend, jump })); + } + + // ── 한글 조합 · 바로 치기 → 편집 시작 ───────────────────────────────────── + ta.addEventListener("compositionstart", () => { + if (!st) start("enter"); + }); + ta.addEventListener("input", () => { + if (!st) return start("enter"); + formula.value = ta.value; + if (st.point && ta.selectionEnd !== st.point.end) st.point = null; + place(); + highlight(); + }); + // 수식 입력줄 — 누르면 edit 방식으로 시작 + formula.addEventListener("focus", () => { + if (!st && !ctx.readOnly) start("edit", formula.value, formula); + }); + formula.addEventListener("input", () => { + if (!st) return start("edit", undefined, formula); + ta.value = formula.value; + if (st.point && formula.selectionEnd !== st.point.end) st.point = null; + place(); + highlight(); + }); + // 주소 상자 — `B5` · `A1:C3` · `시트!B5` 로 가기 + addr.addEventListener("focus", () => addr.select()); + addr.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + e.preventDefault(); + sync(); + focus(); + } + if (e.key !== "Enter") return; + e.preventDefault(); + goTo(addr.value.trim()); + focus(); + }); + + function goTo(value: string): void { + const bang = value.lastIndexOf("!"); + let sheet = ctx.sheet(); + if (bang >= 0) { + const raw = value.slice(0, bang); + const id = sheetId(raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw); + if (!id) return sync(); + if (id !== sheet.id) ctx.showSheet(id); + sheet = ctx.sheet(); + } + const body = value.slice(bang + 1).replace(/\$/g, ""); + const one = parseA1(body); + const g = parseRange(body); + if (!g) return sync(); + if (one) return ctx.select(selectCell(sheet, one.r, one.c), true); + const at = { r: g.r0, c: g.c0 }; + ctx.select({ 시트: sheet.id, 범위: [g], 활성: at, 기준: { ...at } }, true); + } + + return { + ta, + bar, + formula, + editing: () => st !== null, + begin(initial?: string) { + if (initial === undefined) { + const sel = ctx.selection; + start("edit", cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)])); + } else start("enter", initial); + focus(); + }, + commit, + commitAll, + cancel, + sync, + mode: () => st?.mode ?? "enter", + toggleMode() { + if (st) ((st.mode = st.mode === "enter" ? "edit" : "enter"), (st.point = null)); + }, + pointable, + point, + pointMove, + focus, + place() { + if (st) place(); + }, + destroy() { + ta.remove(); + bar.remove(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_eval.ts b/A00_Common/spreadsheet/spreadsheet_eval.ts index c1b62a31..7b48adfd 100644 --- a/A00_Common/spreadsheet/spreadsheet_eval.ts +++ b/A00_Common/spreadsheet/spreadsheet_eval.ts @@ -3,9 +3,24 @@ * 나무 풀기 · 오류 값 전파 · 형 바꾸기(엑셀 규칙). B 의 함수들이 형 바꾸기 도우미를 씀. * 빈 칸 = 수에서 0 · 글에서 "" · `"12"+1 = 13` · `TRUE+1 = 2` · 글이 수로 안 읽히면 `#VALUE!`. * 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수(B · A 같이 `fromDouble`). - * 0 계약 머리 — 몸은 A 가 채움. + * 오류 전파 = 왼쪽부터 처음 만난 오류. 비교 = 수 < 글 < 참거짓 · 글은 대소문자 가리지 않음. * ========================================================================== */ +import { + add, + cmp, + div, + frac, + fracToString, + mul, + parseDecimal, + roundAt, + sub, + toFrac, + ZERO, +} from "@ui/sheet/ui_template_sheet_frac"; +import { normRange } from "./spreadsheet_address"; +import { getFunction } from "./spreadsheet_functions"; import type { CalcValue, ErrorCode, @@ -14,45 +29,259 @@ import type { EvalResult, FormulaNode, Frac, + RangeValue, Scalar, } from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_eval: 아직 없음(A)"); -}; - -export function evaluate(_node: FormulaNode, _ctx: EvalContext): EvalResult { - return todo(); +export function err(code: ErrorCode, why?: string): ErrorValue { + return why === undefined ? { error: code } : { error: code, why }; } -export function err(_code: ErrorCode, _why?: string): ErrorValue { - return todo(); +export function isError(value: unknown): value is ErrorValue { + return typeof value === "object" && value !== null && "error" in value; } -export function isError(_value: unknown): _value is ErrorValue { - return todo(); +export function isFrac(value: unknown): value is Frac { + return typeof value === "object" && value !== null && "n" in value && "d" in value; +} + +export function isRange(value: unknown): value is RangeValue { + return typeof value === "object" && value !== null && (value as RangeValue).kind === "range"; +} + +const ONE = frac(1n); +const HUNDRED = frac(100n); +const BIG_D = 10n ** 40n; + +/** 분모가 너무 커지면(나눗셈 사슬) 30 자리 십진으로 굳힘 */ +export function tame(x: Frac): Frac { + return x.d > BIG_D ? parseDecimal(fracToString(x)) : x; +} + +/** 범위 → 칸 하나(1×1 일 때만) · 아니면 `#VALUE!` */ +function single(value: EvalResult): Scalar { + if (!isRange(value)) return value; + return value.rows === 1 && value.cols === 1 + ? value.at(0, 0) + : err("#VALUE!", "범위를 값 하나로 못 씀"); } /** 수로 — 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` · 범위는 칸 하나일 때만 */ -export function asNumber(_value: EvalResult): Frac | ErrorValue { - return todo(); +export function asNumber(value: EvalResult): Frac | ErrorValue { + const v = single(value); + if (v === null) return ZERO; + if (typeof v === "boolean") return v ? ONE : ZERO; + if (typeof v === "string") { + const t = v.trim(); + const pct = t.endsWith("%"); + try { + const n = parseDecimal(pct ? t.slice(0, -1) : t); + return pct ? div(n, HUNDRED) : n; + } catch { + return err("#VALUE!", `수로 못 읽는 글: "${v}"`); + } + } + return v; +} + +/** 일반 형식 글 — 유효 15 자리 · 1e15 넘거나 1e-9 밑이면 `1.5E+18` 꼴(엑셀 `&` 와 같게) */ +export function numberText(x: Frac): string { + if (x.n === 0n) return "0"; + const d = parseFloat(fracToString(x)); + const e = Math.floor(Math.log10(Math.abs(d))); + if (e >= 15 || e < -9) { + const [m, ex] = d.toExponential(14).split("e"); + const mant = m.replace(/\.?0+$/, ""); + const n = Number(ex); + return `${mant}E${n < 0 ? "-" : "+"}${String(Math.abs(n)).padStart(2, "0")}`; + } + return fracToString(roundAt(x, 14 - e, "round")); } /** 글로 — 수는 일반 형식 글(엑셀 `&` 와 같게) · 참거짓 `TRUE`/`FALSE` */ -export function asText(_value: EvalResult): string | ErrorValue { - return todo(); +export function asText(value: EvalResult): string | ErrorValue { + const v = single(value); + if (v === null) return ""; + if (typeof v === "string" || isError(v)) return v; + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + return numberText(v); } -export function asBool(_value: EvalResult): boolean | ErrorValue { - return todo(); +export function asBool(value: EvalResult): boolean | ErrorValue { + const v = single(value); + if (v === null) return false; + if (typeof v === "boolean" || isError(v)) return v; + if (typeof v === "string") { + const t = v.toUpperCase(); + return t === "TRUE" + ? true + : t === "FALSE" + ? false + : err("#VALUE!", `참거짓으로 못 읽는 글: "${v}"`); + } + return v.n !== 0n; } /** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */ -export function fromDouble(_x: number): Frac | ErrorValue { - return todo(); +export function fromDouble(x: number): Frac | ErrorValue { + if (!Number.isFinite(x)) return err("#NUM!", "수가 너무 크거나 뜻이 없음"); + return x === 0 ? ZERO : parseDecimal(x.toPrecision(15)); } +export const toDouble = (x: Frac): number => parseFloat(fracToString(x)); + /** 저장 모양 — 수는 십진 글(30 자리까지) */ -export function toCalcValue(_value: Scalar): CalcValue { - return todo(); +export function toCalcValue(value: Scalar): CalcValue { + if (value === null) return { 수: "0" }; + if (typeof value === "string") return { 글: value }; + if (typeof value === "boolean") return { 참: value }; + if (isError(value)) + return value.why ? { 오류: value.error, 까닭: value.why } : { 오류: value.error }; + return { 수: fracToString(value) }; +} + +// ── 연산 ──────────────────────────────────────────────────────────────────── + +function power(a: Frac, b: Frac): Frac | ErrorValue { + if (a.n === 0n) { + if (b.n === 0n) return err("#NUM!", "0^0"); + return b.n < 0n ? err("#DIV/0!", "0 의 음수 거듭제곱") : ZERO; + } + if (b.d === 1n) { + const e = b.n < 0n ? -b.n : b.n; + const bits = BigInt(a.n.toString(2).length + a.d.toString(2).length); + // ponytail: 4,000 비트 넘는 정확 거듭제곱은 double 로 — 실무 식엔 없음 + if (bits * e <= 4000n) { + const r = frac(a.n ** e, a.d ** e); + return tame(b.n < 0n ? div(ONE, r) : r); + } + } else if (a.n < 0n) return err("#NUM!", "음수의 소수 거듭제곱"); + return fromDouble(Math.pow(toDouble(a), toDouble(b))); +} + +function arith(op: string, a: Frac, b: Frac): Frac | ErrorValue { + switch (op) { + case "+": + return tame(add(a, b)); + case "-": + return tame(sub(a, b)); + case "*": + return tame(mul(a, b)); + case "/": + return b.n === 0n ? err("#DIV/0!", "0 으로 나눔") : tame(div(a, b)); + default: + return power(a, b); + } +} + +const RANK = (v: Scalar) => (isFrac(v) ? 0 : typeof v === "string" ? 1 : 2); + +/** 엑셀 비교 — 빈 칸은 맞은편 형의 빈 값(0 · "" · FALSE) · 형이 다르면 수 < 글 < 참거짓 */ +export function compareValues(a: Scalar, b: Scalar): number { + const blank = (other: Scalar): Scalar => + isFrac(other) + ? ZERO + : typeof other === "string" + ? "" + : typeof other === "boolean" + ? false + : ZERO; + if (a === null) a = blank(b); + if (b === null) b = blank(a); + const ra = RANK(a); + const rb = RANK(b); + if (ra !== rb) return ra < rb ? -1 : 1; + if (isFrac(a)) return cmp(a, b as Frac); + if (typeof a === "string") { + const x = a.toLowerCase(); + const y = (b as string).toLowerCase(); + return x === y ? 0 : x < y ? -1 : 1; + } + return a === b ? 0 : a ? 1 : -1; +} + +function compareOp(op: string, a: Scalar, b: Scalar): boolean { + const c = compareValues(a, b); + return op === "=" + ? c === 0 + : op === "<>" + ? c !== 0 + : op === "<" + ? c < 0 + : op === "<=" + ? c <= 0 + : op === ">" + ? c > 0 + : c >= 0; +} + +// ── 풀기 ──────────────────────────────────────────────────────────────────── + +export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult { + switch (node.type) { + case "number": + return node.value; + case "string": + return node.value; + case "bool": + return node.value; + case "error": + return err(node.code); + case "name": + return err("#NAME?", `모르는 이름: ${node.name}`); + case "cell": + case "range": { + const sheet = node.sheet === null ? ctx.sheet : ctx.sheetId(node.sheet); + if (sheet === null) return err("#REF!", `없는 시트: ${node.sheet}`); + if (node.type === "cell") return ctx.cell(sheet, node.ref.r, node.ref.c); + return ctx.range(sheet, normRange(node.from.r, node.from.c, node.to.r, node.to.c)); + } + case "unary": { + const v = evaluate(node.arg, ctx); + if (node.op === "+") return single(v); + const n = asNumber(v); + if (isError(n)) return n; + return node.op === "-" ? frac(-n.n, n.d) : div(n, HUNDRED); + } + case "binary": { + const left = evaluate(node.left, ctx); + const right = evaluate(node.right, ctx); + if (node.op === "&") { + const a = asText(left); + if (isError(a)) return a; + const b = asText(right); + return isError(b) ? b : a + b; + } + if (PLAIN_OPS.has(node.op)) { + const a = asNumber(left); + if (isError(a)) return a; + const b = asNumber(right); + return isError(b) ? b : arith(node.op, a, b); + } + const a = single(left); + if (isError(a)) return a; + const b = single(right); + return isError(b) ? b : compareOp(node.op, a, b); + } + case "call": { + const fn = getFunction(node.name); + if (!fn) return err("#NAME?", `모르는 함수: ${node.name}`); + const n = node.args.length; + if (n < fn.min || (fn.max !== undefined && n > fn.max)) { + const want = fn.max === fn.min ? `${fn.min}` : `${fn.min}~${fn.max ?? ""}`; + return err("#VALUE!", `${node.name} 인자 수 ${n} (받는 수 ${want})`); + } + return fn.call( + node.args.map((a) => () => evaluate(a, ctx)), + ctx, + ); + } + } +} + +const PLAIN_OPS = new Set(["+", "-", "*", "/", "^"]); + +/** 수로 온 입력 값 → 풀이 값 */ +export function inputValue(v: number | string | boolean): Scalar { + return typeof v === "number" ? toFrac(v) : v; } 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_graph.ts b/A00_Common/spreadsheet/spreadsheet_graph.ts index 76882474..92ed4dc7 100644 --- a/A00_Common/spreadsheet/spreadsheet_graph.ts +++ b/A00_Common/spreadsheet/spreadsheet_graph.ts @@ -1,17 +1,316 @@ /* ============================================================================= * spreadsheet_graph.ts (주인 A) * 계산 엔진 — 식마다 가리키는 칸 · 범위로 의존 그래프 · 바뀐 칸에 딸린 칸만 차례로 다시 풂. - * 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂. - * 식 나무는 식 글마다 캐시. 0 계약 머리 — 몸은 A 가 채움. + * 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂 + * (고리를 읽는 칸은 오류 전파로 `#CYCLE!`). 식 나무는 식 글마다 캐시. + * 풀이는 필요할 때 끌어 풂(앞 칸이 안 풀렸으면 먼저) — 차례는 저절로 위상 순서. * ========================================================================== */ -import type { CalcEngine, Workbook } from "./spreadsheet_types"; +import { FormulaError, ZERO } from "@ui/sheet/ui_template_sheet_frac"; +import { cellId, fromCellId, inRange, parseA1, toA1 } from "./spreadsheet_address"; +import { err, evaluate, inputValue, isRange, toCalcValue } from "./spreadsheet_eval"; +import { parseFormula } from "./spreadsheet_parser"; +import type { + CalcEngine, + CalcValues, + Cell, + CellRange, + EvalContext, + EvalResult, + FormulaNode, + ParseResult, + RangeValue, + Scalar, + SheetCellAddress, + Workbook, +} from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_graph: 아직 없음(A)"); -}; +interface SheetIndex { + id: string; + cells: Map; + /** 값 든 칸의 가장 먼 행 · 열 — 행 · 열 전체 범위를 여기까지만 읽음 */ + maxR: number; + maxC: number; +} + +interface Dep { + sheet: string; + range: CellRange; +} + +const DONE = 2; +const ACTIVE = 1; + +const keyOf = (sheet: string, id: number) => `${sheet}!${id}`; + +function splitKey(key: string): SheetCellAddress { + const i = key.lastIndexOf("!"); + return { 시트: key.slice(0, i), ...fromCellId(Number(key.slice(i + 1))) }; +} + +/** 나무 안 칸 · 범위 참조(시트 이름 → id · 없는 시트는 뺌) */ +function collectRefs( + node: FormulaNode, + host: string, + sheetId: (n: string) => string | null, + out: Dep[], +) { + switch (node.type) { + case "cell": + case "range": { + const sheet = node.sheet === null ? host : sheetId(node.sheet); + if (sheet === null) return; + const [a, b] = node.type === "cell" ? [node.ref, node.ref] : [node.from, node.to]; + out.push({ + sheet, + range: { + r0: Math.min(a.r, b.r), + c0: Math.min(a.c, b.c), + r1: Math.max(a.r, b.r), + c1: Math.max(a.c, b.c), + }, + }); + return; + } + case "unary": + return collectRefs(node.arg, host, sheetId, out); + case "binary": + collectRefs(node.left, host, sheetId, out); + return collectRefs(node.right, host, sheetId, out); + case "call": + for (const a of node.args) collectRefs(a, host, sheetId, out); + } +} /** 문서를 참조로 쥠 — 만들 때 전부 풂. */ -export function createCalcEngine(_book: Workbook): CalcEngine { - return todo(); +export function createCalcEngine(book: Workbook): CalcEngine { + const parsed = new Map(); + let sheets = new Map(); + let names = new Map(); + /** 식 칸 → 가리키는 칸 · 범위 */ + let precs = new Map(); + /** 칸 하나 → 그 칸을 가리키는 식 칸 */ + let cellDeps = new Map>(); + /** 시트 → 범위를 가리키는 식 칸 */ + let rangeDeps = new Map>(); + let vals = new Map(); + let state = new Map(); + const stack: string[] = []; + const cycle = new Map(); + + const sheetId = (name: string) => names.get(name.toLowerCase()) ?? null; + + const parse = (text: string) => { + let p = parsed.get(text); + if (!p) parsed.set(text, (p = parseFormula(text))); + return p; + }; + + function unlink(key: string) { + for (const d of precs.get(key) ?? []) { + if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1) + cellDeps.get(keyOf(d.sheet, cellId(d.range.r0, d.range.c0)))?.delete(key); + else rangeDeps.get(d.sheet)?.delete(key); + } + precs.delete(key); + } + + /** 칸 하나를 색인에 올림(식이면 가리키는 곳 잇기) */ + function index(sheet: SheetIndex, id: number, cell: Cell | undefined) { + const key = keyOf(sheet.id, id); + unlink(key); + vals.delete(key); + state.delete(key); + cycle.delete(key); + if (!cell || (cell.식 === undefined && cell.값 === undefined)) { + sheet.cells.delete(id); + return; + } + sheet.cells.set(id, cell); + const { r, c } = fromCellId(id); + sheet.maxR = Math.max(sheet.maxR, r); + sheet.maxC = Math.max(sheet.maxC, c); + if (cell.식 === undefined) { + vals.set(key, inputValue(cell.값!)); + return; + } + const p = parse(cell.식); + const deps: Dep[] = []; + if (p.ok) collectRefs(p.node, sheet.id, sheetId, deps); + precs.set(key, deps); + for (const d of deps) { + if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1) { + const k = keyOf(d.sheet, cellId(d.range.r0, d.range.c0)); + let set = cellDeps.get(k); + if (!set) cellDeps.set(k, (set = new Set())); + set.add(key); + } else { + let m = rangeDeps.get(d.sheet); + if (!m) rangeDeps.set(d.sheet, (m = new Map())); + let list = m.get(key); + if (!list) m.set(key, (list = [])); + list.push(d.range); + } + } + } + + function rebuild(next: Workbook) { + book = next; + sheets = new Map(); + names = new Map(); + precs = new Map(); + cellDeps = new Map(); + rangeDeps = new Map(); + vals = new Map(); + state = new Map(); + cycle.clear(); + for (const s of book.시트) { + sheets.set(s.id, { id: s.id, cells: new Map(), maxR: 0, maxC: 0 }); + names.set(s.이름.toLowerCase(), s.id); + } + for (const s of book.시트) { + const idx = sheets.get(s.id)!; + for (const [a1, cell] of Object.entries(s.칸)) { + const at = parseA1(a1); + if (at) index(idx, cellId(at.r, at.c), cell); + } + } + for (const key of precs.keys()) compute(key); + } + + function ctxFor(sheet: string, r: number, c: number): EvalContext { + return { sheet, r, c, cell: read, range, sheetId }; + } + + function read(sheet: string, r: number, c: number): Scalar { + const key = keyOf(sheet, cellId(r, c)); + if (precs.has(key)) return compute(key); + return vals.get(key) ?? null; + } + + function range(sheet: string, rg: CellRange): RangeValue { + const s = sheets.get(sheet); + // ponytail: 행 · 열 전체 범위는 값 든 끝까지만 — INDEX(A:A, 끝 너머)는 #REF! 로 갈림 + const r1 = Math.min(rg.r1, Math.max(rg.r0, s?.maxR ?? 0)); + const c1 = Math.min(rg.c1, Math.max(rg.c0, s?.maxC ?? 0)); + return { + kind: "range", + rows: r1 - rg.r0 + 1, + cols: c1 - rg.c0 + 1, + at: (i, j) => read(sheet, rg.r0 + i, rg.c0 + j), + }; + } + + /** 식 칸 하나 풂 — 풀이 중인 칸을 다시 만나면 그 사이가 고리 */ + function compute(key: string): Scalar { + const st = state.get(key); + if (st === DONE) return vals.get(key) ?? null; + if (st === ACTIVE) { + const ring = stack.slice(stack.indexOf(key)); + const why = `순환 참조: ${ring.map(label).join(" → ")} → ${label(key)}`; + for (const k of ring) if (!cycle.has(k)) cycle.set(k, why); + return err("#CYCLE!", why); + } + state.set(key, ACTIVE); + stack.push(key); + const { 시트, r, c } = splitKey(key); + const cell = sheets.get(시트)!.cells.get(cellId(r, c))!; + let v: Scalar; + try { + // ponytail: 끌어 풀기는 재귀 — 수천 칸 사슬이면 스택이 넘침, 그때 위상 순서 반복으로 + v = run(cell.식!, ctxFor(시트, r, c)); + } catch (e) { + state.delete(key); + throw e; + } finally { + stack.pop(); + } + const why = cycle.get(key); + if (why) v = err("#CYCLE!", why); + vals.set(key, v); + state.set(key, DONE); + return v; + } + + function label(key: string) { + const { 시트, r, c } = splitKey(key); + const s = book.시트.find((x) => x.id === 시트); + return book.시트.length > 1 && s ? `${s.이름}!${toA1(r, c)}` : toA1(r, c); + } + + function run(text: string, ctx: EvalContext): Scalar { + const p = parse(text); + if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`); + let v: EvalResult; + try { + v = evaluate(p.node, ctx); + } catch (e) { + if (e instanceof FormulaError && e.message.startsWith("0 으로")) + return err("#DIV/0!", e.message); + return err("#VALUE!", e instanceof Error ? e.message : String(e)); + } + // ponytail: 범위를 칸에 넣으면 한 칸만 — 엑셀 암묵 교차(식 든 행 · 열과 겹친 칸)는 안 함 + if (isRange(v)) + v = v.rows === 1 && v.cols === 1 ? v.at(0, 0) : err("#VALUE!", "범위를 칸 하나에 못 넣음"); + return v === null ? ZERO : v; + } + + function dependents(key: string, out: Set) { + for (const k of cellDeps.get(key) ?? []) out.add(k); + const { 시트, r, c } = splitKey(key); + for (const [k, list] of rangeDeps.get(시트) ?? []) + if (list.some((rg) => inRange(rg, r, c))) out.add(k); + } + + rebuild(book); + + return { + update(changed) { + const dirty = new Set(); + for (const a of changed) { + const s = sheets.get(a.시트); + const sheet = book.시트.find((x) => x.id === a.시트); + if (!s || !sheet) continue; + const id = cellId(a.r, a.c); + index(s, id, sheet.칸[toA1(a.r, a.c)]); + dirty.add(keyOf(a.시트, id)); + } + // 딸린 칸을 끝까지 모음(고리였던 칸도 다시) + const queue = [...dirty]; + while (queue.length) { + const next = new Set(); + dependents(queue.pop()!, next); + for (const k of next) + if (!dirty.has(k)) { + dirty.add(k); + queue.push(k); + } + } + for (const k of dirty) + if (precs.has(k)) { + state.delete(k); + cycle.delete(k); + } + for (const k of dirty) if (precs.has(k)) compute(k); + return [...dirty].map(splitKey); + }, + rebuild, + value(sheet, r, c) { + return read(sheet, r, c); + }, + precedents(sheet, r, c) { + return (precs.get(keyOf(sheet, cellId(r, c))) ?? []).map((d) => ({ + 시트: d.sheet, + 범위: d.range, + })); + }, + snapshot() { + const out: CalcValues = {}; + for (const key of precs.keys()) { + const { 시트, r, c } = splitKey(key); + (out[시트] ??= {})[toA1(r, c)] = toCalcValue(compute(key)); + } + return out; + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_history.ts b/A00_Common/spreadsheet/spreadsheet_history.ts index ebf7bce5..e2922f82 100644 --- a/A00_Common/spreadsheet/spreadsheet_history.ts +++ b/A00_Common/spreadsheet/spreadsheet_history.ts @@ -1,16 +1,38 @@ /* ============================================================================= * spreadsheet_history.ts (주인 A) * 되돌리기 · 다시 — 명령과 되돌림 짝을 쌓음(메모리만 · 캐시에 안 넣음). - * 0 계약 머리 — 몸은 A 가 채움. + * undo() 는 되돌림 짝을 · redo() 는 원래 명령을 돌려줌 — 적용은 부른 쪽(`applyCommand`). * ========================================================================== */ -import type { History } from "./spreadsheet_types"; - -const todo = (): never => { - throw new Error("spreadsheet_history: 아직 없음(A)"); -}; +import type { Command, History } from "./spreadsheet_types"; /** `limit` = 쌓을 수(넘으면 오래된 것부터 버림) */ -export function createHistory(_limit?: number): History { - return todo(); +export function createHistory(limit = 100): History { + let done: [Command, Command][] = []; + let undone: [Command, Command][] = []; + return { + push(command, undo) { + done.push([command, undo]); + if (done.length > limit) done.shift(); + undone = []; + }, + undo() { + const pair = done.pop(); + if (!pair) return null; + undone.push(pair); + return pair[1]; + }, + redo() { + const pair = undone.pop(); + if (!pair) return null; + done.push(pair); + return pair[0]; + }, + canUndo: () => done.length > 0, + canRedo: () => undone.length > 0, + clear() { + done = []; + undone = []; + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts new file mode 100644 index 00000000..2e5cdfcb --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -0,0 +1,187 @@ +/* ============================================================================= + * spreadsheet_keys.ts (주인 D) + * 엑셀 단축키 지도 — 숨은 textarea · 수식 입력줄 keydown 하나로 받음. + * 편집 중: Enter/Tab 확정 이동 · Alt+Enter 줄바꿈 · Ctrl+Enter 고름 전부 · Esc · F2 방식 바꿈 · + * enter 방식 방향키 = 확정 이동(식 참조 자리면 가리키기). + * 고름 중: 방향(Shift 늘림 · Ctrl 끝) · Home · Ctrl+Home/End · PgUp/PgDn · Enter/Tab(고름 안 돌기) · + * F2 · Delete · Backspace · Ctrl+A · Shift/Ctrl+Space · Ctrl+Z/Y · Ctrl+D/R. + * 글자 키는 막지 않음 — textarea 의 input · compositionstart 가 편집을 엶(한글 첫 글자). + * 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음. + * ========================================================================== */ + +import { fillCells } from "./spreadsheet_fill"; +import { + cycle, + LAST_R, + move, + selectAll, + selectCell, + selectCols, + selectRows, + selectSpan, + usedEnd, +} from "./spreadsheet_selection"; +import type { Editor } from "./spreadsheet_editor"; +import type { Selection, SpreadsheetContext } from "./spreadsheet_view_types"; + +const ARROWS: Record = { + ArrowUp: [-1, 0], + ArrowDown: [1, 0], + ArrowLeft: [0, -1], + ArrowRight: [0, 1], +}; + +export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void { + const go = (sel: Selection): void => ctx.select(sel, true); + + /** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */ + function next(dr: number, dc: number): void { + const sheet = ctx.sheet(); + go(cycle(sheet, ctx.selection, dr, dc) ?? move(sheet, ctx.selection, dr, dc)); + } + + function editingKey(e: KeyboardEvent, inBar: boolean): boolean { + const ctrl = e.ctrlKey || e.metaKey; + switch (e.key) { + case "Enter": { + const node = e.target as HTMLTextAreaElement; + if (e.altKey) { + node.setRangeText("\n", node.selectionStart, node.selectionEnd, "end"); + node.dispatchEvent(new Event("input")); + return true; + } + if (ctrl) return (ed.commitAll(), true); + ed.commit(); + next(e.shiftKey ? -1 : 1, 0); + return true; + } + case "Tab": + ed.commit(); + next(0, e.shiftKey ? -1 : 1); + return true; + case "Escape": + ed.cancel(); + return true; + case "F2": + ed.toggleMode(); + return true; + } + const arrow = ARROWS[e.key]; + if (!arrow || inBar || ed.mode() === "edit") return false; + if (ed.pointable()) { + ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl); + return true; + } + if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김 + ed.commit(); + go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1])); + return true; + } + + function navKey(e: KeyboardEvent): boolean { + const ctrl = e.ctrlKey || e.metaKey; + const sheet = ctx.sheet(); + const sel = ctx.selection; + const arrow = ARROWS[e.key]; + if (arrow && !e.altKey) { + go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl })); + return true; + } + const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; + if (ctrl && !e.altKey) { + switch (k) { + case "z": + e.shiftKey ? ctx.redo() : ctx.undo(); + return true; + case "y": + ctx.redo(); + return true; + case "a": + go(selectAll(sheet, sel.활성)); + return true; + case "Home": + go(e.shiftKey ? selectSpan(sheet, sel.기준, { r: 0, c: 0 }) : selectCell(sheet, 0, 0)); + return true; + case "End": { + const end = usedEnd(sheet); + go(e.shiftKey ? selectSpan(sheet, sel.기준, end) : selectCell(sheet, end.r, end.c)); + return true; + } + case " ": { + const g = sel.범위[0]; + go(selectCols(sheet, g.c0, g.c1, sel.활성.c)); + return true; + } + case "d": + case "r": + fill(k === "d"); + return true; + } + return false; + } + switch (e.key) { + case "Enter": + next(e.shiftKey ? -1 : 1, 0); + return true; + case "Tab": + next(0, e.shiftKey ? -1 : 1); + return true; + case "F2": + ed.begin(); + return true; + case "Delete": + if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 }); + return true; + case "Backspace": + ed.begin(""); + return true; + case "Home": + go( + e.shiftKey + ? selectSpan(sheet, sel.기준, { r: sel.기준.r, c: 0 }) + : selectCell(sheet, sel.활성.r, 0), + ); + return true; + case "PageDown": + case "PageUp": { + const v = ctx.grid.visibleRange(); + const n = Math.max(1, v.r1 - v.r0) * (e.key === "PageDown" ? 1 : -1); + const r = Math.min(LAST_R, Math.max(0, sel.활성.r + n)); + go(selectCell(sheet, r, sel.활성.c)); + return true; + } + case " ": + if (!e.shiftKey) return false; + go(selectRows(sheet, sel.범위[0].r0, sel.범위[0].r1, sel.활성.r)); + return true; + } + return false; + } + + /** Ctrl+D 아래로 · Ctrl+R 오른쪽으로 — 고름 첫 행(열)을 나머지에 */ + function fill(down: boolean): void { + if (ctx.readOnly) return; + const sheet = ctx.sheet(); + const g = ctx.selection.범위[0]; + const src = down ? { ...g, r1: g.r0 } : { ...g, c1: g.c0 }; + if ((down ? g.r1 : g.c1) === (down ? src.r1 : src.c1)) return; + ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: fillCells(ctx.book, sheet.id, src, g) }); + } + + function onKey(e: KeyboardEvent): void { + if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫 + const inBar = e.target === ed.formula; + const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e); + if (done) { + e.preventDefault(); + e.stopPropagation(); + } + } + + ed.ta.addEventListener("keydown", onKey); + ed.formula.addEventListener("keydown", onKey); + return () => { + ed.ta.removeEventListener("keydown", onKey); + ed.formula.removeEventListener("keydown", onKey); + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_mouse.ts b/A00_Common/spreadsheet/spreadsheet_mouse.ts new file mode 100644 index 00000000..5d0e706b --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_mouse.ts @@ -0,0 +1,226 @@ +/* ============================================================================= + * spreadsheet_mouse.ts (주인 D) + * 마우스 — 칸 · 범위 끌기 · Shift 늘림 · 행열 머리 · 모서리(전체) · 식 편집 중 참조 가리키기 · + * 채우기 핸들 끌기(늘림 · 안으로 끌면 지움) · 두 번 누르기(칸 편집 · 핸들이면 옆 열 끝까지 아래로) · + * 우클릭 자리(고름 밖이면 그 칸을 고름 — 메뉴는 E). + * 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음. + * ========================================================================== */ + +import { fillCells } from "./spreadsheet_fill"; +import { + hasValue, + inRange, + LAST_C, + LAST_R, + selectAll, + selectCell, + selectCols, + selectRows, + selectSpan, +} from "./spreadsheet_selection"; +import { boxStyle, type Editor } from "./spreadsheet_editor"; +import type { CellAddress, CellRange } from "./spreadsheet_types"; +import type { HitResult, Selection, SpreadsheetContext } from "./spreadsheet_view_types"; + +/** 채우기 끌기 목표 — 늘림(fill) · 안으로 줄임(clear = 지울 부분) */ +export function fillTarget( + src: CellRange, + p: CellAddress, +): { target: CellRange; clear: CellRange | null } { + if (inRange(src, p.r, p.c)) { + const up = src.r1 - p.r; + const left = src.c1 - p.c; + if (up === 0 && left === 0) return { target: src, clear: null }; + if (up >= left) return { target: { ...src, r1: p.r }, clear: { ...src, r0: p.r + 1 } }; + return { target: { ...src, c1: p.c }, clear: { ...src, c0: p.c + 1 } }; + } + const down = p.r - src.r1; + const upOut = src.r0 - p.r; + const right = p.c - src.c1; + const leftOut = src.c0 - p.c; + const best = Math.max(down, upOut, right, leftOut); + if (best === down) return { target: { ...src, r1: p.r }, clear: null }; + if (best === upOut) return { target: { ...src, r0: p.r }, clear: null }; + if (best === right) return { target: { ...src, c1: p.c }, clear: null }; + return { target: { ...src, c0: p.c }, clear: null }; +} + +export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void { + const root = ctx.grid.root; + const preview = document.createElement("div"); + preview.className = "ss-fill-preview"; + let stopDrag: (() => void) | null = null; + + /** 끌기 — 누른 채 움직이면 `onMove(칸)` · 떼면 `onUp` · 격자 밖이면 가장자리 칸(스크롤 따라감) */ + function drag(onMove: (p: HitResult) => void, onUp?: () => void): void { + const move = (e: MouseEvent): void => { + const box = root.getBoundingClientRect(); + const x = Math.min(box.right - 2, Math.max(box.left + 2, e.clientX)); + const y = Math.min(box.bottom - 2, Math.max(box.top + 2, e.clientY)); + const hit = ctx.grid.hitTest(x, y); + if (!hit || hit.r < 0 || hit.c < 0) return; + onMove(hit); + if (x !== e.clientX || y !== e.clientY) ctx.grid.reveal(hit.r, hit.c); + }; + const up = (): void => { + stopDrag?.(); + onUp?.(); + }; + stopDrag = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + stopDrag = null; + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + } + + const inSelection = (r: number, c: number): boolean => + ctx.selection.범위.some((g) => inRange(g, r, c)); + + function showPreview(g: CellRange | null): void { + if (!g) return preview.remove(); + const a = ctx.grid.editorSlot(g.r0, g.c0); + const b = ctx.grid.editorSlot(Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C)); + if (preview.parentElement !== a.layer) a.layer.append(preview); + boxStyle(preview, { + x: a.box.x, + y: a.box.y, + w: b.box.x + b.box.w - a.box.x, + h: b.box.y + b.box.h - a.box.y, + }); + } + + function fillDrag(): void { + const sheet = ctx.sheet(); + const src = ctx.selection.범위[0]; + let result = { target: src, clear: null as CellRange | null }; + drag( + (p) => { + result = fillTarget(src, p); + showPreview(result.clear ?? result.target); + }, + () => { + showPreview(null); + applyFill(sheet.id, src, result.target, result.clear); + }, + ); + } + + function applyFill( + sheet: string, + src: CellRange, + target: CellRange, + clear: CellRange | null, + ): void { + const at = ctx.selection.활성; + const sel = (g: CellRange): Selection => ({ + 시트: sheet, + 범위: [g], + 활성: at, + 기준: { ...at }, + }); + if (clear) { + ctx.dispatch({ 종류: "내용지움", 시트: sheet, 범위: [clear] }); + ctx.select(sel(target)); + return; + } + if ( + target.r0 === src.r0 && + target.r1 === src.r1 && + target.c0 === src.c0 && + target.c1 === src.c1 + ) + return; + ctx.dispatch({ 종류: "칸", 시트: sheet, 칸: fillCells(ctx.book, sheet, src, target) }); + ctx.select(sel(target)); + } + + /** 핸들 두 번 누르기 — 왼쪽(없으면 오른쪽) 열 값이 이어진 끝까지 아래로 */ + function fillDown(): void { + const sheet = ctx.sheet(); + const src = ctx.selection.범위[0]; + const side = [src.c0 - 1, src.c1 + 1].find( + (c) => c >= 0 && c <= LAST_C && hasValue(sheet, src.r1 + 1, c), + ); + if (side === undefined) return; + let r = src.r1 + 1; + while (r < LAST_R && hasValue(sheet, r + 1, side)) r++; + applyFill(sheet.id, src, { ...src, r1: r }, null); + } + + function onDown(e: MouseEvent): void { + if (e.target === ed.ta) return; // 편집 칸 안 — 글자 자리 옮김 + const hit = ctx.grid.hitTest(e.clientX, e.clientY); + if (!hit || hit.kind === "colEdge" || hit.kind === "rowEdge") return; + const sheet = ctx.sheet(); + if (e.button === 2) { + if (ed.editing() || (hit.kind === "cell" && inSelection(hit.r, hit.c))) return; + if (hit.kind === "cell") ctx.select(selectCell(sheet, hit.r, hit.c)); + else if (hit.kind === "rowHead" && !inSelection(hit.r, ctx.selection.활성.c)) + ctx.select(selectRows(sheet, hit.r, hit.r)); + else if (hit.kind === "colHead" && !inSelection(ctx.selection.활성.r, hit.c)) + ctx.select(selectCols(sheet, hit.c, hit.c)); + return; + } + if (e.button !== 0) return; + e.preventDefault(); // 초점은 편집기 · 수식 입력줄에 둠 + if (ed.editing()) { + if (hit.kind === "cell" && ed.pointable()) { + const at = { r: hit.r, c: hit.c }; + ed.point(selectCell(sheet, hit.r, hit.c)); + drag((p) => ed.point(selectSpan(sheet, at, { r: p.r, c: p.c }))); + return; + } + ed.commit(); + } + ed.focus(); + if (ctx.readOnly && hit.kind === "fillHandle") hit.kind = "cell"; + switch (hit.kind) { + case "fillHandle": + return fillDrag(); + case "corner": + return ctx.select(selectAll(sheet)); + case "rowHead": { + const a = e.shiftKey ? ctx.selection.기준.r : hit.r; + ctx.select(selectRows(sheet, a, hit.r, a)); + return drag((p) => ctx.select(selectRows(sheet, a, p.r, a))); + } + case "colHead": { + const a = e.shiftKey ? ctx.selection.기준.c : hit.c; + ctx.select(selectCols(sheet, a, hit.c, a)); + return drag((p) => ctx.select(selectCols(sheet, a, p.c, a))); + } + case "cell": { + const first = e.shiftKey + ? selectSpan(sheet, ctx.selection.기준, hit) + : selectCell(sheet, hit.r, hit.c); + ctx.select(first); + const anchor = first.기준; + let last = `${hit.r},${hit.c}`; + return drag((p) => { + if (`${p.r},${p.c}` === last) return; + last = `${p.r},${p.c}`; + ctx.select(selectSpan(sheet, anchor, p)); + }); + } + } + } + + function onDbl(e: MouseEvent): void { + if (e.target === ed.ta || ctx.readOnly) return; + const hit = ctx.grid.hitTest(e.clientX, e.clientY); + if (!hit) return; + if (hit.kind === "fillHandle") return fillDown(); + if (hit.kind === "cell" && !ed.editing()) ed.begin(); + } + + root.addEventListener("mousedown", onDown); + root.addEventListener("dblclick", onDbl); + return () => { + stopDrag?.(); + preview.remove(); + root.removeEventListener("mousedown", onDown); + root.removeEventListener("dblclick", onDbl); + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_parser.ts b/A00_Common/spreadsheet/spreadsheet_parser.ts index ab069c7d..7279f7b0 100644 --- a/A00_Common/spreadsheet/spreadsheet_parser.ts +++ b/A00_Common/spreadsheet/spreadsheet_parser.ts @@ -4,26 +4,310 @@ * 우선순위는 엑셀과 같음: 범위 `:` > 부호 `-` `+` > `%` > `^` > `* /` > `+ -` > `&` > 비교 * (부호가 `^` 보다 먼저 — `-2^2 = 4`). 같은 단은 왼쪽부터(`^` 도 왼쪽부터 · 엑셀과 같음). * 글 `"…"` 안 `""` = 따옴표 하나 · 시트 `'이름'!A1` · `이름!A1` · 오류 값 글자 · TRUE/FALSE. - * 0 계약 머리 — 몸은 A 가 채움. + * 참조 낱말 풀기(`parseRef` · `renderRef`)는 참조 옮김(`spreadsheet_refshift.ts`)이 같이 씀. * ========================================================================== */ -import type { FormulaNode, ParseResult, Token } from "./spreadsheet_types"; +import { parseDecimal, ZERO } from "@ui/sheet/ui_template_sheet_frac"; +import { colIndex, colName, quoteSheet, rowIndex } from "./spreadsheet_address"; +import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; +import type { ErrorCode, FormulaNode, ParseResult, RefCell, Token } from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_parser: 아직 없음(A)"); -}; +// ── 참조 낱말 ──────────────────────────────────────────────────────────────── -/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용 */ -export function tokenize(_formula: string): Token[] { - return todo(); +const IDC = "[\\p{L}\\p{N}_.]"; +const END = `(?!${IDC}|\\()`; +const CELL = "(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+)"; +const RE_PREFIX = /^(?:'((?:[^']|'')+)'|([\p{L}\p{N}_.]+))!/u; +const RE_AREA = new RegExp(`^${CELL}(?::${CELL})?${END}`, "u"); +const RE_COLS = new RegExp(`^(\\$?)([A-Za-z]{1,3}):(\\$?)([A-Za-z]{1,3})${END}`, "u"); +const RE_ROWS = new RegExp(`^(\\$?)(\\d+):(\\$?)(\\d+)${END}`, "u"); +const RE_ERROR = /^#(NULL!|DIV\/0!|VALUE!|REF!|NAME\?|NUM!|N\/A|CYCLE!)/i; +const RE_IDENT = /^[\p{L}_\\][\p{L}\p{N}_.]*/u; + +/** 참조 낱말 하나 — `kind` cell = 칸 · area = `A1:B2` · cols = `A:C` · rows = `3:5`. */ +export interface RefParts { + /** 적힌 시트 머리 글 그대로(`'돌-골막이'!`) · 없으면 "" */ + prefix: string; + sheet: string | null; + kind: "cell" | "area" | "cols" | "rows"; + a: RefCell; + b: RefCell; } +/** 참조 글 → 조각 · 참조가 아니면 null(주소가 격자 밖이어도 null → 이름) */ +export function parseRef(text: string): RefParts | null { + const pm = RE_PREFIX.exec(text); + const prefix = pm ? pm[0] : ""; + const sheet = pm ? (pm[1] !== undefined ? pm[1].replace(/''/g, "'") : pm[2]) : null; + const body = text.slice(prefix.length); + const pt = (sr: string, col: number, sc: string, row: number): RefCell => ({ + r: row, + c: col, + absR: sc === "$", + absC: sr === "$", + }); + let m = RE_AREA.exec(body); + if (m && m[0].length === body.length) { + const a = pt(m[1], colIndex(m[2]), m[3], rowIndex(m[4])); + const b = m[5] === undefined ? { ...a } : pt(m[5], colIndex(m[6]), m[7], rowIndex(m[8])); + if (a.r < 0 || a.c < 0 || b.r < 0 || b.c < 0) return null; + return { prefix, sheet, kind: m[5] === undefined ? "cell" : "area", a, b }; + } + m = RE_COLS.exec(body); + if (m && m[0].length === body.length) { + const c0 = colIndex(m[2]); + const c1 = colIndex(m[4]); + if (c0 < 0 || c1 < 0) return null; + return { + prefix, + sheet, + kind: "cols", + a: pt(m[1], c0, "", 0), + b: pt(m[3], c1, "", MAX_ROWS - 1), + }; + } + m = RE_ROWS.exec(body); + if (m && m[0].length === body.length) { + const r0 = rowIndex(m[2]); + const r1 = rowIndex(m[4]); + if (r0 < 0 || r1 < 0) return null; + return { + prefix, + sheet, + kind: "rows", + a: pt("", 0, m[1], r0), + b: pt("", MAX_COLS - 1, m[3], r1), + }; + } + return null; +} + +const cellText = (p: RefCell): string => + `${p.absC ? "$" : ""}${colName(p.c)}${p.absR ? "$" : ""}${p.r + 1}`; + +/** 조각 → 참조 글(시트 머리는 `prefix` 그대로) */ +export function renderRef(p: RefParts): string { + if (p.kind === "cols") + return `${p.prefix}${p.a.absC ? "$" : ""}${colName(p.a.c)}:${p.b.absC ? "$" : ""}${colName(p.b.c)}`; + if (p.kind === "rows") + return `${p.prefix}${p.a.absR ? "$" : ""}${p.a.r + 1}:${p.b.absR ? "$" : ""}${p.b.r + 1}`; + return p.prefix + cellText(p.a) + (p.kind === "area" ? `:${cellText(p.b)}` : ""); +} + +// ── 낱말 나누기 ─────────────────────────────────────────────────────────────── + +/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용. + * 이름(정의 안 된 글자 묶음)도 `unknown` — 파서가 이름 나무로 읽음. */ +export function tokenize(formula: string): Token[] { + const out: Token[] = []; + let i = 0; + let ref: string | null; + const push = (kind: Token["kind"], len: number) => { + out.push({ kind, text: formula.slice(i, i + len), start: i, end: i + len }); + i += len; + }; + while (i < formula.length) { + const rest = formula.slice(i); + const ch = rest[0]; + let m: RegExpExecArray | null; + if ((m = /^\s+/.exec(rest))) push("space", m[0].length); + else if (ch === '"') { + const s = /^"(?:[^"]|"")*"?/.exec(rest)!; + push("string", s[0].length); + } else if ((m = /^([\p{L}_][\p{L}\p{N}_.]*)\(/u.exec(rest)) && !/^(TRUE|FALSE)$/i.test(m[1])) + push("func", m[1].length); + else if ((ref = refAt(rest))) push(/#REF!$/i.test(ref) ? "error" : "ref", ref.length); + else if ((m = /^(TRUE|FALSE)(?![\p{L}\p{N}_.(])/iu.exec(rest))) push("bool", m[0].length); + else if ((m = RE_ERROR.exec(rest))) push("error", m[0].length); + else if ((m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(rest))) push("number", m[0].length); + else if ((m = RE_IDENT.exec(rest))) push("unknown", m[0].length); + else if ((m = /^(<>|<=|>=|[-+*/^&=<>%:])/.exec(rest))) push("op", m[0].length); + else if (ch === "(" || ch === ")") push("paren", 1); + else if (ch === ",") push("comma", 1); + else push("unknown", 1); + } + return out; +} + +/** 머리 이 자리에서 시작하는 참조(또는 `시트!#REF!`) 글 */ +function refAt(rest: string): string | null { + const head = RE_PREFIX.exec(rest)?.[0] ?? ""; + const body = rest.slice(head.length); + if (head && /^#REF!/i.test(body)) return head + body.slice(0, 5); + for (const re of [RE_AREA, RE_COLS, RE_ROWS]) { + const m = re.exec(body); + if (m && parseRef(head + m[0])) return head + m[0]; + } + return null; +} + +// ── 나무 ──────────────────────────────────────────────────────────────────── + +class Fail { + constructor( + readonly message: string, + readonly at: number, + ) {} +} + +const COMPARE = ["=", "<>", "<", "<=", ">", ">="]; + /** 앞 `=` 뺀 식 글 → 나무. 앞에 `=` 가 붙어 와도 됨. 함수 이름은 대문자로. */ -export function parseFormula(_formula: string): ParseResult { - return todo(); +export function parseFormula(formula: string): ParseResult { + const text = formula.startsWith("=") ? formula.slice(1) : formula; + const toks = tokenize(text).filter((t) => t.kind !== "space"); + let p = 0; + const peek = (): Token | undefined => toks[p]; + const isOp = (...ops: string[]) => { + const t = toks[p]; + return !!t && t.kind === "op" && ops.includes(t.text); + }; + const where = () => (toks[p] ? toks[p].start : text.length); + + const binary = (ops: string[], next: () => FormulaNode) => (): FormulaNode => { + let left = next(); + while (isOp(...ops)) { + const op = toks[p++].text as "+"; + left = { type: "binary", op, left, right: next() }; + } + return left; + }; + const primary = (): FormulaNode => { + const t = peek(); + if (!t) throw new Fail("식이 끝남", text.length); + p++; + switch (t.kind) { + case "number": + return { type: "number", value: parseDecimal(t.text), text: t.text }; + case "string": + if (t.text.length < 2 || !t.text.endsWith('"')) throw new Fail("따옴표가 안 닫힘", t.start); + return { type: "string", value: t.text.slice(1, -1).replace(/""/g, '"') }; + case "bool": + return { type: "bool", value: t.text.toUpperCase() === "TRUE" }; + case "error": + return { + type: "error", + code: t.text.slice(t.text.lastIndexOf("#")).toUpperCase() as ErrorCode, + }; + case "ref": { + const r = parseRef(t.text)!; + return r.kind === "cell" + ? { type: "cell", sheet: r.sheet, ref: r.a } + : { type: "range", sheet: r.sheet, from: r.a, to: r.b }; + } + case "func": { + const name = t.text.toUpperCase().replace(/^_XLFN\./, ""); + p++; // `(` + const args: FormulaNode[] = []; + if (peek()?.text === ")") { + p++; + return { type: "call", name, args }; + } + for (;;) { + const k = peek(); + // 빈 인자(`IF(A1,,1)`) = 0 · 글은 비움 + if (k && (k.kind === "comma" || k.text === ")")) + args.push({ type: "number", value: ZERO, text: "" }); + else args.push(compare()); + const s = toks[p++]; + if (s?.kind === "comma") continue; + if (s?.text === ")") return { type: "call", name, args }; + throw new Fail("함수 괄호가 안 닫힘", s ? s.start : text.length); + } + } + case "paren": { + if (t.text !== "(") break; + const inner = compare(); + if (peek()?.text !== ")") throw new Fail("괄호가 안 닫힘", where()); + p++; + return inner; + } + case "unknown": + if (RE_IDENT.exec(t.text)?.[0] === t.text) return { type: "name", name: t.text }; + } + throw new Fail(`읽지 못한 글: ${t.text}`, t.start); + }; + const unary = (): FormulaNode => { + if (isOp("-", "+")) { + const op = toks[p++].text as "-"; + return { type: "unary", op, arg: unary() }; + } + return primary(); + }; + const percent = (): FormulaNode => { + let node = unary(); + while (isOp("%")) { + p++; + node = { type: "unary", op: "%", arg: node }; + } + return node; + }; + const power = binary(["^"], percent); + const mul = binary(["*", "/"], power); + const add = binary(["+", "-"], mul); + const concat = binary(["&"], add); + const compare = binary(COMPARE, concat); + + try { + if (!toks.length) throw new Fail("빈 식", 0); + const node = compare(); + if (p < toks.length) throw new Fail(`남은 글: ${toks[p].text}`, toks[p].start); + return { ok: true, node }; + } catch (e) { + if (e instanceof Fail) return { ok: false, message: e.message, at: e.at }; + throw e; + } } -/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 */ -export function formulaToText(_node: FormulaNode): string { - return todo(); +// ── 나무 → 글 ───────────────────────────────────────────────────────────────── + +const PREC: Record = { "&": 2, "+": 3, "-": 3, "*": 4, "/": 4, "^": 5 }; +for (const op of COMPARE) PREC[op] = 1; + +function prec(node: FormulaNode): number { + if (node.type === "binary") return PREC[node.op]; + if (node.type === "unary") return node.op === "%" ? 6 : 7; + return 8; +} + +const sheetHead = (sheet: string | null) => (sheet === null ? "" : `${quoteSheet(sheet)}!`); + +/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만 */ +export function formulaToText(node: FormulaNode): string { + const wrap = (child: FormulaNode, need: boolean) => + need ? `(${formulaToText(child)})` : formulaToText(child); + switch (node.type) { + case "number": + return node.text; + case "string": + return `"${node.value.replace(/"/g, '""')}"`; + case "bool": + return node.value ? "TRUE" : "FALSE"; + case "error": + return node.code; + case "name": + return node.name; + case "cell": + return sheetHead(node.sheet) + cellText(node.ref); + case "range": { + const { from, to } = node; + const kind = + from.r === 0 && to.r === MAX_ROWS - 1 + ? "cols" + : from.c === 0 && to.c === MAX_COLS - 1 + ? "rows" + : "area"; + return renderRef({ prefix: sheetHead(node.sheet), sheet: node.sheet, kind, a: from, b: to }); + } + case "unary": + return node.op === "%" + ? `${wrap(node.arg, prec(node.arg) < 6)}%` + : `${node.op}${wrap(node.arg, prec(node.arg) < 7)}`; + case "binary": { + const p = PREC[node.op]; + return `${wrap(node.left, prec(node.left) < p)}${node.op}${wrap(node.right, prec(node.right) <= p)}`; + } + case "call": + return `${node.name}(${node.args.map(formulaToText).join(",")})`; + } } 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/A00_Common/spreadsheet/spreadsheet_recalc.ts b/A00_Common/spreadsheet/spreadsheet_recalc.ts index 7fa012da..30383ce2 100644 --- a/A00_Common/spreadsheet/spreadsheet_recalc.ts +++ b/A00_Common/spreadsheet/spreadsheet_recalc.ts @@ -2,15 +2,11 @@ * spreadsheet_recalc.ts (주인 A) * 통합문서 한 벌 풀이 — 서버 Node 진입(`common_util_spreadsheet_node.ts`) · 시험이 부름. * 화면과 같은 엔진(`createCalcEngine`)을 한 번 돌려 `snapshot()` — 계산이 두 벌이 되지 않게. - * 0 계약 머리 — 몸은 A 가 채움. * ========================================================================== */ +import { createCalcEngine } from "./spreadsheet_graph"; import type { CalcValues, Workbook } from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_recalc: 아직 없음(A)"); -}; - -export function recalcWorkbook(_book: Workbook): CalcValues { - return todo(); +export function recalcWorkbook(book: Workbook): CalcValues { + return createCalcEngine(book).snapshot(); } diff --git a/A00_Common/spreadsheet/spreadsheet_refshift.ts b/A00_Common/spreadsheet/spreadsheet_refshift.ts index 75d87001..fe28e699 100644 --- a/A00_Common/spreadsheet/spreadsheet_refshift.ts +++ b/A00_Common/spreadsheet/spreadsheet_refshift.ts @@ -1,33 +1,179 @@ /* ============================================================================= * spreadsheet_refshift.ts (주인 A) - * 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 때 식 글을 고쳐 적음(엑셀 규칙) · - * 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1. + * 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 · 시트 지우기 때 식 글을 고쳐 적음 + * (엑셀 규칙) · 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1. * 지운 칸 · 격자 밖으로 나간 참조는 `#REF!` · 범위는 남은 만큼 줄어듦(끝까지 지우면 `#REF!`). - * 0 계약 머리 — 몸은 A 가 채움. + * 낱말 단위로 참조만 바꿔 끼움 — 빈칸 · 괄호 · 수 글은 적힌 그대로 둠. + * + * 엑셀 규칙표: + * 넣기 — 넣는 자리 이후를 가리키면 밀림 · 범위가 넣는 자리를 품으면 늘어남(첫 행 앞 넣기는 통째 밀림) + * 지우기 — 지운 칸 하나 = `#REF!` · 범위는 남은 만큼 · 전부 지우면 `#REF!` · `$` 도 똑같이 옮김 + * 옮기기 — 옮긴 덩어리 안을 가리키던 참조(범위는 통째 들 때만)는 따라감 · 덮어쓴 자리를 가리키면 `#REF!` + * 복사 — 상대만 (dr, dc) · 행 · 열 전체 범위는 그 축을 안 옮김 * ========================================================================== */ -import type { CellAddress, Command, Workbook } from "./spreadsheet_types"; +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 todo = (): never => { - throw new Error("spreadsheet_refshift: 아직 없음(A)"); -}; +const REF_ERR = "#REF!"; -/** 행열 · 옮기기 · 시트이름 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글 */ +/** 참조 낱말만 `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, + formula: string, + host: string, + command: Command, + book: Workbook, + newHost: string = host, ): string { - return todo(); + 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 todo(); +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 { - return todo(); +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 }, + }); + }, + ); } diff --git a/A00_Common/spreadsheet/spreadsheet_selection.ts b/A00_Common/spreadsheet/spreadsheet_selection.ts new file mode 100644 index 00000000..64449dcf --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_selection.ts @@ -0,0 +1,284 @@ +/* ============================================================================= + * spreadsheet_selection.ts (주인 D) + * 고름 모델 — 한 칸 · 범위 · 행열 전체 · 전체 · 방향키(Shift 늘림 · Ctrl 끝으로) · 병합으로 늘림 · + * 고름 안 Enter/Tab 돌기. DOM 모름(시험이 그대로 부름). 엑셀 동작이 기준. + * ========================================================================== */ + +import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address"; +import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; +import type { CellAddress, CellRange, Sheet } from "./spreadsheet_types"; +import type { Selection } from "./spreadsheet_view_types"; + +export const LAST_R = MAX_ROWS - 1; +export const LAST_C = MAX_COLS - 1; + +export function merges(sheet: Sheet): CellRange[] { + const out: CellRange[] = []; + for (const t of sheet.병합 ?? []) { + const m = parseRange(t); + if (m) out.push(m); + } + return out; +} + +const overlaps = (a: CellRange, b: CellRange): boolean => + a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1; + +export const inRange = (g: CellRange, r: number, c: number): boolean => + r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1; + +/** 병합 일부를 걸친 범위 → 병합 전체를 품게 늘림(늘린 뒤 새로 걸친 것도) */ +export function expand(range: CellRange, list: CellRange[]): CellRange { + const g = { ...range }; + for (let grew = true; grew;) { + grew = false; + for (const m of list) { + if (!overlaps(g, m)) continue; + if (m.r0 < g.r0 || m.r1 > g.r1 || m.c0 < g.c0 || m.c1 > g.c1) { + g.r0 = Math.min(g.r0, m.r0); + g.r1 = Math.max(g.r1, m.r1); + g.c0 = Math.min(g.c0, m.c0); + g.c1 = Math.max(g.c1, m.c1); + grew = true; + } + } + } + return g; +} + +/** 칸이 든 병합(없으면 칸 하나) */ +export function mergeAt(sheet: Sheet, r: number, c: number): CellRange { + return merges(sheet).find((m) => inRange(m, r, c)) ?? { r0: r, c0: c, r1: r, c1: c }; +} + +export const rowHidden = (sheet: Sheet, r: number): boolean => !!sheet.행?.[String(r + 1)]?.숨김; + +export function colHidden(sheet: Sheet, c: number): boolean { + const cols = sheet.열; + if (!cols) return false; + return !!cols[colName(c)]?.숨김; +} + +export function hasValue(sheet: Sheet, r: number, c: number): boolean { + const cell = sheet.칸[toA1(r, c)]; + return !!cell && (cell.식 !== undefined || (cell.값 !== undefined && cell.값 !== "")); +} + +export function span(a: CellAddress, b: CellAddress): CellRange { + return { + r0: Math.min(a.r, b.r), + c0: Math.min(a.c, b.c), + r1: Math.max(a.r, b.r), + c1: Math.max(a.c, b.c), + }; +} + +/** 칸 하나 고름 — 병합 안이면 병합 전체 · 활성은 병합 왼위 */ +export function selectCell(sheet: Sheet, r: number, c: number): Selection { + const m = mergeAt(sheet, r, c); + const at = { r: m.r0, c: m.c0 }; + return { 시트: sheet.id, 범위: [m], 활성: at, 기준: { ...at } }; +} + +/** 닻(기준)에서 `to` 까지 — 활성은 닻 그대로(엑셀 Shift) */ +export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress): Selection { + const g = expand(span(anchor, to), merges(sheet)); + return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } }; +} + +/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */ +export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection { + const r0 = Math.min(a, b); + const at = { r: active ?? r0, c: firstVisible(sheet, "c") }; + return { + 시트: sheet.id, + 범위: [expand({ r0, r1: Math.max(a, b), c0: 0, c1: LAST_C }, merges(sheet))], + 활성: at, + 기준: { ...at }, + }; +} + +export function selectCols(sheet: Sheet, a: number, b: number, active?: number): Selection { + const c0 = Math.min(a, b); + const at = { r: firstVisible(sheet, "r"), c: active ?? c0 }; + return { + 시트: sheet.id, + 범위: [expand({ r0: 0, r1: LAST_R, c0, c1: Math.max(a, b) }, merges(sheet))], + 활성: at, + 기준: { ...at }, + }; +} + +export function selectAll(sheet: Sheet, active?: CellAddress): Selection { + const at = active ?? { r: firstVisible(sheet, "r"), c: firstVisible(sheet, "c") }; + return { + 시트: sheet.id, + 범위: [{ r0: 0, c0: 0, r1: LAST_R, c1: LAST_C }], + 활성: at, + 기준: { ...at }, + }; +} + +function firstVisible(sheet: Sheet, axis: "r" | "c"): number { + let i = 0; + while (i < 200 && (axis === "r" ? rowHidden(sheet, i) : colHidden(sheet, i))) i++; + return i; +} + +/** 한 칸 옮김 — 숨김 건너뜀 · 끝에서 멈춤 */ +function step(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress { + let { r, c } = at; + do { + r += dr; + c += dc; + } while ( + r >= 0 && + r <= LAST_R && + c >= 0 && + c <= LAST_C && + ((dr && rowHidden(sheet, r)) || (dc && colHidden(sheet, c))) + ); + if (r < 0 || r > LAST_R || c < 0 || c > LAST_C) return at; + return { r, c }; +} + +/** 한 줄(행 또는 열)에서 값 든 자리 — 오름차순 */ +function occupied(sheet: Sheet, at: CellAddress, vertical: boolean): number[] { + const out: number[] = []; + for (const key of Object.keys(sheet.칸)) { + const a = parseA1(key); + if (!a || !hasValue(sheet, a.r, a.c)) continue; + if (vertical ? a.c === at.c : a.r === at.r) out.push(vertical ? a.r : a.c); + } + return out.sort((a, b) => a - b); +} + +/** Ctrl+방향 — 값 사이를 뛰어 끝으로(엑셀: 값 칸 뭉치의 끝 · 빈 칸이면 다음 값 · 없으면 시트 끝) */ +function jump(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress { + const vertical = dr !== 0; + const d = vertical ? dr : dc; + const pos = vertical ? at.r : at.c; + const edge = d > 0 ? (vertical ? LAST_R : LAST_C) : 0; + const set = new Set(occupied(sheet, at, vertical)); + const has = (i: number): boolean => set.has(i); + const put = (i: number): CellAddress => (vertical ? { r: i, c: at.c } : { r: at.r, c: i }); + if (pos === edge) return at; + if (has(pos) && has(pos + d)) { + let i = pos; + while (i !== edge && has(i + d)) i += d; + return put(i); + } + const sorted = [...set].sort((a, b) => a - b); + const next = d > 0 ? sorted.find((i) => i > pos) : sorted.reverse().find((i) => i < pos); + return put(next ?? edge); +} + +export interface MoveOptions { + /** Shift — 닻은 두고 움직이는 끝만 */ + extend?: boolean; + /** Ctrl — 값 끝으로 */ + jump?: boolean; +} + +/** 방향키 · Home 류 공통 — 새 고름 */ +export function move( + sheet: Sheet, + sel: Selection, + dr: number, + dc: number, + opts: MoveOptions = {}, +): Selection { + const list = merges(sheet); + if (!opts.extend) { + const from = sel.활성; + const m = mergeAt(sheet, from.r, from.c); + // 병합에서 나갈 때는 병합 끝에서 한 칸 + const edge = { r: dr > 0 ? m.r1 : m.r0, c: dc > 0 ? m.c1 : m.c0 }; + const to = opts.jump ? jump(sheet, edge, dr, dc) : step(sheet, edge, dr, dc); + if (to.r === edge.r && to.c === edge.c) return selectCell(sheet, from.r, from.c); + return selectCell(sheet, to.r, to.c); + } + const g = sel.범위[0] ?? span(sel.활성, sel.활성); + const anchor = sel.기준; + // 움직이는 끝 = 닻에서 먼 쪽 + const far = { + r: Math.abs(g.r1 - anchor.r) >= Math.abs(anchor.r - g.r0) ? g.r1 : g.r0, + c: Math.abs(g.c1 - anchor.c) >= Math.abs(anchor.c - g.c0) ? g.c1 : g.c0, + }; + let to = opts.jump ? jump(sheet, far, dr, dc) : step(sheet, far, dr, dc); + // 줄이는 쪽이면 병합 너머까지 한 번에 + for (let guard = 0; guard < 50; guard++) { + const next = expand(span(anchor, to), list); + if (!sameRange(next, g) || to === far) break; + to = step(sheet, to, dr, dc); + } + return { + 시트: sheet.id, + 범위: [expand(span(anchor, to), list)], + 활성: { ...sel.활성 }, + 기준: { ...anchor }, + }; +} + +export const sameRange = (a: CellRange, b: CellRange): boolean => + a.r0 === b.r0 && a.c0 === b.c0 && a.r1 === b.r1 && a.c1 === b.c1; + +/** 쓰인 칸 끝(Ctrl+End) */ +export function usedEnd(sheet: Sheet): CellAddress { + let r = 0; + let c = 0; + for (const key of Object.keys(sheet.칸)) { + const a = parseA1(key); + if (!a) continue; + r = Math.max(r, a.r); + c = Math.max(c, a.c); + } + return { r, c }; +} + +/** 고름 안 Enter/Tab 돌기 — 범위가 한 칸(또는 병합 하나)이면 null(밖으로 옮김) */ +export function cycle(sheet: Sheet, sel: Selection, dr: number, dc: number): Selection | null { + const g = sel.범위[0]; + if (!g) return null; + const list = merges(sheet); + if (list.some((m) => sameRange(m, g)) || (g.r0 === g.r1 && g.c0 === g.c1)) return null; + // 행 전체 · 열 전체는 쓰인 칸 끝까지만 돎 + const end = usedEnd(sheet); + const box = { + ...g, + r1: g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1, + c1: g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1, + }; + let { r, c } = sel.활성; + const cur = mergeAt(sheet, r, c); + const rows = box.r1 - box.r0 + 1; + const cols = box.c1 - box.c0 + 1; + for (let n = 0; n < rows * cols; n++) { + if (dr) { + r += dr; + if (r > box.r1) ((r = box.r0), (c = c + 1 > box.c1 ? box.c0 : c + 1)); + if (r < box.r0) ((r = box.r1), (c = c - 1 < box.c0 ? box.c1 : c - 1)); + } else { + c += dc; + if (c > box.c1) ((c = box.c0), (r = r + 1 > box.r1 ? box.r0 : r + 1)); + if (c < box.c0) ((c = box.c1), (r = r - 1 < box.r0 ? box.r1 : r - 1)); + } + if (rowHidden(sheet, r) || colHidden(sheet, c)) continue; + const m = mergeAt(sheet, r, c); + if (m.r0 !== r || m.c0 !== c || sameRange(m, cur)) continue; + return { ...sel, 범위: sel.범위.map((x) => ({ ...x })), 활성: { r, c } }; + } + return null; +} + +/** 고름 안 칸 전부(행열 전체는 쓰인 끝까지) — Ctrl+Enter · 지우기 대상 */ +export function cellsOf(sheet: Sheet, sel: Selection): CellAddress[] { + const end = usedEnd(sheet); + const out: CellAddress[] = []; + for (const g of sel.범위) { + const r1 = g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1; + const c1 = g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1; + for (let r = g.r0; r <= r1; r++) for (let c = g.c0; c <= c1; c++) out.push({ r, c }); + } + return out; +} diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index fadc2fdc..1091d467 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -149,6 +149,7 @@ body > canvas[data-id="canvas"] { flex-direction: column; background: var(--cad-chrome-raised); border-bottom: 1px solid var(--cad-line); + transition: height 120ms ease; } .cad-ribbon-tabs { display: flex; @@ -157,6 +158,21 @@ body > canvas[data-id="canvas"] { padding: 0 8px; border-bottom: 1px solid var(--cad-line); } +.cad-ribbon-tabs__collapse { + margin-left: auto; + width: 24px; + height: 20px; + align-self: center; + padding: 0; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--cad-text-dim); + font-size: 11px; +} +.cad-ribbon-tabs__collapse:hover { + background: var(--cad-hover); +} .cad-ribbon-tabs button { padding: 0 14px; border: 1px solid transparent; diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index c45c847c..f197d58e 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -12,6 +12,7 @@ import { QuickAccessBar } from './QuickAccessBar'; import { Ribbon } from '../ribbon/Ribbon'; import { StatusBar } from './StatusBar'; import { useCadRefresh } from './use-cad-refresh'; +import { isRibbonCollapsed } from './ui-state'; import { getActiveToolActor } from '../state'; import type { Tool } from '../tools'; import { ViewControls } from './ViewControls'; @@ -22,6 +23,7 @@ export const Toolbar: FC = () => { const [commandLineVisible, setCommandLineVisible] = useState(false); const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null; + const ribbonCollapsed = isRibbonCollapsed(); useEffect(() => { // 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아 @@ -32,8 +34,11 @@ export const Toolbar: FC = () => { '--cad-command-height', commandLineVisible ? '86px' : '0px' ); + // 리본 접힘 — 탭 줄(26px)만 남기고 패널 줄 자리를 걷는다(`Ribbon.tsx` 가 안 그림). + if (ribbonCollapsed) document.documentElement.style.setProperty('--cad-ribbon-height', '26px'); + else document.documentElement.style.removeProperty('--cad-ribbon-height'); window.dispatchEvent(new Event('resize')); - }, [panelCollapsed, commandLineVisible]); + }, [panelCollapsed, commandLineVisible, ribbonCollapsed]); return ( <> diff --git a/B07_DesignDetail/openwebcad/src/components/ui-state.ts b/B07_DesignDetail/openwebcad/src/components/ui-state.ts index b6a19552..868f1375 100644 --- a/B07_DesignDetail/openwebcad/src/components/ui-state.ts +++ b/B07_DesignDetail/openwebcad/src/components/ui-state.ts @@ -12,6 +12,8 @@ let inspectorCollapsed = false; let quickPropertiesVisible = false; let activeRibbonTab = 'home'; let blockLibraryVisible = false; +/** 호스트(M02)가 처음부터 접어 실을 때 true — 탭을 누르면 펼침(`setActiveRibbonTab`). */ +let ribbonCollapsed = false; const notify = () => notifyWindow(HtmlEvent.UPDATE_STATE); @@ -20,12 +22,18 @@ export const isInspectorCollapsed = () => inspectorCollapsed; export const isQuickPropertiesVisible = () => quickPropertiesVisible; export const getActiveRibbonTab = () => activeRibbonTab; export const isBlockLibraryVisible = () => blockLibraryVisible; +export const isRibbonCollapsed = () => ribbonCollapsed; export function setActiveRibbonTab(tabId: string): void { activeRibbonTab = tabId; notify(); } +export function setRibbonCollapsed(collapsed: boolean): void { + ribbonCollapsed = collapsed; + notify(); +} + export function openInspector(tab: InspectorTab): void { inspectorTab = tab; inspectorCollapsed = false; diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts index 6cfbf049..6783c378 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts @@ -84,7 +84,7 @@ export async function getEntitiesAndLayersFromJsonObject( if (groupId) entity.groupId = groupId; } let layers = data.layers; - if (data.layers.length === 0) { + if (!layers || layers.length === 0) { layers = [getNewLayer()]; } return { diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index a29f203c..b42e3130 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -25,6 +25,7 @@ import { setHostTitle, setLayers, } from '../state.ts'; +import { setRibbonCollapsed } from '../components/ui-state.ts'; import { toast } from 'react-toastify'; import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; @@ -57,6 +58,8 @@ interface DrawingLoadMessage { hostTitle?: string; /** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡기는가. */ hostSave?: boolean; + /** 리본을 접힌 채로 여는가 — 없으면(B07) 펼침 그대로. */ + ribbonCollapsed?: boolean; } interface DrawingSaveRequestMessage { @@ -207,6 +210,7 @@ export function registerAisloDrawingBridge() { setHostReadOnly(event.data.readOnly === true); setHostTitle(event.data.hostTitle ?? 'B07 상세 설계'); setHostSave(event.data.hostSave === true); + setRibbonCollapsed(event.data.ribbonCollapsed === true); if (event.data.frameEdit) applyFramePreview(); // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** // 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측: diff --git a/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx b/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx index 57fe1f00..b46d3c77 100644 --- a/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx +++ b/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx @@ -3,7 +3,12 @@ import { type FC, type ReactNode, useEffect, useRef, useState } from 'react'; import type { CadCommand } from '../commands/command.types'; import { getCommandById } from '../commands/registry'; import { runCommand } from '../commands/run-command'; -import { getActiveRibbonTab, setActiveRibbonTab } from '../components/ui-state'; +import { + getActiveRibbonTab, + isRibbonCollapsed, + setActiveRibbonTab, + setRibbonCollapsed, +} from '../components/ui-state'; import type { Tool } from '../tools'; import { RIBBON_TABS, type RibbonPanel, type RibbonWidget } from './ribbon.config'; @@ -147,6 +152,7 @@ const Panel: FC<{ export const Ribbon: FC = ({ activeTool, widgets, onCommandRun }) => { const activeTabId = getActiveRibbonTab(); const activeTab = RIBBON_TABS.find((tab) => tab.id === activeTabId) ?? RIBBON_TABS[0]; + const collapsed = isRibbonCollapsed(); const handleRun = (command: CadCommand) => { runCommand(command); @@ -161,23 +167,36 @@ export const Ribbon: FC = ({ activeTool, widgets, onCommandRun }) = type="button" key={tab.id} data-active={tab.id === activeTab.id} - onClick={() => setActiveRibbonTab(tab.id)} + onClick={() => { + setActiveRibbonTab(tab.id); + if (collapsed) setRibbonCollapsed(false); + }} > {tab.label} ))} + -
- {activeTab.panels.map((panel) => ( - - ))} -
+ {!collapsed && ( +
+ {activeTab.panels.map((panel) => ( + + ))} +
+ )} ); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 68dd9ca7..8a142780 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -7,8 +7,9 @@ import { API_BASE_URL } from "@config/config_frontend"; /** 층 이름 — 서버와 같은 글 */ export type Layer = "system" | "company" | "personal" | "project"; -/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure */ -export type Kind = "table" | "drawing" | "structure"; +/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · + * 「상세 산출근거」 = basis(구조물집계표 열마다 한 통합문서) */ +export type Kind = "table" | "drawing" | "structure" | "basis"; export interface TemplateInfo { 종류: Kind; @@ -95,7 +96,7 @@ export const deleteLayerTemplate = ( /* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) --- * 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */ -/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · `산출근거` = 표 양식과 같은 표 문서 */ +/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · 산출근거는 따로(`basis` · 같은 열 id) */ export interface StructureDoc { 양식: "구조물도면"; 종류: "structure"; @@ -103,14 +104,13 @@ export interface StructureDoc { 열: string; 도번: string; 도면: { entities: unknown[] } & Record; - 산출근거: { 열: unknown[]; 줄: unknown[] } & Record; } /** 시스템 층 구조물 도면 목록 */ export const listStructures = async (): Promise => (await listTemplates("system", null)).filter((t) => t.종류 === "structure"); -/** 새로 — 도번 자동 · A1 도각을 깐 빈 도면 + 빈 산출근거 표 · 이미 있으면 StaleError */ +/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 산출근거 통합문서) · 이미 있으면 StaleError */ export const createStructure = (column: string): Promise => call(`/structures/${enc(column)}`, { method: "POST" }); @@ -118,6 +118,11 @@ export const createStructure = (column: string): Promise => export const fetchStructureNumbers = (): Promise> => call("/structures/numbers"); +/* --- ⚠ 임시 — [스프레드시트 시험] 단추(1단계 검증 뒤 지움) · 저장 자리 = 서버 tmp 견본 --- */ +export const readSpreadsheetTrial = (): Promise<{ 문서: unknown }> => call("/spreadsheet-trial"); +export const saveSpreadsheetTrial = (문서: unknown): Promise<{ 문서: unknown }> => + call("/spreadsheet-trial", { method: "PUT", body: JSON.stringify({ 문서 }) }); + /* --- 프로젝트 층 단추 다섯 --- */ const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`; const post = (path: string, body: object = {}): Promise => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index 63ae1738..6c0bec41 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -189,7 +189,13 @@ export function mountDrawingTemplate( null, !readOnly, {}, - { recoveryScope, readOnly, hostTitle: "도면 양식 편집", hostSave: true }, + { + recoveryScope, + readOnly, + hostTitle: "도면 양식 편집", + hostSave: true, + ribbonCollapsed: true, + }, ); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router.py b/M02_MasterTemplete/M02_MasterTemplete_Router.py index 54a4c74f..764fa989 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router.py @@ -5,11 +5,15 @@ from __future__ import annotations +import json +from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from common_util import common_util_spreadsheet as spreadsheet +from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_MasterTemplete_Store as store router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"]) @@ -57,3 +61,34 @@ def put_template(kind: str, name: str, body: SaveBody) -> dict: def delete_template(kind: str, name: str, 판: str | None = None) -> dict: _call(store.delete, kind, name, 판) return {"ok": True} + + +# ── ⚠ 임시(스프레드시트 시험 단추 · 사용자 지시 · 1단계 검증 뒤 지움) ────────── +# 저장 자리 = `tmp/spreadsheet_trial.json`(git 밖) · 없으면 시트 셋 빈 통합문서. +TRIAL_PATH = Path(__file__).resolve().parent.parent / "tmp" / "spreadsheet_trial.json" + + +def _trial_doc() -> dict[str, Any]: + sheets = [{"id": f"s{i}", "이름": f"시트{i}", "칸": {}} for i in (1, 2, 3)] + return {"종류": "통합문서", "판": 1, "열": "시험", "서식": [{}], "시트": sheets, "활성": "s1"} + + +@router.get("/spreadsheet-trial") +def get_spreadsheet_trial() -> dict: + if not TRIAL_PATH.is_file(): + return {"문서": _trial_doc()} + return {"문서": json.loads(TRIAL_PATH.read_text(encoding="utf-8"))} + + +class TrialBody(BaseModel): + 문서: dict[str, Any] + + +@router.put("/spreadsheet-trial") +def put_spreadsheet_trial(body: TrialBody) -> dict: + try: + doc = spreadsheet.with_server_values(body.문서) + except spreadsheet.RecalcError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + atomic_write_json(TRIAL_PATH, doc) + return {"문서": doc} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index a2730e68..8ae50715 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -22,6 +22,7 @@ from B06_Section.B06_Section_Repository import ( get_cross_section_designs, get_workflow_route_context, ) +from common_util import common_util_spreadsheet as spreadsheet from common_util.common_util_auth import verify_session from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection @@ -260,6 +261,12 @@ async def save_layer_template( if kind == "table" and fill.is_fillable(document): # 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음) document = fill.strip_design(document) + if kind == "basis": + # 산출근거 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음(브라우저 값 버림 · 5장 ②) + try: + document = await asyncio.to_thread(spreadsheet.with_server_values, document) + except spreadsheet.RecalcError as error: + raise HTTPException(status_code=503, detail=str(error)) from error try: version = await asyncio.to_thread( layers.write_template, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.css b/M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.css new file mode 100644 index 00000000..aca49b93 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.css @@ -0,0 +1,25 @@ +/* ⚠ 임시 — [스프레드시트 시험] 화면(1단계 검증 뒤 지움) */ +.m02-trial { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.m02-trial__bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.m02-trial__note { + color: var(--color-text-muted); + font-size: 0.85rem; +} + +.m02-trial__sheet { + width: 100%; + height: 75vh; + min-height: 420px; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.ts b/M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.ts new file mode 100644 index 00000000..7d655959 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.ts @@ -0,0 +1,89 @@ +/* ============================================================================= + * M02_MasterTemplete_Spreadsheet_Trial.ts + * ⚠ 임시 — [스프레드시트 시험] 단추(사용자 지시 · 스프레드시트 1단계 검증 뒤 지움: + * 이 파일 · `M02_MasterTemplete_UI_Main.ts` 의 시험 단추 · Api_Fetch `*SpreadsheetTrial` · + * Router `/spreadsheet-trial` · 저장 자리 `tmp/spreadsheet_trial.json`). + * 빈 통합문서(시트 셋)를 메인 칸에 넓게 엶 · [시험 저장] = 서버가 같은 엔진으로 다시 풀어 tmp 에 적음. + * 글자는 이 파일에 둠. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import { readSpreadsheetTrial, saveSpreadsheetTrial } from "./M02_MasterTemplete_Api_Fetch"; +import "./M02_MasterTemplete_Spreadsheet_Trial.css"; + +const TEXT = { + save: "시험 저장", + note: "임시 시험 화면 — 저장은 서버 tmp 견본 자리(작업 양식과 무관)", + saved: "시험 통합문서를 저장했음(서버 재계산)", + failed: "스프레드시트 시험 실패 — {value}", +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export interface TrialHandle { + getDoc: () => unknown; + destroy: () => void; +} + +/** `onDirty` = 고침 있음 · 저장 뒤 없음(페이지의 떠날 때 물음과 이음) */ +export function mountSpreadsheetTrial( + host: HTMLElement, + onDirty: (dirty: boolean) => void, +): TrialHandle { + let sheet: SpreadsheetHandle | null = null; + let alive = true; + const save = createButton({ label: TEXT.save, variant: "filled" }); + const area = el("div", { className: "m02-trial__sheet" }); + const root = el("div", { + className: "m02-trial", + children: [ + el("div", { + className: "m02-trial__bar", + children: [el("span", { className: "m02-trial__note", text: TEXT.note }), save], + }), + area, + ], + }); + host.replaceChildren(root); + const fail = (error: unknown): void => + showToast(TEXT.failed.replace("{value}", why(error)), "error"); + + void (async () => { + try { + const [{ createSpreadsheet }, got] = await Promise.all([ + import("../A00_Common/spreadsheet/spreadsheet"), + readSpreadsheetTrial(), + ]); + if (!alive) return; + sheet = createSpreadsheet(area, got.문서 as Workbook, { onChange: () => onDirty(true) }); + } catch (error) { + if (!alive) return; + area.replaceChildren(el("p", { className: "m02-trial__empty", text: why(error) })); + fail(error); + } + })(); + + save.addEventListener("click", async () => { + if (!sheet) return; + save.disabled = true; + try { + await saveSpreadsheetTrial(sheet.getDoc()); + onDirty(false); + showToast(TEXT.saved, "success"); + } catch (error) { + fail(error); + } finally { + save.disabled = false; + } + }); + + return { + getDoc: () => sheet?.getDoc() ?? null, + destroy: () => { + alive = false; + sheet?.destroy(); + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 4522430b..7879945c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -14,14 +14,22 @@ from datetime import datetime from pathlib import Path from typing import Any +from common_util import common_util_spreadsheet as spreadsheet from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_Template_Layers as layers FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template" KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈 # 구조물 도면 = 구조물집계표 열 id 마다 하나(파일 이름 = 열 id) · 도각 없는 전용 화면(그린 만큼이 설계 영역) +# 산출근거 = 같은 열 id 로 따로 한 파일(`basis/<열 id>.json` · 스프레드시트 통합문서) STRUCTURE_TABLE = "구조물집계표" -_BASIS_HEAD = (("work", "공종"), ("spec", "규격"), ("detail", "산출 내역"), ("unit", "단위")) +_BASIS_HEAD = ("공종", "규격", "산출 내역", "단위", "수량(m당)") +_BASIS_HEAD_STYLE = { + "굵게": True, + "가로": "center", + "세로": "center", + "테두리": {side: {"선": "thin"} for side in ("위", "아래", "왼", "오른")}, +} _NUMBER = re.compile(r"^구-(\d+)$") _BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.') # ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로 @@ -80,10 +88,14 @@ def _check_skeleton(kind: str, doc: Any) -> None: def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]: """`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀. - 구조물집계표를 저장하면 — 새 열의 빈 구조물 도면도 같이 만듦(잠금 밖에서 · `create_structure` - 가 다시 잠금을 잡으므로 안에서 부르면 죽음).""" + 구조물집계표를 저장하면 — 새 열의 빈 구조물 도면 · 산출근거도 같이 만듦(잠금 밖에서 · + `create_structure` 가 다시 잠금을 잡으므로 안에서 부르면 죽음). + 산출근거(basis)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음 + (브라우저 값 버림 · 5장 ②).""" path = _path(kind, name) _check_skeleton(kind, doc) + if kind == "basis": + doc = _server_values(doc) with _LOCK: have = version_of(path.read_bytes()) if path.is_file() else "" if have != (version or ""): @@ -125,16 +137,17 @@ def _number(doc: Any) -> int: return int(found.group(1)) if found else 0 -def create_structure(column: str) -> dict[str, Any]: - """열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409.""" - path = _path("structure", column) +def _column_ids() -> list[str]: table = read("table", STRUCTURE_TABLE)["문서"] - if column not in {col.get("id") for col in table.get("열", []) if isinstance(col, dict)}: + return [col["id"] for col in table.get("열", []) if isinstance(col, dict) and col.get("id")] + + +def create_structure(column: str) -> dict[str, Any]: + """열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409. + 산출근거 파일이 없으면 빈 통합문서도 같이 만듦.""" + path = _path("structure", column) + if column not in _column_ids(): raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 열 「{column}」") - basis = [{"id": key, "머리": [label], "단위": None, "꼴": "글"} for key, label in _BASIS_HEAD] - basis.append({"id": "qty", "머리": ["수량(m당)"], "단위": None, "꼴": "수"}) - # 빈 줄 몇 개를 미리 둠 — 「줄 더하기」 안 눌러도 바로 입력하게 - basis_rows = [{"id": str(i), "값": {}} for i in range(1, 4)] with _LOCK: if path.is_file(): raise StoreError(409, f"이미 있는 구조물 도면 「{column}」") @@ -146,21 +159,107 @@ def create_structure(column: str) -> dict[str, Any]: "열": column, "도번": f"구-{number:02d}", "도면": {"format": 6, "entities": []}, - "산출근거": {"양식": "산출근거", "종류": "표", "판": 1, "열": basis, "줄": basis_rows}, } atomic_write_json(path, doc) + ensure_basis(column) return read("structure", column) +# ── 산출근거 ────────────────────────────────────────── + + +def _col_letters(index: int) -> str: + letters = "" + index += 1 + while index: + index, rest = divmod(index - 1, 26) + letters = chr(65 + rest) + letters + return letters + + +def basis_doc(column: str, old: Any = None) -> dict[str, Any]: + """빈 산출근거 통합문서 — 1 행 머리(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)). + `old` = 옛 구조물 문서 `산출근거` 표(열 · 줄) — 머리 글 · 칸 값 · 열 폭을 옮김 + (옛 열 식은 없어 안 옮김).""" + columns = old.get("열") if isinstance(old, dict) and isinstance(old.get("열"), list) else None + if columns: + heads = [ + " ".join(str(h) for h in (c.get("머리") or []) if h) or c.get("id", "") for c in columns + ] + else: + heads, columns = list(_BASIS_HEAD), [] + cells: dict[str, Any] = { + f"{_col_letters(i)}1": {"값": head, "서식": 1} for i, head in enumerate(heads) if head + } + for r, row in enumerate(old.get("줄", []) if columns else [], start=2): + values = row.get("값", {}) if isinstance(row, dict) else {} + for i, col in enumerate(columns): + value = values.get(col.get("id")) + if value not in (None, ""): + cells[f"{_col_letters(i)}{r}"] = {"값": value} + widths = ((old or {}).get("보기") or {}).get("열너비") or {} if columns else {} + # 옛 폭은 px · 새 폭은 엑셀 글자 단위(숫자 폭 7px 어림) + info = { + _col_letters(i): {"폭": round(widths[c["id"]] / 7, 1)} + for i, c in enumerate(columns) + if isinstance(widths.get(c.get("id")), (int, float)) + } + sheet: dict[str, Any] = {"id": "s1", "이름": "산출근거", "칸": cells} + if info: + sheet["열"] = info + return { + "종류": "통합문서", + "판": 1, + "열": column, + "서식": [{}, _BASIS_HEAD_STYLE], + "시트": [sheet], + "활성": "s1", + } + + +def ensure_basis(column: str, old: Any = None) -> bool: + """산출근거 파일이 없으면 만듦(식 없는 새 문서라 `계산값` 없음 · 첫 [저장] 때 서버가 적음).""" + path = _path("basis", column) + with _LOCK: + if path.is_file(): + return False + atomic_write_json(path, basis_doc(column, old)) + return True + + +def _server_values(doc: Any) -> dict[str, Any]: + try: + return spreadsheet.with_server_values(doc) + except spreadsheet.RecalcError as e: + raise StoreError(503, str(e)) from e + + +def migrate_basis() -> dict[str, list[str]]: + """한 번 — 옛 구조물 문서 안 `산출근거` 표를 `basis/<열 id>.json` 으로 옮기고 + 구조물 문서에서 뺌 · 그 뒤 산출근거가 없는 집계표 열은 빈 통합문서. + 두 번 돌려도 같음(있는 파일은 안 덮음).""" + moved: list[str] = [] + for name, doc in _structures(): + if not isinstance(doc, dict) or "산출근거" not in doc: + continue + ensure_basis(name, doc["산출근거"]) + rest = {key: value for key, value in doc.items() if key != "산출근거"} + with _LOCK: + atomic_write_json(_path("structure", name), rest) + moved.append(name) + made = [column for column in _column_ids() if ensure_basis(column)] + return {"옮김": moved, "새로": made} + + def backfill_missing_structures() -> list[str]: - """구조물집계표 열마다 빈 구조물 도면이 있게 — 없는 열만 열 차례로 새로 만듦(도번 이어서). + """구조물집계표 열마다 빈 구조물 도면 · 산출근거가 있게 — 없는 열만 열 차례로 새로 + 만듦(도번 이어서). 그 사이 남이 만들었거나(409) 열 규칙에 안 맞으면(404) 그 열만 건너뜀.""" - table = read("table", STRUCTURE_TABLE)["문서"] have = {name for name, _ in _structures()} made: list[str] = [] - for col in table.get("열", []): - cid = col.get("id") if isinstance(col, dict) else None - if not cid or cid in have: + for cid in _column_ids(): + if cid in have: + ensure_basis(cid) continue try: create_structure(cid) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 0dee58a3..33bb21e5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -1,7 +1,9 @@ /* ============================================================================= * M02_MasterTemplete_Structure.ts - * 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거 표. + * 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거. * 카드 둘(도면 정보 · 상세 산출근거)은 좌측 패널과 같은 공용 ui-sidebar 틀. + * 상세 산출근거 = 스프레드시트(`A00_Common/spreadsheet/`) · 열마다 따로 파일(`basis/<열 id>.json`) · + * 부품은 이 카드를 열 때만 `import()` 로 받음(다른 페이지 번들에 안 섞임) · [산출근거 저장] 때 서버가 다시 풂. * * 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) · * 서버가 열마다 미리 만들어 둠(도번 자동 · 새 열도 집계표 저장 때 서버가 같이 만듦 — `M02_MasterTemplete_Store.py`). @@ -11,7 +13,7 @@ import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { createSheet, type SheetDoc, type SheetHandle } from "@ui/sheet/ui_template_sheet"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { fetchStructureNumbers, readTemplate, @@ -36,17 +38,20 @@ const TEXT = { basis: "상세 산출근거", noNumber: "도번 없음", noDrawing: "도면 없음", - noBasis: "표 없음 — 도면이 아직 없음", + noBasis: "산출근거 없음", + noSheet: "스프레드시트를 못 띄움 — {value}", saved: "산출근거를 저장했음", dirty: "저장 안 한 산출근거 고침이 있음 — 버리고 넘어갈까?", kept: "열을 바꾸지 않음 — 산출근거를 먼저 저장할 것", - stale: "그 사이 구조물 도면이 바뀜 — 열을 다시 고를 것", + stale: "그 사이 산출근거가 바뀜 — 열을 다시 고를 것", readFailed: "구조물 도면을 못 읽음 — {value}", saveFailed: "저장 못 함 — {value}", }; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const fill = (text: string, value: string): string => text.replace("{value}", value); +/** 스프레드시트는 산출근거 카드를 열 때만 받음 */ +const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); /** 집계표 열 — 머리 글만 씀 */ export interface StructureColumn { @@ -83,9 +88,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane let column: string | null = null; let columnLabel = ""; let doc: StructureDoc | null = null; - let version = ""; + let basisDoc: Workbook | null = null; + let basisVersion = ""; let preview: DrawingTemplateHandle | null = null; - let basis: SheetHandle | null = null; + let basis: SpreadsheetHandle | null = null; let basisDirty = false; let busy = false; let seq = 0; @@ -138,26 +144,42 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane if (!inner?.head || inner.getElementById("m02-struct-hide")) return; const style = inner.createElement("style"); style.id = "m02-struct-hide"; - style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }"; + style.textContent = + ".cad-titlebar, .cad-ribbon, .cad-inspector { display: none !important; }"; inner.head.append(style); }; frame.addEventListener("load", apply); apply(); }; - /** 미리보기 · 산출근거 표 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */ - const paintBody = (): void => { + /** 산출근거 스프레드시트 — 부품을 늦게 받음 · 그 사이 열이 바뀌면 버림 */ + const paintBasis = async (mine: number): Promise => { + if (!basisDoc) return void basisHost.replaceChildren(note(TEXT.noBasis)); + try { + const { createSpreadsheet } = await loadSpreadsheet(); + if (mine !== seq || !basisDoc) return; + basis = createSpreadsheet(basisHost, basisDoc, { + readOnly: !opts.isAdmin, + onChange: () => (basisDirty = true), + }); + } catch (error) { + if (mine === seq) basisHost.replaceChildren(note(fill(TEXT.noSheet, why(error)))); + } + }; + + /** 미리보기 · 산출근거 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */ + const paintBody = (mine: number): void => { drop(); number.textContent = doc?.도번 || numbers[column ?? ""] || TEXT.noNumber; edit.hidden = !opts.isAdmin; - saveBasis.hidden = !opts.isAdmin || !doc; + saveBasis.hidden = !opts.isAdmin || !basisDoc; + basisHost.replaceChildren(); + void paintBasis(mine); if (!doc) { previewHost.replaceChildren(note(TEXT.noDrawing)); - basisHost.replaceChildren(note(TEXT.noBasis)); return; } previewHost.replaceChildren(); - basisHost.replaceChildren(); // 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠. preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, { readOnly: true, @@ -166,10 +188,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane }); const frame = previewHost.querySelector("iframe"); if (frame) hideToolbar(frame); - basis = createSheet(basisHost, doc.산출근거 as unknown as SheetDoc, { - mode: "master", - onChange: () => (basisDirty = true), - }); }; async function load(col: StructureColumn | null): Promise { @@ -187,20 +205,23 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane columnName.textContent = columnLabel; number.textContent = numbers[col.id] ?? TEXT.noNumber; doc = null; - version = ""; + basisDoc = null; + basisVersion = ""; previewHost.replaceChildren(); basisHost.replaceChildren(); - try { - const got = await readTemplate("system", "structure", col.id, null); - if (mine !== seq) return; - doc = got.문서 as StructureDoc; - version = got.판 ?? ""; - } catch (error) { - if (mine !== seq) return; - // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 - if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(error)), "error"); + const [drawing, sheet] = await Promise.allSettled([ + readTemplate("system", "structure", col.id, null), + readTemplate("system", "basis", col.id, null), + ]); + if (mine !== seq) return; + if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc; + // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 + else if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(drawing.reason)), "error"); + if (sheet.status === "fulfilled") { + basisDoc = sheet.value.문서 as Workbook; + basisVersion = sheet.value.판 ?? ""; } - paintBody(); + paintBody(mine); } const show = (colId: string | null, columns: StructureColumn[]): void => { @@ -216,17 +237,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane saveBasis.addEventListener("click", async () => { const at = column; - if (!at || !doc || !basis || busy) return; + if (!at || !basis || busy) return; busy = true; saveBasis.disabled = true; try { - const next: StructureDoc = { - ...doc, - 산출근거: basis.getDoc() as unknown as StructureDoc["산출근거"], - }; - const info = await saveTemplate("system", "structure", at, null, version, next); - version = info.판; - doc = next; + // 계산값은 서버가 같은 엔진으로 다시 풀어 적음(보낸 값은 버림) + const next = basis.getDoc(); + const info = await saveTemplate("system", "basis", at, null, basisVersion, next); + basisVersion = info.판; + basisDoc = next; basisDirty = false; showToast(TEXT.saved, "success"); } catch (error) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 20328a0f..392e8db2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -22,6 +22,8 @@ import { type StructureColumn, type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; +// ⚠ 임시 — [스프레드시트 시험] 단추(1단계 검증 뒤 지움) +import { mountSpreadsheetTrial } from "./M02_MasterTemplete_Spreadsheet_Trial"; export interface Selection { layer: Layer; @@ -77,6 +79,9 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") }); const reload = createButton({ label: L("M02_Reload"), variant: "ghost" }); const save = createButton({ label: L("M02_Save"), variant: "filled" }); + // ⚠ 임시 — [스프레드시트 시험] 단추(사용자 지시 · 1단계 검증 뒤 지움) + const trial = createButton({ label: "스프레드시트 시험", variant: "ghost" }); + trial.hidden = !isAdmin; const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴 const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } }); const host = el("div", { className: "m02-main__host" }); @@ -84,7 +89,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { const root = el("div", { className: "m02-main", children: [ - el("div", { className: "m02-main__head", children: [title, slot, reload, save] }), + el("div", { className: "m02-main__head", children: [title, slot, reload, save, trial] }), notice, host, extra, @@ -269,6 +274,18 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { } save.addEventListener("click", () => void doSave()); + // ⚠ 임시 — [스프레드시트 시험] 빈 통합문서(시트 셋)를 메인 칸에 넓게 엶 · 저장은 서버 tmp 견본 자리 + trial.addEventListener("click", async () => { + if (!(await confirmLeave())) return; + seq += 1; + dirty = false; + drop(); + showNotice([]); + sel = null; + bar(); + title.textContent = "스프레드시트 시험"; + editor = mountSpreadsheetTrial(host, (d) => (dirty = d)); + }); reload.addEventListener("click", async () => { if (sel && (await confirmLeave())) void open(sel); }); diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index 9c889c25..e3f3060b 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -1,10 +1,10 @@ """M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest. 층 넷 (PLAN 10-5): - system `resources/master_template/{table,drawing,structure}/<이름>.json` (git) - company `storage/{회사}/templates/{table,drawing,structure}/` - personal `storage/{회사}/{사용자}/templates/{table,drawing,structure}/` - project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure}/` + system `resources/master_template/{table,drawing,structure,basis}/<이름>.json` (git) + company `storage/{회사}/templates/{table,drawing,structure,basis}/` + personal `storage/{회사}/{사용자}/templates/{table,drawing,structure,basis}/` + project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure,basis}/` + 초기 사본 `templates/_initial/` - 층 폴더마다 `manifest.json` — 그 폴더에 든 양식의 출처 `{"table/이름": {층, 이름, 판, 적용일}}`. @@ -26,7 +26,8 @@ from common_util.common_util_json import atomic_write_json from config import config_system LAYERS = ("system", "company", "personal", "project") -KINDS = ("table", "drawing", "structure") +# basis = 산출근거 통합문서(구조물집계표 열마다 · 스프레드시트 `A00_Common/spreadsheet/`) +KINDS = ("table", "drawing", "structure", "basis") TEMPLATES_DIRNAME = "templates" INITIAL_DIRNAME = "_initial" MANIFEST_NAME = "manifest.json" @@ -165,13 +166,14 @@ def read_template(layer_dir: str | Path, kind: str, name: str) -> dict[str, Any] _SKELETON = { "table": ("열",), "drawing": ("entities",), - "structure": ("도면.entities", "산출근거.열"), + "structure": ("도면.entities",), + "basis": ("시트",), } def check_skeleton(kind: str, document: Any) -> None: """뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록 · - 구조물 도면 = `도면.entities` · `산출근거.열` 목록(빈 `{}` 저장 막기).""" + 구조물 도면 = `도면.entities` 목록 · 산출근거 = `시트` 목록(빈 `{}` 저장 막기).""" if not isinstance(document, dict): raise ValueError("양식 문서는 JSON 객체여야 합니다.") for key in _SKELETON.get(kind, ()): diff --git a/common_util/common_util_spreadsheet.py b/common_util/common_util_spreadsheet.py new file mode 100644 index 00000000..17044da6 --- /dev/null +++ b/common_util/common_util_spreadsheet.py @@ -0,0 +1,56 @@ +"""산출근거 스프레드시트(통합문서) 서버 재계산 — 화면과 같은 TS 엔진을 Node 로 돌리는 껍데기. + +CLAUDE.md 5장 ② — 계산은 `A00_Common/spreadsheet/` 한 벌. 여기는 번들을 부르기만 함 +(`npm run build:spreadsheet` → `config/spreadsheet_node/`). [저장] 때 브라우저가 보낸 `계산값` 은 +버리고 이 결과를 정본으로 적음. + +결과 한 권 = `{시트 id: {A1: {수|글|참|오류}}}`(식 칸만) · 수는 십진 글. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from common_util.common_util_node_bundle import ROOT, build_bundle, run_bundle_json + +BUNDLE = ROOT / "config" / "spreadsheet_node" / "common_util_spreadsheet_node.js" +NPM_SCRIPT = "build:spreadsheet" +ENTRY = ROOT / "common_util" / "common_util_spreadsheet_node.ts" +SOURCES = (ROOT / "A00_Common" / "spreadsheet", ROOT / "ui_template" / "sheet") + + +class RecalcError(Exception): + """번들 빌드 · 실행 실패 또는 엔진이 그 통합문서를 못 풂 — 저장을 막음(검산 없이 적지 않음).""" + + +def _stale() -> bool: + """번들이 없거나 엔진 TS 원본보다 오래됐으면 참 — 낡으면 화면과 서버가 다른 값을 냄.""" + if not BUNDLE.is_file(): + return True + built_at = BUNDLE.stat().st_mtime + paths = [ENTRY, *(p for folder in SOURCES for p in folder.glob("*.ts"))] + return any(p.stat().st_mtime > built_at for p in paths) + + +def recalc_workbooks(books: list[dict[str, Any]]) -> list[dict[str, Any]]: + """통합문서 여럿 → 입력 차례 그대로 `계산값`. 하나라도 못 풀면 `RecalcError`.""" + if _stale() and not build_bundle(NPM_SCRIPT): + raise RecalcError("스프레드시트 계산 번들을 못 만듦") + out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"books": books}) + if out is None: + raise RecalcError("스프레드시트 계산 번들 실행 실패") + values: list[dict[str, Any]] = [] + for result in out["results"]: + if not result.get("ok"): + raise RecalcError(f"스프레드시트 계산 실패 — {result.get('까닭')}") + values.append(result["계산값"]) + return values + + +def with_server_values(book: dict[str, Any]) -> dict[str, Any]: + """[저장] 직전 — 브라우저 `계산값` 은 버리고 서버 풀이로 새로 적은 사본.""" + clean = copy.deepcopy(book) + clean.pop("계산값", None) + clean["계산값"] = recalc_workbooks([clean])[0] + return clean diff --git a/common_util/common_util_spreadsheet_node.ts b/common_util/common_util_spreadsheet_node.ts new file mode 100644 index 00000000..420f7448 --- /dev/null +++ b/common_util/common_util_spreadsheet_node.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * common_util_spreadsheet_node.ts + * 산출근거 스프레드시트 풀이를 **서버가** 돌리는 진입점 — [저장] 때 정본 `계산값`. + * + * 풀이는 화면이 쓰는 `A00_Common/spreadsheet/spreadsheet_recalc.ts` 그대로 — 여기에는 계산이 없음(CLAUDE.md 5장 ②). + * 파이썬 껍데기 = `common_util_spreadsheet.py` · 번들 = `npm run build:spreadsheet`. + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { books: Workbook[] } + * 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 한 권이 죽어도 나머지는 풂 + * 끝 코드: 0 성공 / 2 인자 오류 + * ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게. + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} + +const input = JSON.parse(readFileSync(inputPath, "utf8")) as { books?: Workbook[] }; +const results = (input.books ?? []).map((book) => { + try { + return { ok: true, 계산값: recalcWorkbook(book) }; + } catch (error) { + return { ok: false, 까닭: error instanceof Error ? error.message : String(error) }; + } +}); +writeFileSync(outputPath, JSON.stringify({ results })); diff --git a/package.json b/package.json index 16499d54..f1f0ca81 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "type": "module", "scripts": { "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", - "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:b07-cad", + "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-cad", "build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node", "build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node", "build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node", + "build:spreadsheet": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_spreadsheet_node.ts --outDir ../config/spreadsheet_node", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", diff --git a/resources/master_template/basis/be.json b/resources/master_template/basis/be.json new file mode 100644 index 00000000..d144a756 --- /dev/null +++ b/resources/master_template/basis/be.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "be", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/bg.json b/resources/master_template/basis/bg.json new file mode 100644 index 00000000..2cfbf5e9 --- /dev/null +++ b/resources/master_template/basis/bg.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "bg", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/bm_area.json b/resources/master_template/basis/bm_area.json new file mode 100644 index 00000000..6f2104cb --- /dev/null +++ b/resources/master_template/basis/bm_area.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "bm_area", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/bm_len.json b/resources/master_template/basis/bm_len.json new file mode 100644 index 00000000..6c485795 --- /dev/null +++ b/resources/master_template/basis/bm_len.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "bm_len", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/bx_len.json b/resources/master_template/basis/bx_len.json new file mode 100644 index 00000000..7f77b27a --- /dev/null +++ b/resources/master_template/basis/bx_len.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "bx_len", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/bx_wing.json b/resources/master_template/basis/bx_wing.json new file mode 100644 index 00000000..abb5866d --- /dev/null +++ b/resources/master_template/basis/bx_wing.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "bx_wing", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/cs.json b/resources/master_template/basis/cs.json new file mode 100644 index 00000000..c7e4c54e --- /dev/null +++ b/resources/master_template/basis/cs.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "cs", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/ec.json b/resources/master_template/basis/ec.json new file mode 100644 index 00000000..6fb922db --- /dev/null +++ b/resources/master_template/basis/ec.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "ec", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/etc.json b/resources/master_template/basis/etc.json new file mode 100644 index 00000000..39b122f6 --- /dev/null +++ b/resources/master_template/basis/etc.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "etc", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/fb.json b/resources/master_template/basis/fb.json new file mode 100644 index 00000000..fdf75c78 --- /dev/null +++ b/resources/master_template/basis/fb.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "fb", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/fp_l.json b/resources/master_template/basis/fp_l.json new file mode 100644 index 00000000..94ea26a3 --- /dev/null +++ b/resources/master_template/basis/fp_l.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "fp_l", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/fp_w.json b/resources/master_template/basis/fp_w.json new file mode 100644 index 00000000..6bef5748 --- /dev/null +++ b/resources/master_template/basis/fp_w.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "fp_w", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/gr_curb.json b/resources/master_template/basis/gr_curb.json new file mode 100644 index 00000000..a7c3407c --- /dev/null +++ b/resources/master_template/basis/gr_curb.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "gr_curb", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/gr_rail.json b/resources/master_template/basis/gr_rail.json new file mode 100644 index 00000000..6599a2fe --- /dev/null +++ b/resources/master_template/basis/gr_rail.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "gr_rail", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/gr_sign.json b/resources/master_template/basis/gr_sign.json new file mode 100644 index 00000000..3d05704a --- /dev/null +++ b/resources/master_template/basis/gr_sign.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "gr_sign", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_break.json b/resources/master_template/basis/h_break.json new file mode 100644 index 00000000..ae8cc151 --- /dev/null +++ b/resources/master_template/basis/h_break.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_break", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_ford_basin.json b/resources/master_template/basis/h_ford_basin.json new file mode 100644 index 00000000..29d20574 --- /dev/null +++ b/resources/master_template/basis/h_ford_basin.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_ford_basin", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_intake.json b/resources/master_template/basis/h_intake.json new file mode 100644 index 00000000..d072f999 --- /dev/null +++ b/resources/master_template/basis/h_intake.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_intake", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_pg_rail.json b/resources/master_template/basis/h_pg_rail.json new file mode 100644 index 00000000..ac1d69c2 --- /dev/null +++ b/resources/master_template/basis/h_pg_rail.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_pg_rail", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_pv_rail.json b/resources/master_template/basis/h_pv_rail.json new file mode 100644 index 00000000..665550f5 --- /dev/null +++ b/resources/master_template/basis/h_pv_rail.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_pv_rail", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_rip_c.json b/resources/master_template/basis/h_rip_c.json new file mode 100644 index 00000000..ce2cb1b8 --- /dev/null +++ b/resources/master_template/basis/h_rip_c.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_rip_c", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_rip_m.json b/resources/master_template/basis/h_rip_m.json new file mode 100644 index 00000000..623be3b5 --- /dev/null +++ b/resources/master_template/basis/h_rip_m.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_rip_m", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_rockfall.json b/resources/master_template/basis/h_rockfall.json new file mode 100644 index 00000000..aaa0e2c1 --- /dev/null +++ b/resources/master_template/basis/h_rockfall.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_rockfall", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_soil_ditch.json b/resources/master_template/basis/h_soil_ditch.json new file mode 100644 index 00000000..c5b9a2c0 --- /dev/null +++ b/resources/master_template/basis/h_soil_ditch.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_soil_ditch", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_stone_bed.json b/resources/master_template/basis/h_stone_bed.json new file mode 100644 index 00000000..dafb2577 --- /dev/null +++ b/resources/master_template/basis/h_stone_bed.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_stone_bed", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_stone_ch.json b/resources/master_template/basis/h_stone_ch.json new file mode 100644 index 00000000..1158d797 --- /dev/null +++ b/resources/master_template/basis/h_stone_ch.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_stone_ch", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/h_waste.json b/resources/master_template/basis/h_waste.json new file mode 100644 index 00000000..a1aa8cd2 --- /dev/null +++ b/resources/master_template/basis/h_waste.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "h_waste", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/memo.json b/resources/master_template/basis/memo.json new file mode 100644 index 00000000..76fca18d --- /dev/null +++ b/resources/master_template/basis/memo.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "memo", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/mr.json b/resources/master_template/basis/mr.json new file mode 100644 index 00000000..7d90ba69 --- /dev/null +++ b/resources/master_template/basis/mr.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "mr", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/ms.json b/resources/master_template/basis/ms.json new file mode 100644 index 00000000..81cb2717 --- /dev/null +++ b/resources/master_template/basis/ms.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "ms", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/no.json b/resources/master_template/basis/no.json new file mode 100644 index 00000000..771a4fc4 --- /dev/null +++ b/resources/master_template/basis/no.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "no", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/od_cnt.json b/resources/master_template/basis/od_cnt.json new file mode 100644 index 00000000..00a55396 --- /dev/null +++ b/resources/master_template/basis/od_cnt.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "od_cnt", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/od_len.json b/resources/master_template/basis/od_len.json new file mode 100644 index 00000000..71215e1b --- /dev/null +++ b/resources/master_template/basis/od_len.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "od_len", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/pg_in.json b/resources/master_template/basis/pg_in.json new file mode 100644 index 00000000..44eed2ae --- /dev/null +++ b/resources/master_template/basis/pg_in.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "pg_in", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/pp_cp.json b/resources/master_template/basis/pp_cp.json new file mode 100644 index 00000000..623cae69 --- /dev/null +++ b/resources/master_template/basis/pp_cp.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "pp_cp", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/pp_len.json b/resources/master_template/basis/pp_len.json new file mode 100644 index 00000000..812d3076 --- /dev/null +++ b/resources/master_template/basis/pp_len.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "pp_len", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/ps.json b/resources/master_template/basis/ps.json new file mode 100644 index 00000000..48ca7945 --- /dev/null +++ b/resources/master_template/basis/ps.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "ps", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/pv_a.json b/resources/master_template/basis/pv_a.json new file mode 100644 index 00000000..fe322750 --- /dev/null +++ b/resources/master_template/basis/pv_a.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "pv_a", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/pv_b.json b/resources/master_template/basis/pv_b.json new file mode 100644 index 00000000..71ade0af --- /dev/null +++ b/resources/master_template/basis/pv_b.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "pv_b", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/pv_jt.json b/resources/master_template/basis/pv_jt.json new file mode 100644 index 00000000..ed4e5ef6 --- /dev/null +++ b/resources/master_template/basis/pv_jt.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "pv_jt", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/pv_l.json b/resources/master_template/basis/pv_l.json new file mode 100644 index 00000000..a5d588e3 --- /dev/null +++ b/resources/master_template/basis/pv_l.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "pv_l", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/pv_w.json b/resources/master_template/basis/pv_w.json new file mode 100644 index 00000000..08406dc4 --- /dev/null +++ b/resources/master_template/basis/pv_w.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "pv_w", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/rf.json b/resources/master_template/basis/rf.json new file mode 100644 index 00000000..e57337fd --- /dev/null +++ b/resources/master_template/basis/rf.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "rf", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/rv.json b/resources/master_template/basis/rv.json new file mode 100644 index 00000000..fba5401b --- /dev/null +++ b/resources/master_template/basis/rv.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "rv", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/rw.json b/resources/master_template/basis/rw.json new file mode 100644 index 00000000..047f1e02 --- /dev/null +++ b/resources/master_template/basis/rw.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "rw", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/sg.json b/resources/master_template/basis/sg.json new file mode 100644 index 00000000..b974fcce --- /dev/null +++ b/resources/master_template/basis/sg.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "sg", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/sta.json b/resources/master_template/basis/sta.json new file mode 100644 index 00000000..16bf443b --- /dev/null +++ b/resources/master_template/basis/sta.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "sta", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/ta.json b/resources/master_template/basis/ta.json new file mode 100644 index 00000000..41ddd730 --- /dev/null +++ b/resources/master_template/basis/ta.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "ta", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/basis/wy.json b/resources/master_template/basis/wy.json new file mode 100644 index 00000000..e551e5e8 --- /dev/null +++ b/resources/master_template/basis/wy.json @@ -0,0 +1,56 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "wy", + "서식": [ + {}, + { + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "산출근거", + "칸": { + "A1": { + "값": "공종", + "서식": 1 + }, + "B1": { + "값": "규격", + "서식": 1 + }, + "C1": { + "값": "산출 내역", + "서식": 1 + }, + "D1": { + "값": "단위", + "서식": 1 + }, + "E1": { + "값": "수량(m당)", + "서식": 1 + } + } + } + ], + "활성": "s1" +} diff --git a/resources/master_template/structure/be.json b/resources/master_template/structure/be.json index 66d85d4c..aeee77e4 100644 --- a/resources/master_template/structure/be.json +++ b/resources/master_template/structure/be.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/bg.json b/resources/master_template/structure/bg.json index 80bb0b4b..30aa8bf5 100644 --- a/resources/master_template/structure/bg.json +++ b/resources/master_template/structure/bg.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/bm_area.json b/resources/master_template/structure/bm_area.json index 46cdf626..9337dc2f 100644 --- a/resources/master_template/structure/bm_area.json +++ b/resources/master_template/structure/bm_area.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/bm_len.json b/resources/master_template/structure/bm_len.json index a1cf80d4..88ef0ab3 100644 --- a/resources/master_template/structure/bm_len.json +++ b/resources/master_template/structure/bm_len.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/bx_len.json b/resources/master_template/structure/bx_len.json index f900a3e7..e4cb34ae 100644 --- a/resources/master_template/structure/bx_len.json +++ b/resources/master_template/structure/bx_len.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/bx_wing.json b/resources/master_template/structure/bx_wing.json index 67f21977..fb99203f 100644 --- a/resources/master_template/structure/bx_wing.json +++ b/resources/master_template/structure/bx_wing.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/cs.json b/resources/master_template/structure/cs.json index 30d6273d..dd24845b 100644 --- a/resources/master_template/structure/cs.json +++ b/resources/master_template/structure/cs.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/ec.json b/resources/master_template/structure/ec.json index affd8114..e119aa89 100644 --- a/resources/master_template/structure/ec.json +++ b/resources/master_template/structure/ec.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/etc.json b/resources/master_template/structure/etc.json index fed2e8fa..c9c9af6f 100644 --- a/resources/master_template/structure/etc.json +++ b/resources/master_template/structure/etc.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/fb.json b/resources/master_template/structure/fb.json index 080bd463..5c7d0911 100644 --- a/resources/master_template/structure/fb.json +++ b/resources/master_template/structure/fb.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/fp_l.json b/resources/master_template/structure/fp_l.json index 764a2ae2..69bb0bc6 100644 --- a/resources/master_template/structure/fp_l.json +++ b/resources/master_template/structure/fp_l.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/fp_w.json b/resources/master_template/structure/fp_w.json index f8ff8360..89831d37 100644 --- a/resources/master_template/structure/fp_w.json +++ b/resources/master_template/structure/fp_w.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/gr_curb.json b/resources/master_template/structure/gr_curb.json index 360487e7..0d631708 100644 --- a/resources/master_template/structure/gr_curb.json +++ b/resources/master_template/structure/gr_curb.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/gr_rail.json b/resources/master_template/structure/gr_rail.json index 066402f1..8c93aedc 100644 --- a/resources/master_template/structure/gr_rail.json +++ b/resources/master_template/structure/gr_rail.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/gr_sign.json b/resources/master_template/structure/gr_sign.json index b47412c5..c3afa1f7 100644 --- a/resources/master_template/structure/gr_sign.json +++ b/resources/master_template/structure/gr_sign.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_break.json b/resources/master_template/structure/h_break.json index 981857b3..c78994c2 100644 --- a/resources/master_template/structure/h_break.json +++ b/resources/master_template/structure/h_break.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_ford_basin.json b/resources/master_template/structure/h_ford_basin.json index 4be43476..5c7f9ff7 100644 --- a/resources/master_template/structure/h_ford_basin.json +++ b/resources/master_template/structure/h_ford_basin.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_intake.json b/resources/master_template/structure/h_intake.json index 13c5fb13..0986d7da 100644 --- a/resources/master_template/structure/h_intake.json +++ b/resources/master_template/structure/h_intake.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_pg_rail.json b/resources/master_template/structure/h_pg_rail.json index 93a629e0..ff3fc485 100644 --- a/resources/master_template/structure/h_pg_rail.json +++ b/resources/master_template/structure/h_pg_rail.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_pv_rail.json b/resources/master_template/structure/h_pv_rail.json index 44f3fac4..be30d771 100644 --- a/resources/master_template/structure/h_pv_rail.json +++ b/resources/master_template/structure/h_pv_rail.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_rip_c.json b/resources/master_template/structure/h_rip_c.json index 2b3268d3..b6bbd7b4 100644 --- a/resources/master_template/structure/h_rip_c.json +++ b/resources/master_template/structure/h_rip_c.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_rip_m.json b/resources/master_template/structure/h_rip_m.json index 12564857..d6913c8d 100644 --- a/resources/master_template/structure/h_rip_m.json +++ b/resources/master_template/structure/h_rip_m.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_rockfall.json b/resources/master_template/structure/h_rockfall.json index 25195f35..da684b1e 100644 --- a/resources/master_template/structure/h_rockfall.json +++ b/resources/master_template/structure/h_rockfall.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_soil_ditch.json b/resources/master_template/structure/h_soil_ditch.json index d05202cc..a906ae5b 100644 --- a/resources/master_template/structure/h_soil_ditch.json +++ b/resources/master_template/structure/h_soil_ditch.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_stone_bed.json b/resources/master_template/structure/h_stone_bed.json index f90baf98..bfb2b924 100644 --- a/resources/master_template/structure/h_stone_bed.json +++ b/resources/master_template/structure/h_stone_bed.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_stone_ch.json b/resources/master_template/structure/h_stone_ch.json index 3c6e4922..31e9ac8a 100644 --- a/resources/master_template/structure/h_stone_ch.json +++ b/resources/master_template/structure/h_stone_ch.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/h_waste.json b/resources/master_template/structure/h_waste.json index 82add70b..ccfff700 100644 --- a/resources/master_template/structure/h_waste.json +++ b/resources/master_template/structure/h_waste.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/memo.json b/resources/master_template/structure/memo.json index 489e5ef8..51ebf6c0 100644 --- a/resources/master_template/structure/memo.json +++ b/resources/master_template/structure/memo.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/mr.json b/resources/master_template/structure/mr.json index 53369635..482a06df 100644 --- a/resources/master_template/structure/mr.json +++ b/resources/master_template/structure/mr.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/ms.json b/resources/master_template/structure/ms.json index f0272a10..8b828a33 100644 --- a/resources/master_template/structure/ms.json +++ b/resources/master_template/structure/ms.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/no.json b/resources/master_template/structure/no.json index aa348227..ca136814 100644 --- a/resources/master_template/structure/no.json +++ b/resources/master_template/structure/no.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/od_cnt.json b/resources/master_template/structure/od_cnt.json index 2d01ffab..383d961a 100644 --- a/resources/master_template/structure/od_cnt.json +++ b/resources/master_template/structure/od_cnt.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/od_len.json b/resources/master_template/structure/od_len.json index 52ae094a..29f1c329 100644 --- a/resources/master_template/structure/od_len.json +++ b/resources/master_template/structure/od_len.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/pg_in.json b/resources/master_template/structure/pg_in.json index 7293a81e..7b5733fa 100644 --- a/resources/master_template/structure/pg_in.json +++ b/resources/master_template/structure/pg_in.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/pp_cp.json b/resources/master_template/structure/pp_cp.json index 0bb30dfe..73186323 100644 --- a/resources/master_template/structure/pp_cp.json +++ b/resources/master_template/structure/pp_cp.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/pp_len.json b/resources/master_template/structure/pp_len.json index c55eb833..eadad960 100644 --- a/resources/master_template/structure/pp_len.json +++ b/resources/master_template/structure/pp_len.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/ps.json b/resources/master_template/structure/ps.json index ceaafad7..8ba9010c 100644 --- a/resources/master_template/structure/ps.json +++ b/resources/master_template/structure/ps.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/pv_a.json b/resources/master_template/structure/pv_a.json index edb9aed1..30a22f89 100644 --- a/resources/master_template/structure/pv_a.json +++ b/resources/master_template/structure/pv_a.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/pv_b.json b/resources/master_template/structure/pv_b.json index 04938563..ba01319e 100644 --- a/resources/master_template/structure/pv_b.json +++ b/resources/master_template/structure/pv_b.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/pv_jt.json b/resources/master_template/structure/pv_jt.json index ee6a8e75..a0c6b376 100644 --- a/resources/master_template/structure/pv_jt.json +++ b/resources/master_template/structure/pv_jt.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/pv_l.json b/resources/master_template/structure/pv_l.json index 91980abd..363b41ec 100644 --- a/resources/master_template/structure/pv_l.json +++ b/resources/master_template/structure/pv_l.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/pv_w.json b/resources/master_template/structure/pv_w.json index 91a9fa8c..73dcb13b 100644 --- a/resources/master_template/structure/pv_w.json +++ b/resources/master_template/structure/pv_w.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/rf.json b/resources/master_template/structure/rf.json index 50cf9500..580123df 100644 --- a/resources/master_template/structure/rf.json +++ b/resources/master_template/structure/rf.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/rv.json b/resources/master_template/structure/rv.json index 34378219..01aa92db 100644 --- a/resources/master_template/structure/rv.json +++ b/resources/master_template/structure/rv.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/rw.json b/resources/master_template/structure/rw.json index be6470b2..085121ea 100644 --- a/resources/master_template/structure/rw.json +++ b/resources/master_template/structure/rw.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/sg.json b/resources/master_template/structure/sg.json index 1fa04f0c..450dd30f 100644 --- a/resources/master_template/structure/sg.json +++ b/resources/master_template/structure/sg.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/sta.json b/resources/master_template/structure/sta.json index 855f03db..da54464c 100644 --- a/resources/master_template/structure/sta.json +++ b/resources/master_template/structure/sta.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/ta.json b/resources/master_template/structure/ta.json index 577c44a8..64201388 100644 --- a/resources/master_template/structure/ta.json +++ b/resources/master_template/structure/ta.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/master_template/structure/wy.json b/resources/master_template/structure/wy.json index 1b33e090..5eebbb98 100644 --- a/resources/master_template/structure/wy.json +++ b/resources/master_template/structure/wy.json @@ -7,66 +7,5 @@ "도면": { "format": 6, "entities": [] - }, - "산출근거": { - "양식": "산출근거", - "종류": "표", - "판": 1, - "열": [ - { - "id": "work", - "머리": [ - "공종" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "spec", - "머리": [ - "규격" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "detail", - "머리": [ - "산출 내역" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "unit", - "머리": [ - "단위" - ], - "단위": null, - "꼴": "글" - }, - { - "id": "qty", - "머리": [ - "수량(m당)" - ], - "단위": null, - "꼴": "수" - } - ], - "줄": [ - { - "id": "1", - "값": {} - }, - { - "id": "2", - "값": {} - }, - { - "id": "3", - "값": {} - } - ] } } diff --git a/resources/tester/spreadsheet/harness_spreadsheet_d.cjs b/resources/tester/spreadsheet/harness_spreadsheet_d.cjs new file mode 100644 index 00000000..8d220b14 --- /dev/null +++ b/resources/tester/spreadsheet/harness_spreadsheet_d.cjs @@ -0,0 +1,17 @@ +/* 스프레드시트 D 화면 시험 틀 입구 — 견본 통합문서를 띄우고 window.ss* 로 수치를 꺼냄(ORCA eval). + * 번들: node helper_spreadsheet_d_bundle.cjs harness → tmp/spreadsheet_d/harness.js */ +const { createSpreadsheet } = require("../../../A00_Common/spreadsheet/spreadsheet"); +const sample = require("../../../A00_Common/spreadsheet/spreadsheet_sample.json"); + +const host = document.getElementById("host"); +window.ssSelect = null; +window.ssChanges = 0; +window.ssSample = JSON.parse(JSON.stringify(sample)); +window.ss = createSpreadsheet(host, JSON.parse(JSON.stringify(sample)), { + onChange: () => window.ssChanges++, + onSelect: (s) => (window.ssSelect = s), +}); +window.ssCreate = createSpreadsheet; +/** 칸 입력 값(A1) */ +window.ssCell = (a1, sheet = "s1") => + window.ss.getDoc().시트.find((s) => s.id === sheet).칸[a1] ?? null; diff --git a/resources/tester/spreadsheet/harness_spreadsheet_d.html b/resources/tester/spreadsheet/harness_spreadsheet_d.html new file mode 100644 index 00000000..be9d4089 --- /dev/null +++ b/resources/tester/spreadsheet/harness_spreadsheet_d.html @@ -0,0 +1,32 @@ + + + + + 스프레드시트 D 시험 틀 + + + +
+
+ + + + diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs new file mode 100644 index 00000000..cc09a435 --- /dev/null +++ b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs @@ -0,0 +1,102 @@ +/* 스프레드시트 D 순수 규칙 시험 도우미 — 고름 · 입력 글 규칙 · 가리키기 자리 · 채우기 끌기 목표. + * pytest(test_spreadsheet_d_input.py)가 부르고 결과 JSON 을 판정. */ +const path = require("path"); +const { load, SS } = require("./helper_spreadsheet_d_bundle.cjs"); + +const sel = load(path.join(SS, "spreadsheet_selection.ts")); +const ed = load(path.join(SS, "spreadsheet_editor.ts")); +const mouse = load(path.join(SS, "spreadsheet_mouse.ts")); +const addr = load(path.join(SS, "spreadsheet_address.ts")); +const sample = require(path.join(SS, "spreadsheet_sample.json")); + +const s1 = sample.시트[0]; +const a1 = (s) => `${addr.toA1(s.활성.r, s.활성.c)}|${addr.rangeToA1(s.범위[0])}`; +const cell = (r, c) => sel.selectCell(s1, r, c); +const keys = (start, list) => { + let s = start; + return list.map(([dr, dc, o]) => a1((s = sel.move(s1, s, dr, dc, o || {})))); +}; + +const out = { + // 병합 C4:F4 안을 누르면 병합 전체 · 활성은 왼위 + mergeClick: a1(cell(3, 4)), + // 병합에서 오른쪽 → G4 · 왼쪽 → B4 + mergeMove: keys(cell(3, 2), [ + [0, 1], + [0, -1], + [0, -1], + ]), + // Shift+→ 두 번(병합 걸침) · Shift+← 줄임 · Shift+↓ + shiftExtend: keys(cell(3, 0), [ + [0, 1, { extend: true }], + [0, 1, { extend: true }], + [0, -1, { extend: true }], + [1, 0, { extend: true }], + ]), + // Ctrl+↓ 값 뭉치 끝 → 시트 끝 · Ctrl+↑ 되돌아 값 + ctrlJump: keys(cell(2, 0), [ + [1, 0, { jump: true }], + [1, 0, { jump: true }], + [-1, 0, { jump: true }], + ]), + // 숨긴 열 J 건너뜀(I → K) + hiddenSkip: keys(cell(0, 8), [[0, 1]]).map((x) => x.split("|")[0]), + // 열 머리 C — 병합 C3:F3 걸쳐 C:F + colHead: addr.rangeToA1(sel.selectCols(s1, 2, 2).범위[0]), + // 고름 안 Enter 돌기(열 먼저) · Tab(행 먼저) + cycleEnter: (() => { + let s = { + 시트: "s1", + 범위: [{ r0: 10, c0: 10, r1: 11, c1: 11 }], + 활성: { r: 10, c: 10 }, + 기준: { r: 10, c: 10 }, + }; + return [1, 1, 1, 1].map(() => addr.toA1((s = sel.cycle(s1, s, 1, 0)).활성.r, s.활성.c)); + })(), + cycleTab: (() => { + let s = { + 시트: "s1", + 범위: [{ r0: 10, c0: 10, r1: 11, c1: 11 }], + 활성: { r: 10, c: 10 }, + 기준: { r: 10, c: 10 }, + }; + return [1, 1, 1].map(() => addr.toA1((s = sel.cycle(s1, s, 0, 1)).활성.r, s.활성.c)); + })(), + cycleSingle: sel.cycle(s1, cell(3, 2), 1, 0), + usedEnd: addr.toA1(sel.usedEnd(s1).r, sel.usedEnd(s1).c), + // 입력 글 → 칸 + textToCell: ["12", "1,234.5", "50%", "-1e3", "true", "'=12", "=", "=A1+1", "", "1,23", "abc"].map( + (t) => ed.textToCell(t, { 서식: 4 }), + ), + textToCellNoStyle: ed.textToCell("", {}), + cellToText: [ + { 값: "=12" }, + { 값: "12" }, + { 값: "abc" }, + { 값: 1.5 }, + { 값: false }, + { 식: "A1*2" }, + undefined, + ].map((c) => ed.cellToText(c)), + // 참조 넣을 자리 + insertable: [ + ["=", 1], + ["=A1+", 4], + ["=A1", 3], + ["=SUM(", 5], + ["=SUM(A1,", 8], + ["=1 + ", 5], + ["abc", 3], + ["=A1+B", 4], + ].map(([t, p]) => ed.insertableAt(t, p)), + // 참조 색 — 같은 글은 같은 색 · 다른 시트 + highlights: ed + .refHighlights("=A1+B2:C3*A1+치수!B1", "s1", (n) => (n === "치수" ? "s2" : null)) + .map((h) => `${h.시트}:${addr.rangeToA1(h.범위)}:${h.색번호}`), + // 채우기 끌기 목표 + fill: [[{ r: 9, c: 1 }], [{ r: 2, c: 6 }], [{ r: 3, c: 1 }], [{ r: 2, c: 1 }]].map(([p]) => { + const t = mouse.fillTarget({ r0: 1, c0: 1, r1: 3, c1: 2 }, p); + return [addr.rangeToA1(t.target), t.clear && addr.rangeToA1(t.clear)]; + }), +}; +console.log(JSON.stringify(out)); diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d_bundle.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d_bundle.cjs new file mode 100644 index 00000000..1540441e --- /dev/null +++ b/resources/tester/spreadsheet/helper_spreadsheet_d_bundle.cjs @@ -0,0 +1,127 @@ +/* 스프레드시트 D 시험 번들 도우미 — TS 를 그 자리에서 CommonJS 로 풀어 모음. + * 남의 몫 파일이 아직 빈 몸(「아직 없음」)이면 spreadsheet_d_fakes.cjs 의 가짜로 바꿔 끼움. + * load(entry) Node 안에서 모듈을 돌려 exports 를 줌(pytest 도우미가 씀) + * node 이 파일 harness 브라우저 시험 틀 번들 → tmp/spreadsheet_d/harness.js (git 밖) + * `.cjs` = 루트 package.json 의 "type":"module" 과 무관하게 CommonJS. */ + +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.join(__dirname, "..", "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const SS = path.join(ROOT, "A00_Common", "spreadsheet"); +const FAKES = path.join(__dirname, "spreadsheet_d_fakes.cjs"); + +const isStub = (file) => fs.readFileSync(file, "utf8").includes("아직 없음("); + +/** 가져오기 글 → 모듈 id(파일 경로 · `fake:<이름>` · `css:<경로>`) */ +function resolve(spec, from) { + if (spec === "@ui/ui_template_elements") return "fake:el"; + let abs; + if (spec.startsWith("@ui/")) abs = path.join(ROOT, "ui_template", spec.slice(4)); + else if (spec.startsWith(".")) abs = path.resolve(path.dirname(from), spec); + else throw new Error(`모르는 가져오기: ${spec} (${from})`); + if (/\.(css|json|cjs)$/.test(abs)) return (abs.endsWith(".css") ? "css:" : "") + abs; + if (!abs.endsWith(".ts")) abs += ".ts"; + if (path.dirname(abs) === SS && isStub(abs)) + return "fake:" + path.basename(abs, ".ts").replace(/^spreadsheet_/, ""); + return abs; +} + +/** 입구부터 딸린 모듈 전부 → id → {code, deps} */ +function collect(entry, stubs = []) { + const mods = new Map(); + const walk = (id) => { + if (mods.has(id)) return; + let code; + if (id.startsWith("fake:")) { + const name = id.slice(5); + if (name !== "el") stubs.push(name); + code = `module.exports = require("${FAKES.replace(/\\/g, "/")}")[${JSON.stringify(name)}];`; + mods.set(id, { code, deps: { [FAKES.replace(/\\/g, "/")]: FAKES } }); + walk(FAKES); + return; + } + if (id.startsWith("css:")) { + const css = fs.readFileSync(id.slice(4), "utf8"); + code = `if (typeof document !== "undefined") { const s = document.createElement("style"); s.textContent = ${JSON.stringify(css)}; document.head.append(s); }`; + mods.set(id, { code, deps: {} }); + return; + } + const src = fs.readFileSync(id, "utf8"); + if (id.endsWith(".json")) code = `module.exports = ${src};`; + else if (id.endsWith(".ts")) + code = ts.transpileModule(src, { + fileName: id, + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + else code = src; + const deps = {}; + mods.set(id, { code, deps }); + for (const m of code.matchAll(/require\("([^"]+)"\)/g)) { + const dep = m[1] === FAKES.replace(/\\/g, "/") ? FAKES : resolve(m[1], id); + deps[m[1]] = dep; + walk(dep); + } + }; + walk(entry); + return mods; +} + +/** Node 안에서 돌림 */ +function load(entry) { + const abs = path.resolve(entry); + const id = resolve("./" + path.basename(abs).replace(/\.ts$/, ""), abs); // 빈 몸이면 가짜 + const mods = collect(id); + const cache = new Map(); + const req = (id) => { + if (cache.has(id)) return cache.get(id).exports; + const m = mods.get(id); + const module = { exports: {} }; + cache.set(id, module); + new Function("exports", "module", "require", m.code)(module.exports, module, (s) => + req(m.deps[s]), + ); + return module.exports; + }; + return req(id); +} + +/** 브라우저 한 파일 */ +function bundle(entry, out) { + const stubs = []; + const mods = collect(path.resolve(entry), stubs); + const ids = [...mods.keys()]; + const defs = ids + .map((id, i) => { + const m = mods.get(id); + const map = Object.fromEntries(Object.entries(m.deps).map(([s, d]) => [s, ids.indexOf(d)])); + return `[${JSON.stringify(map)}, function (exports, module, require) {\n${m.code}\n}]`; + }) + .join(",\n"); + const text = `(function () { +const defs = [${defs}]; +const cache = []; +function req(i) { + if (cache[i]) return cache[i].exports; + const module = (cache[i] = { exports: {} }); + defs[i][1](module.exports, module, (s) => req(defs[i][0][s])); + return module.exports; +} +window.__ssFakes = ${JSON.stringify([...new Set(stubs)])}; +req(${ids.indexOf(path.resolve(entry))}); +})();\n`; + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, text); + return { out, modules: ids.length, fakes: [...new Set(stubs)] }; +} + +module.exports = { load, bundle, ROOT, SS }; + +if (require.main === module && process.argv[2] === "harness") { + const r = bundle( + path.join(__dirname, "harness_spreadsheet_d.cjs"), + path.join(ROOT, "tmp", "spreadsheet_d", "harness.js"), + ); + console.log(JSON.stringify(r)); +} diff --git a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs new file mode 100644 index 00000000..afa240df --- /dev/null +++ b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs @@ -0,0 +1,665 @@ +/* 스프레드시트 엔진 A 시험 헬퍼 — `A00_Common/spreadsheet/*.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림. + * 묶음마다 틀린 것 목록을 JSON 으로 냄 → `test_spreadsheet_engine.py` 가 묶음마다 빈 목록인지 봄. + * 함수 표(B 몫)는 가짜 넷(SUM · IF · IFERROR · ROUNDDOWN)으로 갈아 끼움 — A 의 부르는 길만 잼. + * 확장자 `.cjs` = 루트 package.json `"type": "module"` 때문. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const DIR = path.join(ROOT, "A00_Common", "spreadsheet"); + +const cache = new Map(); +let fakeFunctions = null; +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full).exports; + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const mod = { exports: {} }; + cache.set(full, mod); + const req = (r) => { + if (r === "./spreadsheet_functions") return fakeFunctions; + if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`)); + if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), `${r}.ts`)); + return require(r); + }; + new Function("exports", "module", "require", js)(mod.exports, mod, req); + return mod.exports; +} +const load = (name) => loadTs(path.join(DIR, `${name}.ts`)); + +fakeFunctions = { + getFunction: (name) => FAKE[name], + functionNames: () => Object.keys(FAKE), +}; +const F = loadTs(path.join(ROOT, "ui_template", "sheet", "ui_template_sheet_frac.ts")); +const E = load("spreadsheet_eval"); +const FAKE = { + SUM: { + min: 1, + call(args) { + let total = F.ZERO; + for (const a of args) { + const v = a(); + if (E.isError(v)) return v; + if (v && v.kind === "range") { + for (let i = 0; i < v.rows; i++) + for (let j = 0; j < v.cols; j++) { + const x = v.at(i, j); + if (E.isError(x)) return x; + if (E.isFrac(x)) total = F.add(total, x); + } + } else { + const n = E.asNumber(v); + if (E.isError(n)) return n; + total = F.add(total, n); + } + } + return total; + }, + }, + IF: { + min: 2, + max: 3, + call(args) { + const c = E.asBool(args[0]()); + if (E.isError(c)) return c; + return c ? args[1]() : args[2] ? args[2]() : false; + }, + }, + IFERROR: { + min: 2, + max: 2, + call(args) { + const v = args[0](); + return E.isError(v) ? args[1]() : v; + }, + }, + ROUNDDOWN: { + min: 2, + max: 2, + call(args) { + const x = E.asNumber(args[0]()); + if (E.isError(x)) return x; + const d = E.asNumber(args[1]()); + if (E.isError(d)) return d; + return F.roundAt(x, Number(F.fracToString(d)), "trunc"); + }, + }, +}; + +const A = load("spreadsheet_address"); +const P = load("spreadsheet_parser"); +const G = load("spreadsheet_graph"); +const R = load("spreadsheet_refshift"); +const C = load("spreadsheet_commands"); +const H = load("spreadsheet_history"); +const RC = load("spreadsheet_recalc"); + +const results = {}; +let group = ""; +const fails = () => (results[group] ??= []); +const eq = (label, got, want) => { + const g = JSON.stringify(got); + const w = JSON.stringify(want); + if (g !== w) fails().push(`${label}: got ${g} want ${w}`); +}; +const section = (name, fn) => { + group = name; + results[name] = []; + try { + fn(); + } catch (e) { + fails().push(`예외: ${e && e.stack ? e.stack : e}`); + } +}; + +/** 값 보기 — 수는 십진 글 · 오류는 코드 */ +function show(v) { + if (v === null) return null; + if (E.isError(v)) return v.error; + if (E.isFrac(v)) return F.fracToString(v); + return v; +} + +/** 시트 둘(s1 「본」 · s2 「치수」) · 칸 표 → 통합문서 */ +function book(cells1, cells2 = {}) { + const wrap = (cells) => + Object.fromEntries( + Object.entries(cells).map(([k, v]) => + typeof v === "string" && v.startsWith("=") ? [k, { 식: v.slice(1) }] : [k, { 값: v }], + ), + ); + return { + 종류: "통합문서", + 판: 1, + 열: "t", + 서식: [{}], + 시트: [ + { id: "s1", 이름: "본", 칸: wrap(cells1) }, + { id: "s2", 이름: "치수", 칸: wrap(cells2) }, + ], + }; +} + +/** 식 하나 풀어 보기 */ +function calc(formula, cells = {}) { + const b = book({ ...cells, Z99: "=" + formula }); + const e = G.createCalcEngine(b); + const p = A.parseA1("Z99"); + return show(e.value("s1", p.r, p.c)); +} + +const at = (a1) => A.parseA1(a1); +const val = (engine, sheet, a1) => show(engine.value(sheet, at(a1).r, at(a1).c)); + +// ── 1. 주소 ───────────────────────────────────────────────────────────────── +section("address", () => { + eq("colName", [0, 25, 26, 27, 701, 702, 16383].map(A.colName), ["A", "Z", "AA", "AB", "ZZ", "AAA", "XFD"]); + eq("colIndex", ["A", "ab", "XFD", "XFE", "", "A1"].map(A.colIndex), [0, 27, 16383, -1, -1, -1]); + eq("toA1", A.toA1(30, 4), "E31"); + eq("parseA1", [A.parseA1("$E$31"), A.parseA1("e31"), A.parseA1("A0"), A.parseA1("XFE1")], [ + { r: 30, c: 4 }, + { r: 30, c: 4 }, + null, + null, + ]); + eq("parseRange 뒤집힘", A.parseRange("B5:A1"), { r0: 0, c0: 0, r1: 4, c1: 1 }); + eq("rangeToA1", [ + A.rangeToA1({ r0: 2, c0: 2, r1: 2, c1: 29 }), + A.rangeToA1({ r0: 30, c0: 4, r1: 30, c1: 4 }), + A.rangeToA1(A.parseRange("A:C")), + A.rangeToA1(A.parseRange("3:5")), + A.rangeToA1(A.parseRange("$B$2:$C$3")), + ], ["C3:AD3", "E31", "A:C", "3:5", "B2:C3"]); + eq("quoteSheet", ["치수", "돌-골막이치수", "암거수량집계표(2×2)", "a'b", "A1", "Sheet 1", "TRUE", "_x"].map(A.quoteSheet), [ + "치수", + "'돌-골막이치수'", + "'암거수량집계표(2×2)'", + "'a''b'", + "'A1'", + "'Sheet 1'", + "'TRUE'", + "_x", + ]); + eq("cellId 왕복", A.fromCellId(A.cellId(1048575, 16383)), { r: 1048575, c: 16383 }); +}); + +// ── 2. 파서 ───────────────────────────────────────────────────────────────── +section("parser", () => { + const kinds = (f) => P.tokenize(f).map((t) => `${t.kind}:${t.text}`); + eq("낱말", kinds("'돌-골막이'!D14+SUM(A1:B2, $C$3)&\"a\"\"b\""), [ + "ref:'돌-골막이'!D14", + "op:+", + "func:SUM", + "paren:(", + "ref:A1:B2", + "comma:,", + "space: ", + "ref:$C$3", + "paren:)", + "op:&", + 'string:"a""b"', + ]); + eq("낱말 자리", P.tokenize("A1 + 2").map((t) => [t.start, t.end]), [[0, 2], [2, 3], [3, 4], [4, 5], [5, 6]]); + eq("낱말 행열 · 오류 · 참거짓", kinds("A:A+3:5+#N/A+치수!#REF!+true+LOG10(1)"), [ + "ref:A:A", "op:+", "ref:3:5", "op:+", "error:#N/A", "op:+", "error:치수!#REF!", "op:+", "bool:true", "op:+", + "func:LOG10", "paren:(", "number:1", "paren:)", + ]); + // 나무 → 글 왕복(괄호는 필요한 곳만) + const round = [ + ["ROUNDDOWN((G31+J31/2)*P31,2)", "ROUNDDOWN((G31+J31/2)*P31,2)"], + ["=-2^2", "-2^2"], + ["-(2^2)", "-(2^2)"], + ["(1+2)*3", "(1+2)*3"], + ["1-(2-3)", "1-(2-3)"], + ["(1-2)-3", "1-2-3"], + ["2^(3^2)", "2^(3^2)"], + ['"a"&(1+2)', '"a"&1+2'], + ["(A1&B1)=C1", "A1&B1=C1"], + ["50%^2", "50%^2"], + ["'돌-골막이치수'!D14*$A$1", "'돌-골막이치수'!D14*$A$1"], + ["치수!B1:C2", "치수!B1:C2"], + ["SUM($A:$A,3:3)", "SUM($A:$A,3:3)"], + ["IF(A1,,1)", "IF(A1,,1)"], + ["sum()", "SUM()"], + ['"say ""hi"""', '"say ""hi"""'], + ["#REF!+1", "#REF!+1"], + ["TRUE<>false", "TRUE<>FALSE"], + ["1.50E+3", "1.50E+3"], + ["_xlfn.CONCAT(1)", "CONCAT(1)"], + ["미정의이름*2", "미정의이름*2"], + ]; + for (const [src, want] of round) { + const p = P.parseFormula(src); + eq(`왕복 ${src}`, p.ok ? P.formulaToText(p.node) : `실패 ${p.message}`, want); + } + const bad = ["1+", "(1", "SUM(1", '"abc', "1 2", ")", "", "A1:", "1+*2"]; + for (const src of bad) eq(`실패 ${src}`, P.parseFormula(src).ok, false); + const f = P.parseFormula("1+SUM(2"); + eq("실패 자리", [f.ok, typeof f.at === "number", typeof f.message], [false, true, "string"]); + eq("격자 밖 주소는 이름", P.parseFormula("XFE1").node, { type: "name", name: "XFE1" }); +}); + +// ── 3. 우선순위 · 형 바꾸기 ───────────────────────────────────────────────── +section("precedence", () => { + const cases = [ + ["-2^2", "4"], + ["0-2^2", "-4"], + ["2^3^2", "64"], + ["2+3*4", "14"], + ["(2+3)*4", "20"], + ["10%*2", "0.2"], + ["-10%", "-0.1"], + ["2^50%", "1.4142135623731"], + ['"a"&1+2', "a3"], + ['1+2&""', "3"], + ["1&2=12", false], + ['1&2="12"', true], + ["3>2=TRUE", true], + ['"abc"="ABC"', true], + ['1<"a"', true], + ['"a""a"', true], + ["0.1+0.2=0.3", true], + ["1/3*3=1", true], + ["A9", "0"], + ["-A9", "0"], + ['A9=""', true], + ["A9=0", true], + ['"12"+1', "13"], + ["TRUE+1", "2"], + ['" 1,234 "+1', "1235"], + ['"50%"+0', "0.5"], + ["2^0.5", "1.4142135623731"], + ["2^-1", "0.5"], + ["(-2)^3", "-8"], + ['1/3&""', "0.333333333333333"], + ['2^60&""', "1.15292150460685E+18"], + ['(0.1+0.2)&""', "0.3"], + ['0.000000001&""', "0.000000001"], + ['TRUE&1', "TRUE1"], + ["+\"x\"", "x"], + ]; + for (const [f, want] of cases) eq(f, calc(f), want); +}); + +// ── 4. 오류 값 · 전파 ─────────────────────────────────────────────────────── +section("errors", () => { + const cases = [ + ["1/0", "#DIV/0!"], + ['"a"+1', "#VALUE!"], + ["#N/A+1/0", "#N/A"], + ["1/0&#N/A", "#DIV/0!"], + ["1/0=#N/A", "#DIV/0!"], + ["-#NUM!", "#NUM!"], + ["(-8)^(1/3)", "#NUM!"], + ["0^0", "#NUM!"], + ["0^-1", "#DIV/0!"], + ["없는시트!A1", "#REF!"], + ["#REF!", "#REF!"], + ["이름표*2", "#NAME?"], + ["FOO(1)", "#NAME?"], + ["IF(1)", "#VALUE!"], + ["1+", "#NAME?"], + ["IF(TRUE,1,1/0)", "1"], + ["IF(FALSE,1/0,2)", "2"], + ["IFERROR(1/0,5)", "5"], + ["IFERROR(A1,5)", "5"], + ["A1:B2", "#VALUE!"], + ["A1:A1", "#NULL!"], + ["B1*2", "#DIV/0!"], + ["SUM(B1:B2)", "#DIV/0!"], + ]; + for (const [f, want] of cases) eq(f, calc(f, { A1: "=#NULL!", B1: "=1/0" }), want); + const b = book({ A1: "=1+", B1: "=FOO(1)", C1: "=IF(1)" }); + const e = G.createCalcEngine(b); + const why = (a1) => e.value("s1", at(a1).r, at(a1).c).why || ""; + eq("까닭 파싱", why("A1").startsWith("식을 못 읽음"), true); + eq("까닭 함수", why("B1"), "모르는 함수: FOO"); + eq("까닭 인자 수", why("C1").includes("인자 수 1"), true); + eq("toCalcValue 오류", E.toCalcValue(e.value("s1", 0, 1)), { 오류: "#NAME?", 까닭: "모르는 함수: FOO" }); +}); + +// ── 5. 순환 ───────────────────────────────────────────────────────────────── +section("cycle", () => { + const b = book({ A1: "=B1+1", B1: "=A1+1", C1: "=A1*2", D1: 5, E1: "=E1", F1: "=SUM(F2:F3)", F3: "=F1", G1: "=D1*2" }); + const e = G.createCalcEngine(b); + const v = (a1) => val(e, "s1", a1); + eq("고리 칸", ["A1", "B1", "E1", "F1", "F3"].map(v), ["#CYCLE!", "#CYCLE!", "#CYCLE!", "#CYCLE!", "#CYCLE!"]); + eq("고리를 읽는 칸", v("C1"), "#CYCLE!"); + eq("나머지 칸", [v("D1"), v("G1")], ["5", "10"]); + const why = e.value("s1", 0, 0).why; + eq("까닭 = 고리 칸 목록", /A1/.test(why) && /B1/.test(why) && why.startsWith("순환 참조"), true); + // 고리를 끊으면 딸린 칸이 풀림 + C.applyCommand(b, { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 3 } } }); + e.update([{ 시트: "s1", ...at("B1") }]); + eq("끊은 뒤", ["A1", "B1", "C1"].map(v), ["4", "3", "8"]); + // 다시 고리 + C.applyCommand(b, { 종류: "칸", 시트: "s1", 칸: { B1: { 식: "C1" } } }); + e.update([{ 시트: "s1", ...at("B1") }]); + eq("다시 고리", ["A1", "B1", "C1", "D1"].map(v), ["#CYCLE!", "#CYCLE!", "#CYCLE!", "5"]); +}); + +// ── 6. 딸린 칸만 다시 ─────────────────────────────────────────────────────── +section("graph", () => { + const b = book({ A1: 1, A2: 2, B1: "=A1*2", C1: "=B1+1", D1: "=7", E1: "=SUM(A1:A3)", F1: "=치수!B1*10" }, { B1: 0.9 }); + const e = G.createCalcEngine(b); + const v = (a1) => val(e, "s1", a1); + eq("처음", ["B1", "C1", "D1", "E1", "F1"].map(v), ["2", "3", "7", "3", "9"]); + b.시트[0].칸.A1 = { 값: 10 }; + const redo = e.update([{ 시트: "s1", ...at("A1") }]).map((a) => A.toA1(a.r, a.c)).sort(); + eq("다시 푼 칸", redo, ["A1", "B1", "C1", "E1"]); + eq("값", ["B1", "C1", "D1", "E1"].map(v), ["20", "21", "7", "12"]); + b.시트[0].칸.A3 = { 값: 5 }; + eq("범위 안 새 칸", e.update([{ 시트: "s1", ...at("A3") }]).map((a) => A.toA1(a.r, a.c)).sort(), ["A3", "E1"]); + eq("범위 값", v("E1"), "17"); + b.시트[1].칸.B1 = { 값: 2 }; + eq("다른 시트", e.update([{ 시트: "s2", ...at("B1") }]).map((a) => `${a.시트}!${A.toA1(a.r, a.c)}`).sort(), [ + "s1!F1", + "s2!B1", + ]); + eq("다른 시트 값", v("F1"), "20"); + eq("앞 칸", e.precedents("s1", at("E1").r, at("E1").c), [{ 시트: "s1", 범위: { r0: 0, c0: 0, r1: 2, c1: 0 } }]); + eq("snapshot 식 칸만", Object.keys(e.snapshot().s1).sort(), ["B1", "C1", "D1", "E1", "F1"]); + // 긴 사슬(끌어 풀기 재귀 깊이) + const chain = { A1: 1 }; + for (let i = 2; i <= 1500; i++) chain[`A${i}`] = `=A${i - 1}+1`; + eq("사슬 1500", val(G.createCalcEngine(book(chain)), "s1", "A1500"), "1500"); +}); + +// ── 7. 참조 옮김 엑셀 규칙표 ──────────────────────────────────────────────── +section("refshift", () => { + const b = book({}, {}); + const run = (label, cmd, rows, host = "s1") => { + for (const [src, want] of rows) eq(`${label} ${src}`, R.shiftForCommand(src, host, cmd, b), want); + }; + run("행넣기 3행에 1", { 종류: "행넣기", 시트: "s1", at: 2, 수: 1 }, [ + ["A1", "A1"], + ["A3", "A4"], + ["A2:A5", "A2:A6"], + ["A3:A5", "A4:A6"], + ["$A$3", "$A$4"], + ["A$3+$A3", "A$4+$A4"], + ["A:A", "A:A"], + ["3:5", "4:6"], + ["2:5", "2:6"], + ["치수!A3", "치수!A3"], + ["본!A3", "본!A4"], + ["( A3 + 1 )", "( A4 + 1 )"], + ['"A3"&A3', '"A3"&A4'], + ["A5:A2", "A2:A6"], + ]); + run("행넣기 다른 시트에서", { 종류: "행넣기", 시트: "s1", at: 2, 수: 1 }, [["본!A3+A3", "본!A4+A3"]], "s2"); + run("행지우기 3~4행", { 종류: "행지우기", 시트: "s1", at: 2, 수: 2 }, [ + ["A3", "#REF!"], + ["A4", "#REF!"], + ["A5", "A3"], + ["A2:A6", "A2:A4"], + ["A3:A4", "#REF!"], + ["A1:A3", "A1:A2"], + ["A4:A8", "A3:A6"], + ["SUM(A3,A5)", "SUM(#REF!,A3)"], + ["$B$9", "$B$7"], + ["3:3", "#REF!"], + ["B:B", "B:B"], + ]); + run("열넣기 B열에 2", { 종류: "열넣기", 시트: "s1", at: 1, 수: 2 }, [ + ["B1", "D1"], + ["A1:C1", "A1:E1"], + ["A:B", "A:D"], + ["3:3", "3:3"], + ["$B$1", "$D$1"], + ["A1", "A1"], + ]); + run("열지우기 B열", { 종류: "열지우기", 시트: "s1", at: 1, 수: 1 }, [ + ["B1", "#REF!"], + ["C1", "B1"], + ["A1:C1", "A1:B1"], + ["B:B", "#REF!"], + ]); + const cut = { 종류: "옮기기", 시트: "s1", 범위: { r0: 0, c0: 0, r1: 1, c1: 1 }, 대상시트: "s1", r: 4, c: 3 }; + run("옮기기 A1:B2 → D5", cut, [ + ["A1", "D5"], + ["$B$2", "$E$6"], + ["A1:B2", "D5:E6"], + ["A1:C3", "A1:C3"], + ["D5", "#REF!"], + ["D5:E6", "#REF!"], + ["C3", "C3"], + ["치수!A1", "치수!A1"], + ]); + const cutAway = { 종류: "옮기기", 시트: "s1", 범위: { r0: 0, c0: 0, r1: 0, c1: 0 }, 대상시트: "s2", r: 9, c: 0 }; + run("옮기기 다른 시트로", cutAway, [["A1+B1", "치수!A10+B1"]]); + run("옮기기 다른 시트로(그쪽 식)", cutAway, [["본!A1+A1", "A10+A1"]], "s2"); + eq("옮겨 간 식 자체", R.shiftForCommand("A1+B1", "s1", cutAway, b, "s2"), "A10+본!B1"); + run("시트이름 치수 → 치 수", { 종류: "시트이름", 시트: "s2", 이름: "치 수" }, [ + ["치수!B1", "'치 수'!B1"], + ["'치수'!B1:B2", "'치 수'!B1:B2"], + ["치수!B1+B1", "'치 수'!B1+B1"], + ]); + run("시트지우기", { 종류: "시트지우기", 시트: "s2" }, [ + ["치수!B1+1", "#REF!+1"], + ["B1", "B1"], + ]); + const mv = [ + [["A1+$B$2+B$3+$C4", 1, 1], "B2+$B$2+C$3+$C5"], + [["A1", -1, 0], "#REF!"], + [["A:A+3:3", 5, 1], "B:B+8:8"], + [["SUM(A1:B2)*2", 2, 0], "SUM(A3:B4)*2"], + [["치수!A1", 0, 1], "치수!B1"], + [["XFD1", 0, 1], "#REF!"], + ]; + for (const [[f, dr, dc], want] of mv) eq(`복사 ${f} (${dr},${dc})`, R.moveFormula(f, dr, dc), want); + const r1 = [ + [["=2*R[0]C[-1]", { r: 4, c: 3 }], "2*C5"], + [["=SUM(R[-2]C:R[-1]C)", { r: 4, c: 0 }], "SUM(A3:A4)"], + [["=R1C1", { r: 9, c: 9 }], "$A$1"], + [['="RC"&ROUND(RC[1],0)', { r: 0, c: 0 }], '"RC"&ROUND(B1,0)'], + [["='치수'!R[1]C", { r: 0, c: 0 }], "'치수'!A2"], + [["=R[-1]C", { r: 0, c: 0 }], "#REF!"], + ]; + for (const [[f, pos], want] of r1) eq(`R1C1 ${f}`, R.r1c1ToA1(f, pos), want); +}); + +// ── 8. 명령 · 되돌리기 ────────────────────────────────────────────────────── +const SAMPLE = JSON.parse(fs.readFileSync(path.join(DIR, "spreadsheet_sample.json"), "utf8")); +section("commands", () => { + const fresh = () => structuredClone(SAMPLE); + // 열쇠 차례 · 병합 차례는 뜻이 없음 — 바로 세워 견줌 + const canon = (v, k) => + Array.isArray(v) + ? (k === "병합" ? [...v].sort() : v).map((x) => canon(x)) + : v && typeof v === "object" + ? Object.fromEntries(Object.keys(v).sort().map((key) => [key, canon(v[key], key)])) + : v; + const sheetsJson = (b) => JSON.stringify(canon(b.시트)); + /** 적용 → 되돌림 → 처음과 같은지 · 다시 → 적용 뒤와 같은지 */ + const roundTrip = (label, cmd, check) => { + const b = fresh(); + const before = sheetsJson(b); + const fx = C.applyCommand(b, cmd); + const after = sheetsJson(b); + if (check) check(b, fx); + C.applyCommand(b, fx.undo); + eq(`${label} 되돌림`, sheetsJson(b) === before, true); + C.applyCommand(b, cmd); + eq(`${label} 다시`, sheetsJson(b) === after, true); + }; + const s1 = (b) => b.시트[0]; + roundTrip("칸", { 종류: "칸", 시트: "s1", 칸: { h4: { 값: 1 }, A1: null, Z9: { 식: "H4*2" } } }, (b, fx) => { + eq("칸 열쇠 바로 세움", [s1(b).칸.H4, s1(b).칸.A1, fx.cells.length, fx.rebuild], [{ 값: 1 }, undefined, 3, false]); + }); + roundTrip("내용지움", { 종류: "내용지움", 시트: "s1", 범위: [A.parseRange("A3:H4")] }, (b, fx) => { + eq("서식은 남음", [s1(b).칸.A3, s1(b).칸.D3, fx.cells.length], [{ 서식: 2 }, { 서식: 2 }, 10]); + }); + roundTrip("서식", { 종류: "서식", 시트: "s1", 범위: [A.parseRange("H4:H6")], 바꿀: { 굵게: true, 형식: null } }, (b) => { + const st = b.서식[s1(b).칸.H4.서식]; + eq("서식 덮기", [st.굵게, st.형식, s1(b).칸.H4.서식 === s1(b).칸.H5.서식], [true, undefined, true]); + }); + roundTrip("서식 열 전체", { 종류: "서식", 시트: "s1", 범위: [A.parseRange("B:B")], 바꿀: { 기울임: true } }, (b, fx) => { + eq("열 서식", [!!s1(b).열.B.서식, fx.rebuild, b.서식[s1(b).칸.B4.서식].기울임], [true, true, true]); + }); + roundTrip("서식지움", { 종류: "서식지움", 시트: "s1", 범위: [A.parseRange("A3:B4")] }, (b) => { + eq("서식지움", [s1(b).칸.A3, s1(b).칸.A4], [{ 값: "공종" }, { 값: "터파기" }]); + }); + roundTrip("행넣기", { 종류: "행넣기", 시트: "s1", at: 3, 수: 1 }, (b) => { + const c = s1(b).칸; + eq("행넣기 칸 · 식 · 병합", [c.H4, c.H5.식, c.H6.식, c.H7.식, c.J5.식, s1(b).병합], [ + undefined, + "ROUNDDOWN((J5+J6)/2*J7,2)", + "ROUNDDOWN(H5*0.3,2)", + "SUM(H5:H6)", + "치수!B1", + ["A1:H1", "C3:F3", "C5:F5", "C6:F6", "A7:G7"], + ]); + }); + roundTrip("행넣기 틀고정 위", { 종류: "행넣기", 시트: "s1", at: 0, 수: 2 }, (b) => { + eq("틀고정 · 행 높이 밀림", [s1(b).틀고정, s1(b).행], [{ 행: 5 }, { 3: { 높이: 20 } }]); + }); + roundTrip("행지우기", { 종류: "행지우기", 시트: "s1", at: 3, 수: 1 }, (b) => { + const c = s1(b).칸; + eq("행지우기", [c.H4.식, c.H5.식, s1(b).병합], ["ROUNDDOWN(#REF!*0.3,2)", "SUM(H4:H4)", ["A1:H1", "C3:F3", "C4:F4", "A5:G5"]]); + }); + roundTrip("열넣기 병합 안", { 종류: "열넣기", 시트: "s1", at: 2, 수: 2 }, (b) => { + eq("병합 늘어남 · 열 폭 밀림", [s1(b).병합[0], s1(b).열.E, s1(b).열.L, s1(b).칸.J4.식], ["A1:J1", { 폭: 6 }, { 숨김: true }, "ROUNDDOWN((L4+L5)/2*L6,2)"]); + }); + roundTrip("열지우기", { 종류: "열지우기", 시트: "s1", at: 2, 수: 4 }, (b) => { + eq("병합 줄고 없어짐", s1(b).병합, ["A1:D1", "A6:C6"]); + }); + roundTrip("옮기기", { 종류: "옮기기", 시트: "s1", 범위: A.parseRange("J4:J6"), 대상시트: "s2", r: 4, c: 0 }, (b) => { + const t = b.시트[1].칸; + eq("옮기기", [s1(b).칸.H4.식, t.A5.식, t.A6.값, s1(b).칸.J4], ["ROUNDDOWN((치수!A5+치수!A6)/2*치수!A7,2)", "B1", 0.3, undefined]); + }); + roundTrip("옮기기 병합째", { 종류: "옮기기", 시트: "s1", 범위: A.parseRange("A5:H6"), 대상시트: "s1", r: 19, c: 0 }, (b) => { + eq("병합 따라감", s1(b).병합, ["A1:H1", "C3:F3", "C4:F4", "C20:F20", "A21:G21"]); + }); + roundTrip("병합", { 종류: "병합", 시트: "s1", 범위: A.parseRange("A4:H5") }, (b, fx) => { + eq("왼위만 값", [s1(b).칸.A4.값, s1(b).칸.B4, s1(b).칸.H5, fx.cells.length], ["터파기", { 서식: 4 }, { 서식: 5 }, 9]); + eq("품은 병합 흡수", s1(b).병합, ["A1:H1", "C3:F3", "A6:G6", "A4:H5"]); + }); + roundTrip("병합풀기", { 종류: "병합풀기", 시트: "s1", 범위: A.parseRange("A1:D4") }, (b) => { + eq("병합풀기", s1(b).병합, ["C5:F5", "A6:G6"]); + }); + roundTrip("열폭", { 종류: "열폭", 시트: "s1", 열: [0, 1, 2], 폭: 9 }, (b) => eq("열폭", s1(b).열.A.폭, 9)); + roundTrip("열폭 기본으로", { 종류: "열폭", 시트: "s1", 열: [0, 2], 폭: null }, (b) => eq("열폭 지움", s1(b).열.A, undefined)); + roundTrip("행높이", { 종류: "행높이", 시트: "s1", 행: [0, 4], 높이: 30 }); + roundTrip("숨김", { 종류: "숨김", 시트: "s1", 축: "열", 번호: [8, 9], 숨김: true }); + roundTrip("보이기", { 종류: "숨김", 시트: "s1", 축: "열", 번호: [9], 숨김: false }, (b) => eq("보이기", s1(b).열.J, undefined)); + roundTrip("틀고정", { 종류: "틀고정", 시트: "s1", 행: 0, 열: 2 }, (b) => eq("틀고정", s1(b).틀고정, { 열: 2 })); + roundTrip("눈금선", { 종류: "눈금선", 시트: "s1", 보임: true }, (b) => eq("눈금선", s1(b).보기, undefined)); + roundTrip("시트더하기", { 종류: "시트더하기", 시트: C.emptySheet("s3", "새 시트"), 자리: 1 }, (b) => + eq("시트 차례", b.시트.map((s) => s.id), ["s1", "s3", "s2"]), + ); + roundTrip("시트지우기", { 종류: "시트지우기", 시트: "s2" }, (b) => eq("#REF!", s1(b).칸.J4.식, "#REF!")); + roundTrip("시트이름", { 종류: "시트이름", 시트: "s2", 이름: "치 수" }, (b) => eq("이름 따라감", s1(b).칸.J4.식, "'치 수'!B1")); + roundTrip("시트옮기기", { 종류: "시트옮기기", 시트: "s2", 자리: 0 }); + roundTrip("묶음", { + 종류: "묶음", + 명령: [ + { 종류: "칸", 시트: "s1", 칸: { K1: { 값: 1 } } }, + { 종류: "행넣기", 시트: "s1", at: 0, 수: 1 }, + { 종류: "병합", 시트: "s1", 범위: A.parseRange("K2:L2") }, + ], + }); + // 막힘 + const blocked = [ + ["병합 가운데 칸 쓰기", { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 1 } } }], + ["병합 가르는 옮기기", { 종류: "옮기기", 시트: "s1", 범위: A.parseRange("A1:B1"), 대상시트: "s1", r: 10, c: 0 }], + ["병합 걸침", { 종류: "병합", 시트: "s1", 범위: A.parseRange("B1:B2") }], + ["같은 시트 이름", { 종류: "시트이름", 시트: "s1", 이름: "치수" }], + ["못 쓰는 글자", { 종류: "시트이름", 시트: "s1", 이름: "a/b" }], + ["격자 끝 밀림", { 종류: "열넣기", 시트: "s2", at: 0, 수: 16384 }], + ]; + for (const [label, cmd] of blocked) { + const b = fresh(); + const before = sheetsJson(b); + let msg = null; + try { + C.applyCommand(b, cmd); + } catch (e) { + msg = e instanceof C.CommandError ? "CommandError" : String(e); + } + eq(`막힘 ${label}`, [msg, sheetsJson(b) === before], ["CommandError", true]); + } + const one = C.emptyWorkbook("x", "시트1"); + let last = null; + try { + C.applyCommand(one, { 종류: "시트지우기", 시트: "s1" }); + } catch (e) { + last = e instanceof C.CommandError; + } + eq("마지막 시트", last, true); + // 묶음은 통째로 — 뒤가 막히면 앞도 되돌림 + const b = fresh(); + const before = sheetsJson(b); + try { + C.applyCommand(b, { + 종류: "묶음", + 명령: [ + { 종류: "칸", 시트: "s1", 칸: { K1: { 값: 1 } } }, + { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 1 } } }, + ], + }); + } catch {} + eq("묶음 원자", sheetsJson(b) === before, true); + eq("emptyWorkbook", C.emptyWorkbook("h1", "시트1"), { + 종류: "통합문서", + 판: 1, + 열: "h1", + 서식: [{}], + 시트: [{ id: "s1", 이름: "시트1", 칸: {} }], + 활성: "s1", + }); + const cs = fresh(); + cs.서식.push({ 굵게: true }, { 크기: 12, 굵게: true }); + cs.시트[0].칸.K1 = { 값: 1, 서식: 8 }; + C.compactStyles(cs); + eq("compactStyles", [cs.서식.length, cs.시트[0].칸.K1.서식, cs.시트[0].칸.A1.서식], [7, 1, 1]); +}); + +// ── 9. 되돌리기 스택 · 엔진과 잇기 · 한 벌 풀이 ────────────────────────────── +section("history", () => { + const h = H.createHistory(2); + const c = (n) => ({ 종류: "칸", 시트: "s1", 칸: { A1: { 값: n } } }); + eq("빈", [h.canUndo(), h.canRedo(), h.undo(), h.redo()], [false, false, null, null]); + h.push(c(1), c(-1)); + h.push(c(2), c(-2)); + h.push(c(3), c(-3)); + eq("한도 넘어 버림", [h.undo(), h.undo(), h.undo()], [c(-3), c(-2), null]); + eq("다시", [h.canRedo(), h.redo(), h.redo(), h.redo()], [true, c(2), c(3), null]); + h.undo(); + h.push(c(4), c(-4)); + eq("새 명령은 다시 스택 비움", h.canRedo(), false); + h.clear(); + eq("clear", [h.canUndo(), h.canRedo()], [false, false]); + + // 명령 → 엔진 → Ctrl+Z · Ctrl+Y + const b = structuredClone(SAMPLE); + const e = G.createCalcEngine(b); + const hist = H.createHistory(); + const run = (cmd) => { + const fx = C.applyCommand(b, cmd); + if (fx.rebuild) e.rebuild(b); + else e.update(fx.cells); + return fx; + }; + const doCmd = (cmd) => hist.push(cmd, run(cmd).undo); + const v = (a1) => val(e, "s1", a1); + eq("견본 값", ["H4", "H5", "H6", "J4"].map(v), ["0.18", "0.05", "0.23", "0.9"]); + doCmd({ 종류: "칸", 시트: "s1", 칸: { J5: { 값: 0.5 } } }); + eq("고친 뒤", ["H4", "H5", "H6"].map(v), ["0.21", "0.06", "0.27"]); + doCmd({ 종류: "행지우기", 시트: "s1", at: 3, 수: 1 }); + eq("행 지운 뒤", [v("H4"), v("H5")], ["#REF!", "#REF!"]); + run(hist.undo()); + eq("Ctrl+Z", ["H4", "H5", "H6"].map(v), ["0.21", "0.06", "0.27"]); + run(hist.undo()); + eq("Ctrl+Z 둘", ["H4", "H5", "H6"].map(v), ["0.18", "0.05", "0.23"]); + run(hist.redo()); + eq("Ctrl+Y", v("H6"), "0.27"); + eq("recalcWorkbook = 견본 계산값", RC.recalcWorkbook(structuredClone(SAMPLE)), SAMPLE.계산값); +}); + +process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs b/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs new file mode 100644 index 00000000..2bc09d98 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs @@ -0,0 +1,480 @@ +/* 스프레드시트 D 시험용 가짜 부품 — 남의 몫(A 엔진 · B 채우기 · C 격자 · E 도구)이 아직 빈 몸일 때만 + * 번들 도우미(helper_spreadsheet_d_bundle.cjs)가 이것으로 바꿔 끼움. 진짜가 들어오면 안 씀. + * 뜻은 엑셀 흉내의 최소 — D 입력(고름 · 편집 · 가리키기 · 채우기 끌기 · 되돌리기)을 재기 위한 것뿐. */ + +const MAX_ROWS = 1048576; +const MAX_COLS = 16384; + +// ── @ui el ──────────────────────────────────────────────────────────────── +function el(tag, o = {}) { + const n = document.createElement(tag); + if (o.className) n.className = o.className; + if (o.text !== undefined) n.textContent = o.text; + for (const [k, v] of Object.entries(o.attrs || {})) n.setAttribute(k, v); + for (const c of o.children || []) n.append(c); + return n; +} + +// ── A 주소 ──────────────────────────────────────────────────────────────── +function colName(c) { + let s = ""; + for (c += 1; c > 0; c = Math.floor((c - 1) / 26)) + s = String.fromCharCode(65 + ((c - 1) % 26)) + s; + return s; +} +function colIndex(t) { + if (!/^[A-Za-z]{1,3}$/.test(t)) return -1; + let n = 0; + for (const ch of t.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64; + return n - 1; +} +const toA1 = (r, c) => colName(c) + (r + 1); +function parseA1(t) { + const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(t.trim()); + if (!m) return null; + return { r: Number(m[2]) - 1, c: colIndex(m[1]) }; +} +function rangeToA1(g) { + if (g.c0 === 0 && g.c1 === MAX_COLS - 1) return `${g.r0 + 1}:${g.r1 + 1}`; + if (g.r0 === 0 && g.r1 === MAX_ROWS - 1) return `${colName(g.c0)}:${colName(g.c1)}`; + if (g.r0 === g.r1 && g.c0 === g.c1) return toA1(g.r0, g.c0); + return `${toA1(g.r0, g.c0)}:${toA1(g.r1, g.c1)}`; +} +function parseRange(t) { + t = t.replace(/\$/g, "").trim(); + const [a, b = a] = t.split(":"); + const pa = parseA1(a); + const pb = parseA1(b); + if (pa && pb) + return { + r0: Math.min(pa.r, pb.r), + c0: Math.min(pa.c, pb.c), + r1: Math.max(pa.r, pb.r), + c1: Math.max(pa.c, pb.c), + }; + if (/^\d+$/.test(a) && /^\d+$/.test(b)) + return { r0: Math.min(a, b) - 1, r1: Math.max(a, b) - 1, c0: 0, c1: MAX_COLS - 1 }; + if (colIndex(a) >= 0 && colIndex(b) >= 0) + return { + r0: 0, + r1: MAX_ROWS - 1, + c0: Math.min(colIndex(a), colIndex(b)), + c1: Math.max(colIndex(a), colIndex(b)), + }; + return null; +} +const address = { + colName, + colIndex, + toA1, + parseA1, + rangeToA1, + parseRange, + quoteSheet: (n) => (/^[\w가-힣]+$/.test(n) ? n : `'${n.replace(/'/g, "''")}'`), + cellId: (r, c) => r * MAX_COLS + c, + fromCellId: (id) => ({ r: Math.floor(id / MAX_COLS), c: id % MAX_COLS }), +}; + +// ── A 낱말 (참조만 정확히) ────────────────────────────────────────────────── +const REF = + /(?:(?:'(?:[^']|'')+'|[A-Za-z_가-힣][\w.가-힣]*)!)?\$?[A-Za-z]{1,3}\$?\d+(?::\$?[A-Za-z]{1,3}\$?\d+)?/y; +function tokenize(f) { + const out = []; + let i = 0; + while (i < f.length) { + REF.lastIndex = i; + const m = REF.exec(f); + if (m && !/[A-Za-z0-9_]/.test(f[i - 1] || "") && f[i + m[0].length] !== "(") { + out.push({ kind: "ref", text: m[0], start: i, end: i + m[0].length }); + i += m[0].length; + continue; + } + out.push({ kind: "unknown", text: f[i], start: i, end: i + 1 }); + i++; + } + return out; +} +const parser = { + tokenize, + parseFormula: () => ({ ok: false, message: "가짜", at: 0 }), + formulaToText: () => "", +}; + +// ── A 참조 옮김 ──────────────────────────────────────────────────────────── +function moveFormula(f, dr, dc) { + return f.replace(/(\$?)([A-Z]{1,3})(\$?)(\d+)/g, (all, ac, col, ar, row) => { + const c = ac ? colIndex(col) : colIndex(col) + dc; + const r = ar ? Number(row) - 1 : Number(row) - 1 + dr; + if (c < 0 || r < 0) return "#REF!"; + return `${ac}${colName(c)}${ar}${r + 1}`; + }); +} +const refshift = { moveFormula, shiftForCommand: (f) => f, r1c1ToA1: (f) => f }; + +// ── B 채우기 (복사 + 두 수 간격만) ───────────────────────────────────────── +function fillCells(book, sheetId, src, target) { + const sheet = book.시트.find((s) => s.id === sheetId); + const out = {}; + const vertical = target.r0 !== src.r0 || target.r1 !== src.r1; + const len = vertical ? src.r1 - src.r0 + 1 : src.c1 - src.c0 + 1; + for (let r = target.r0; r <= target.r1; r++) + for (let c = target.c0; c <= target.c1; c++) { + if (r >= src.r0 && r <= src.r1 && c >= src.c0 && c <= src.c1) continue; + const k = vertical ? (((r - src.r0) % len) + len) % len : (((c - src.c0) % len) + len) % len; + const sr = vertical ? src.r0 + k : r; + const sc = vertical ? c : src.c0 + k; + const cell = sheet.칸[toA1(sr, sc)]; + if (!cell) { + out[toA1(r, c)] = null; + continue; + } + const next = { ...cell }; + if (cell.식) next.식 = moveFormula(cell.식, r - sr, c - sc); + // 수 두 칸 이상이면 간격 이음 + const first = sheet.칸[toA1(vertical ? src.r0 : r, vertical ? c : src.c0)]; + const second = sheet.칸[toA1(vertical ? src.r0 + 1 : r, vertical ? c : src.c0 + 1)]; + if (len >= 2 && typeof first?.값 === "number" && typeof second?.값 === "number") { + const step = second.값 - first.값; + const n = vertical ? r - src.r0 : c - src.c0; + next.값 = first.값 + step * n; + } + out[toA1(r, c)] = next; + } + return out; +} +const fill = { fillCells }; + +// ── A 명령 · 이력 (칸 · 내용지움 · 묶음) ──────────────────────────────────── +class CommandError extends Error {} +function cellsIn(sheet, ranges) { + const keys = []; + for (const key of Object.keys(sheet.칸)) { + const a = parseA1(key); + if (ranges.some((g) => a.r >= g.r0 && a.r <= g.r1 && a.c >= g.c0 && a.c <= g.c1)) + keys.push(key); + } + return keys; +} +function applyCommand(book, cmd) { + const sheetOf = (id) => book.시트.find((s) => s.id === id); + if (cmd.종류 === "칸") { + const sheet = sheetOf(cmd.시트); + const undo = {}; + const cells = []; + for (const [k, v] of Object.entries(cmd.칸)) { + undo[k] = sheet.칸[k] ? structuredClone(sheet.칸[k]) : null; + if (v === null) delete sheet.칸[k]; + else sheet.칸[k] = structuredClone(v); + cells.push({ 시트: cmd.시트, ...parseA1(k) }); + } + return { undo: { 종류: "칸", 시트: cmd.시트, 칸: undo }, cells, rebuild: false }; + } + if (cmd.종류 === "내용지움") { + const sheet = sheetOf(cmd.시트); + const next = {}; + for (const k of cellsIn(sheet, cmd.범위)) { + const { 서식 } = sheet.칸[k]; + next[k] = 서식 === undefined ? null : { 서식 }; + } + return applyCommand(book, { 종류: "칸", 시트: cmd.시트, 칸: next }); + } + if (cmd.종류 === "묶음") { + const undos = []; + const cells = []; + for (const c of cmd.명령) { + const e = applyCommand(book, c); + undos.unshift(e.undo); + cells.push(...e.cells); + } + return { undo: { 종류: "묶음", 명령: undos }, cells, rebuild: false }; + } + throw new CommandError(`가짜 명령 없음: ${cmd.종류}`); +} +const commands = { + CommandError, + applyCommand, + emptyWorkbook: (col, name) => ({ + 종류: "통합문서", + 판: 1, + 열: col, + 서식: [{}], + 시트: [{ id: "s1", 이름: name, 칸: {} }], + }), + emptySheet: (id, name) => ({ id, 이름: name, 칸: {} }), + compactStyles: () => {}, +}; +const history = { + createHistory(limit = 100) { + let done = []; + let undone = []; + return { + push(cmd, undo) { + done.push({ cmd, undo }); + if (done.length > limit) done.shift(); + undone = []; + }, + undo() { + const e = done.pop(); + if (!e) return null; + undone.push(e); + return e.undo; + }, + redo() { + const e = undone.pop(); + if (!e) return null; + done.push(e); + return e.cmd; + }, + canUndo: () => done.length > 0, + canRedo: () => undone.length > 0, + clear() { + done = []; + undone = []; + }, + }; + }, +}; + +// ── A 계산 엔진 (사칙 · SUM 만 · double) ──────────────────────────────────── +function createCalcEngine(book0) { + let book = book0; + const sheetOf = (id) => book.시트.find((s) => s.id === id); + function value(sid, r, c, depth = 0) { + const cell = sheetOf(sid)?.칸[toA1(r, c)]; + if (!cell) return null; + if (cell.식 === undefined) return cell.값 ?? null; + if (depth > 50) return { error: "#CYCLE!" }; + const num = (v) => (typeof v === "number" ? v : v === true ? 1 : 0); + let f = cell.식.replace(/SUM\(([A-Z]+\d+):([A-Z]+\d+)\)/gi, (_, a, b) => { + const g = parseRange(`${a}:${b}`); + let s = 0; + for (let rr = g.r0; rr <= g.r1; rr++) + for (let cc = g.c0; cc <= g.c1; cc++) s += num(value(sid, rr, cc, depth + 1)); + return `(${s})`; + }); + f = f.replace(/\$?([A-Z]{1,3})\$?(\d+)/g, (_, col, row) => { + const v = value(sid, Number(row) - 1, colIndex(col), depth + 1); + return `(${num(v)})`; + }); + if (!/^[\d+\-*/(). e]*$/.test(f)) return { error: "#NAME?" }; + try { + return Function(`return (${f})`)(); + } catch { + return { error: "#NAME?" }; + } + } + return { + update: (changed) => { + const out = [...changed]; + for (const s of book.시트) + for (const k of Object.keys(s.칸)) + if (s.칸[k].식 !== undefined) out.push({ 시트: s.id, ...parseA1(k) }); + return out; + }, + rebuild: (b) => (book = b), + value: (s, r, c) => value(s, r, c), + precedents: () => [], + snapshot: () => ({}), + }; +} +const graph = { createCalcEngine }; + +// ── C 격자 (고정 칸 크기 · 병합 · 고름 · 참조 테두리 · 채우기 손잡이) ──────────── +const W = 72; +const H = 22; +const HW = 40; +const HH = 22; +const ROWS = 60; +const COLS = 20; +const REF_COLORS = ["#1f6feb", "#cf222e", "#8250df", "#1a7f37", "#bc4c00"]; + +function createGrid(ctx) { + const root = el("div", { className: "fg" }); + root.style.cssText = "position:relative;overflow:auto;height:420px;border:1px solid #ccc"; + const content = el("div"); + content.style.cssText = `position:relative;width:${HW + COLS * W}px;height:${HH + ROWS * H}px;font:12px sans-serif`; + root.append(content); + const cellLayer = el("div"); + const overlay = el("div"); + content.append(cellLayer, overlay); + let refs = []; + const merges = () => (ctx.sheet().병합 || []).map(parseRange).filter(Boolean); + const mergeAt = (r, c) => merges().find((m) => r >= m.r0 && r <= m.r1 && c >= m.c0 && c <= m.c1); + + function cellBox(r, c) { + const m = mergeAt(r, c) || { r0: r, c0: c, r1: r, c1: c }; + return { + x: HW + m.c0 * W, + y: HH + m.r0 * H, + w: (m.c1 - m.c0 + 1) * W, + h: (m.r1 - m.r0 + 1) * H, + }; + } + const rangeBox = (g) => { + const a = cellBox(g.r0, g.c0); + const b = cellBox(Math.min(g.r1, ROWS - 1), Math.min(g.c1, COLS - 1)); + return { x: a.x, y: a.y, w: b.x + b.w - a.x, h: b.y + b.h - a.y }; + }; + const place = (n, b) => + Object.assign(n.style, { + position: "absolute", + left: `${b.x}px`, + top: `${b.y}px`, + width: `${b.w}px`, + height: `${b.h}px`, + boxSizing: "border-box", + }); + function show(v) { + if (v === null || v === undefined) return ""; + if (typeof v === "object") return v.error; + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + return String(v); + } + function render() { + cellLayer.textContent = ""; + const sheet = ctx.sheet(); + for (let c = 0; c < COLS; c++) { + const h = el("div", { text: colName(c) }); + place(h, { x: HW + c * W, y: 0, w: W, h: HH }); + h.style.cssText += ";background:#f3f3f3;border:1px solid #ddd;text-align:center"; + cellLayer.append(h); + } + for (let r = 0; r < ROWS; r++) { + const h = el("div", { text: String(r + 1) }); + place(h, { x: 0, y: HH + r * H, w: HW, h: H }); + h.style.cssText += ";background:#f3f3f3;border:1px solid #ddd;text-align:center"; + cellLayer.append(h); + } + for (let r = 0; r < ROWS; r++) + for (let c = 0; c < COLS; c++) { + const m = mergeAt(r, c); + if (m && (m.r0 !== r || m.c0 !== c)) continue; + const d = el("div", { + className: "fg-cell", + attrs: { "data-a1": toA1(r, c) }, + text: show(ctx.engine.value(sheet.id, r, c)), + }); + place(d, cellBox(r, c)); + d.style.cssText += + ";border-right:1px solid #eee;border-bottom:1px solid #eee;padding:2px 3px;overflow:hidden;white-space:nowrap"; + if (typeof ctx.engine.value(sheet.id, r, c) === "number") d.style.textAlign = "right"; + cellLayer.append(d); + } + renderSelection(); + } + function renderSelection() { + overlay.textContent = ""; + const sel = ctx.selection; + for (const g of sel.범위) { + const b = el("div", { className: "fg-sel" }); + place(b, rangeBox(g)); + b.style.cssText += + ";border:2px solid #217346;background:rgba(33,115,70,.08);pointer-events:none"; + overlay.append(b); + } + const g = sel.범위[0]; + const rb = rangeBox(g); + const hd = el("div", { className: "fg-handle" }); + place(hd, { x: rb.x + rb.w - 4, y: rb.y + rb.h - 4, w: 7, h: 7 }); + hd.style.cssText += ";background:#217346;border:1px solid #fff;pointer-events:none"; + overlay.append(hd); + refs.forEach((x) => { + if (x.시트 !== ctx.selection.시트) return; + const b = el("div", { className: "fg-ref", attrs: { "data-ref": rangeToA1(x.범위) } }); + place(b, rangeBox(x.범위)); + const col = REF_COLORS[x.색번호 % REF_COLORS.length]; + b.style.cssText += `;border:2px solid ${col};pointer-events:none`; + overlay.append(b); + }); + } + function hitTest(cx, cy) { + const rect = content.getBoundingClientRect(); + const x = cx - rect.left; + const y = cy - rect.top; + const outer = root.getBoundingClientRect(); + if (cx < outer.left || cx > outer.right || cy < outer.top || cy > outer.bottom) return null; + const c = Math.floor((x - HW) / W); + const r = Math.floor((y - HH) / H); + const rb = rangeBox(ctx.selection.범위[0]); + if (Math.abs(x - (rb.x + rb.w)) <= 4 && Math.abs(y - (rb.y + rb.h)) <= 4) + return { + kind: "fillHandle", + r: Math.min(ctx.selection.범위[0].r1, ROWS - 1), + c: Math.min(ctx.selection.범위[0].c1, COLS - 1), + }; + if (x < HW && y < HH) return { kind: "corner", r: -1, c: -1 }; + if (y < HH) return { kind: "colHead", r: -1, c: Math.max(0, Math.min(COLS - 1, c)) }; + if (x < HW) return { kind: "rowHead", r: Math.max(0, Math.min(ROWS - 1, r)), c: -1 }; + return { + kind: "cell", + r: Math.max(0, Math.min(ROWS - 1, r)), + c: Math.max(0, Math.min(COLS - 1, c)), + }; + } + return { + root, + render, + invalidate: () => render(), + renderSelection, + setRefHighlights(list) { + refs = list; + renderSelection(); + }, + cellBox, + hitTest, + reveal(r, c) { + const b = cellBox(Math.min(r, ROWS - 1), Math.min(c, COLS - 1)); + if (b.y - HH < root.scrollTop) root.scrollTop = b.y - HH; + if (b.y + b.h > root.scrollTop + root.clientHeight) + root.scrollTop = b.y + b.h - root.clientHeight; + if (b.x - HW < root.scrollLeft) root.scrollLeft = b.x - HW; + if (b.x + b.w > root.scrollLeft + root.clientWidth) + root.scrollLeft = b.x + b.w - root.clientWidth; + }, + visibleRange() { + const r0 = Math.floor(root.scrollTop / H); + return { r0, r1: r0 + Math.floor(root.clientHeight / H) - 2, c0: 0, c1: COLS - 1 }; + }, + editorSlot: (r, c) => ({ layer: content, box: cellBox(r, c) }), + destroy: () => root.remove(), + }; +} +const grid = { createGrid }; + +// ── E 부품 (탭만 단추 · 나머지 빈 손잡이) ─────────────────────────────────── +const none = () => ({ root: null, refresh() {}, destroy() {} }); +function mountTabs(ctx) { + const root = el("div", { className: "fg-tabs" }); + root.style.cssText = "display:flex;gap:4px;padding:4px"; + function refresh() { + root.textContent = ""; + for (const s of ctx.book.시트) { + const b = el("button", { text: s.이름, attrs: { "data-sheet": s.id } }); + if (s.id === ctx.selection.시트) b.style.fontWeight = "bold"; + b.addEventListener("click", () => ctx.showSheet(s.id)); + root.append(b); + } + } + refresh(); + return { root, refresh, destroy: () => root.remove() }; +} + +module.exports = { + el: { el }, + address, + parser, + refshift, + fill, + commands, + history, + graph, + grid, + toolbar: { mountToolbar: none }, + tabs: { mountTabs }, + menu: { attachMenu: none }, + clipboard: { + attachClipboard: none, + parseClipboard: () => null, + blockToClipboard: () => ({ html: "", text: "" }), + }, +}; diff --git a/resources/tester/spreadsheet/spreadsheet_size.mjs b/resources/tester/spreadsheet/spreadsheet_size.mjs new file mode 100644 index 00000000..57eff220 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_size.mjs @@ -0,0 +1,56 @@ +// 스프레드시트 크기 재기 — vite 로 줄여 묶은 뒤 gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름. +// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}} +// 앱 번들에 이미 있는 `@ui/ui_template_elements*` 는 빼고 잼(스프레드시트가 새로 더하는 몫만). +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { gzipSync } from "node:zlib"; + +const root = path.resolve(process.argv[2] ?? "."); +const dir = path.join(root, "A00_Common", "spreadsheet"); +const { build } = await import( + pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href +); + +const ENGINE = [ + "spreadsheet_address", + "spreadsheet_parser", + "spreadsheet_eval", + "spreadsheet_graph", + "spreadsheet_refshift", + "spreadsheet_commands", + "spreadsheet_history", + "spreadsheet_recalc", + "spreadsheet_functions", + "spreadsheet_numfmt", + "spreadsheet_fill", +]; + +async function measure(entries) { + const out = await build({ + configFile: false, + logLevel: "silent", + root, + resolve: { alias: { "@ui": path.join(root, "ui_template") } }, + build: { + write: false, + minify: true, + target: "es2022", + cssCodeSplit: false, + lib: { entry: entries, formats: ["es"] }, + rollupOptions: { external: [/ui_template_elements/] }, + }, + }); + const outputs = (Array.isArray(out) ? out : [out]).flatMap((o) => o.output); + const size = { js: 0, css: 0 }; + for (const o of outputs) { + const body = o.type === "chunk" ? o.code : o.source; + const gz = gzipSync(typeof body === "string" ? body : Buffer.from(body)).length; + if (o.fileName.endsWith(".css")) size.css += gz; + else if (o.fileName.endsWith(".js")) size.js += gz; + } + return size; +} + +const engine = await measure(ENGINE.map((name) => path.join(dir, `${name}.ts`))); +const total = await measure([path.join(dir, "spreadsheet.ts")]); +console.log(JSON.stringify({ engine, total })); diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py new file mode 100644 index 00000000..c4a5f38d --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +"""스프레드시트 D 입력 — 고름 · 입력 글 규칙 · 참조 가리키기 자리 · 채우기 끌기 목표(엑셀 동작 기준). + +Node 도우미(helper_spreadsheet_d.cjs)가 TS 를 풀어 돌리고 JSON 을 줌 — 남의 몫이 빈 몸이면 가짜 부품. +화면 조작(한글 첫 글자 · 수식 입력줄 · 끌기)은 harness_spreadsheet_d.html 을 ORCA 로. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_spreadsheet_d.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_merge_selection(r): + assert r["mergeClick"] == "C4|C4:F4" + assert r["mergeMove"] == ["G4|G4", "C4|C4:F4", "B4|B4"] + assert r["colHead"] == "A:H" # 걸친 병합(A1:H1 · C3:F3 · A6:G6)만큼 늘림 + + +def test_shift_ctrl_arrows(r): + assert r["shiftExtend"] == ["A4|A4:B4", "A4|A4:F4", "A4|A4:B4", "A4|A4:B5"] + assert r["ctrlJump"] == ["A6|A6:G6", "A1048576|A1048576", "A6|A6:G6"] + assert r["hiddenSkip"] == ["K1"] + assert r["usedEnd"] == "J6" + + +def test_cycle_in_selection(r): + assert r["cycleEnter"] == ["K12", "L11", "L12", "K11"] + assert r["cycleTab"] == ["L11", "K12", "L12"] + assert r["cycleSingle"] is None + + +def test_text_to_cell(r): + got = r["textToCell"] + assert [c.get("값", c.get("식")) for c in got] == [ + 12, + 1234.5, + 0.5, + -1000, + True, + "=12", + "=", + "A1+1", + None, + "1,23", + "abc", + ] + assert "식" in got[7] and all(c["서식"] == 4 for c in got) + assert r["textToCellNoStyle"] is None + + +def test_cell_to_text(r): + assert r["cellToText"] == ["'=12", "'12", "abc", "1.5", "FALSE", "=A1*2", ""] + + +def test_pointing(r): + assert r["insertable"] == [True, True, False, True, True, True, False, False] + assert r["highlights"] == ["s1:A1:0", "s1:B2:C3:1", "s1:A1:0", "s2:B1:2"] + + +def test_fill_target(r): + assert r["fill"] == [ + ["B2:C10", None], + ["B2:G4", None], + ["B2:B4", "C2:C4"], + ["B2:C3", "B4:C4"], + ] diff --git a/resources/tester/spreadsheet/test_spreadsheet_engine.py b/resources/tester/spreadsheet/test_spreadsheet_engine.py new file mode 100644 index 00000000..b6e92882 --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_engine.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +"""산출근거 스프레드시트 엔진 A — 주소 · 파서 · 우선순위 · 오류 전파 · 순환 · 딸린 칸 재계산 · +참조 옮김 엑셀 규칙표 · 명령 되돌림 짝 · 되돌리기 스택. + +Node 헬퍼(`helper_spreadsheet_engine.cjs`)가 TS 를 그 자리에서 트랜스파일해 돌리고 +묶음마다 틀린 것 목록을 냄 — 여기서는 묶음마다 빈 목록인지 봄. 규칙표는 헬퍼 안에 있음. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +GROUPS = ["address", "parser", "precedence", "errors", "cycle", "graph", "refshift", "commands", "history"] + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_spreadsheet_engine.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("group", GROUPS) +def test_묶음(results, group): + assert group in results + assert results[group] == [] diff --git a/resources/tester/spreadsheet/test_spreadsheet_golden.py b/resources/tester/spreadsheet/test_spreadsheet_golden.py new file mode 100644 index 00000000..b5d2972e --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_golden.py @@ -0,0 +1,129 @@ +"""F — 금빛 시험: 실무 구조도 xlsx 세 벌의 수식을 새 엔진으로 풀어 엑셀 캐시값과 대조. + +xlsx 는 **시험에서만** openpyxl 로 읽음(앱에는 가져오기 없음). 식 = 엑셀 글 그대로 · 값 = 칸 값 · +바깥 통합문서 참조(`[172]관집계!…`)는 식을 못 푸니 캐시값을 값으로 굳힘(대조에서 뺌). +갈린 칸은 조용히 맞추지 않음 — 목록을 `tmp/spreadsheet_golden_report.json` 에 적고 시험을 떨굼. +엔진(A · B)이 빈 몸이면 건너뜀 — 엔진이 차는 대로 다시 돌림. +""" + +import datetime as dt +import json +import re +import shutil +from decimal import Decimal, InvalidOperation +from pathlib import Path + +import pytest + +openpyxl = pytest.importorskip("openpyxl") +from openpyxl.utils.datetime import to_excel # noqa: E402 + +from common_util import common_util_spreadsheet as spreadsheet # noqa: E402 + +ROOT = Path(__file__).resolve().parents[3] +PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" +NAMES = ("07-구조도-소광리.xlsx", "5. 구조도(기번3).xlsx", "구조도(변경).xlsx") +FILES = [next(PRACTICE.rglob(name)) for name in NAMES] +REPORT = ROOT / "tmp" / "spreadsheet_golden_report.json" +EXPECTED_FORMULAS = 2699 +_EXTERNAL = re.compile(r"\[\d+\]") +_ERRORS = {"#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A"} + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def _plain(value): + """칸 값 → JSON(날짜 · 시각 형식 칸은 엑셀 일련수).""" + if isinstance(value, (dt.datetime, dt.date, dt.time, dt.timedelta)): + return to_excel(value) + return value + + +def _book(path: Path): + """xlsx → 통합문서 + 대조할 칸 `[(시트 id, A1, 캐시값)]`.""" + formulas = openpyxl.load_workbook(path, data_only=False) + cached = openpyxl.load_workbook(path, data_only=True) + sheets, checks = [], [] + for i, ws in enumerate(formulas.worksheets, start=1): + sid, cells = f"s{i}", {} + values = cached[ws.title] + for row in ws.iter_rows(): + for c in row: + if c.value is None: + continue + keep = _plain(values[c.coordinate].value) + if c.data_type == "f" and isinstance(c.value, str): + if _EXTERNAL.search(c.value): + if keep is not None: + cells[c.coordinate] = {"값": keep} + continue + cells[c.coordinate] = {"식": c.value[1:]} + checks.append((sid, c.coordinate, keep)) + elif c.data_type != "f": + cells[c.coordinate] = {"값": _plain(c.value)} + sheets.append({"id": sid, "이름": ws.title, "칸": cells}) + book = {"종류": "통합문서", "판": 1, "열": path.stem, "서식": [{}], "시트": sheets} + return book, checks + + +def _same(want, got) -> bool: + if got is None: + return False + if isinstance(want, bool): + return got.get("참") is want + if isinstance(want, (int, float)): + try: + a, b = Decimal(got["수"]), Decimal(repr(float(want))) + except (KeyError, InvalidOperation): + return False + return abs(a - b) <= Decimal("1e-9") * max(Decimal(1), abs(b)) + if want is None or want == "": + return got.get("글") == "" or got.get("수") == "0" + if isinstance(want, str) and want in _ERRORS: + return got.get("오류") == want + return got.get("글") == want + + +def test_실무_구조도_수식_엑셀과_같음(): + loaded = [(path, *_book(path)) for path in FILES] + total = sum(len(checks) for _p, _b, checks in loaded) + external = 6 # 소광리 `[172]관집계` — 값으로 굳혀 대조에서 뺌 + assert total + external == EXPECTED_FORMULAS + try: + results = spreadsheet.recalc_workbooks([book for _p, book, _c in loaded]) + except spreadsheet.RecalcError as error: + if "아직 없음" in str(error): + pytest.skip(f"엔진 아직 — {error}") + raise + mismatches = [] + for (path, book, checks), values in zip(loaded, results, strict=True): + names = {s["id"]: s["이름"] for s in book["시트"]} + for sid, a1, want in checks: + got = values.get(sid, {}).get(a1) + if not _same(want, got): + formula = next(s for s in book["시트"] if s["id"] == sid)["칸"][a1]["식"] + mismatches.append( + { + "파일": path.name, + "시트": names[sid], + "칸": a1, + "식": formula, + "엑셀": want, + "엔진": got, + } + ) + REPORT.parent.mkdir(exist_ok=True) + REPORT.write_text( + json.dumps( + {"대조": total, "갈림": len(mismatches), "목록": mismatches}, + ensure_ascii=False, + indent=1, + default=str, + ), + encoding="utf-8", + ) + head = "\n".join( + f"{m['파일']} {m['시트']}!{m['칸']} ={m['식']} 엑셀 {m['엑셀']!r} · 엔진 {m['엔진']}" + for m in mismatches[:30] + ) + assert not mismatches, f"{len(mismatches)}/{total} 칸 갈림 — 전체 {REPORT}\n{head}" diff --git a/resources/tester/spreadsheet/test_spreadsheet_node.py b/resources/tester/spreadsheet/test_spreadsheet_node.py new file mode 100644 index 00000000..57a1b346 --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_node.py @@ -0,0 +1,37 @@ +"""F — 서버 재계산 다리: 파이썬 → Node 번들(`build:spreadsheet`) → 화면과 같은 TS 엔진. + +엔진(A · B)이 아직 빈 몸이면 `아직 없음` 까닭이 그대로 올라와 저장을 막는지 봄 · +엔진이 차면 저장 견본(`spreadsheet_sample.json`)의 `계산값` 과 서버 풀이가 같은지 봄. +""" + +import json +import shutil +from pathlib import Path + +import pytest + +from common_util import common_util_spreadsheet as spreadsheet + +ROOT = Path(__file__).resolve().parents[3] +SAMPLE = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_sample.json" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def test_견본_계산값_같음(): + sample = json.loads(SAMPLE.read_text(encoding="utf-8")) + try: + saved = spreadsheet.with_server_values({**sample, "계산값": {"s1": {"H4": {"수": "틀림"}}}}) + except spreadsheet.RecalcError as error: + assert "아직 없음" in str(error), error # 엔진이 빈 몸일 때만 봐줌 + pytest.skip(f"엔진 아직 — {error}") + assert saved["계산값"] == sample["계산값"] + assert {k: v for k, v in saved.items() if k != "계산값"} == { + k: v for k, v in sample.items() if k != "계산값" + } + + +def test_못_푸는_문서는_까닭과_함께_막음(): + with pytest.raises(spreadsheet.RecalcError) as caught: + spreadsheet.recalc_workbooks([{"종류": "통합문서", "시트": "깨짐"}]) + assert "스프레드시트 계산 실패" in str(caught.value) diff --git a/resources/tester/spreadsheet/test_spreadsheet_size.py b/resources/tester/spreadsheet/test_spreadsheet_size.py new file mode 100644 index 00000000..4b4d2580 --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_size.py @@ -0,0 +1,74 @@ +"""F — 스프레드시트 크기 예산 · M02 만 늦게 불러옴(계약 `spreadsheet_types.ts` 머리). + +예산(압축 gzip 뒤) — 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB. +엔진은 파일마다 따로 묶어 더하므로 실제보다 조금 크게 잼(넘치면 넘친 것). +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = Path(__file__).with_name("spreadsheet_size.mjs") +KB = 1024 +BUDGET = {"engine": 22 * KB, "view": 28 * KB, "css": 4 * KB, "total": 54 * KB} + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def test_크기_예산(): + out = subprocess.run( + ["node", str(SCRIPT), str(ROOT)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + check=True, + ) + size = json.loads(out.stdout.strip().splitlines()[-1]) + engine, total = size["engine"]["js"], size["total"] + view = max(0, total["js"] - engine) + print( + f"gzip — 엔진 {engine} · 화면 {view} · css {total['css']} · 합 {total['js'] + total['css']}" + ) + assert engine <= BUDGET["engine"], f"엔진 {engine} B > {BUDGET['engine']}" + assert view <= BUDGET["view"], f"화면 {view} B > {BUDGET['view']}" + assert total["css"] <= BUDGET["css"], f"css {total['css']} B > {BUDGET['css']}" + assert total["js"] + total["css"] <= BUDGET["total"] + + +# 정적 import 금지 — 타입만(`import type`) · 늦게(`import(`) 는 됨 +_STATIC = re.compile( + r"^\s*import\s+(?!type\b)[^;]*?from\s+[\"'][^\"']*spreadsheet/spreadsheet", re.M +) +_SIDE = re.compile(r"^\s*import\s+[\"'][^\"']*spreadsheet/spreadsheet", re.M) + + +def test_M02_만_늦게_불러옴(): + offenders, users = [], [] + listed = subprocess.run( + ["git", "ls-files", "--cached", "--others", "--exclude-standard", "*.ts"], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ).stdout.splitlines() + for rel in listed: + if rel.startswith("A00_Common/spreadsheet/") or not (ROOT / rel).is_file(): + continue + text = (ROOT / rel).read_text(encoding="utf-8", errors="replace") + if "spreadsheet/spreadsheet" not in text: + continue + if _STATIC.search(text) or _SIDE.search(text): + offenders.append(rel) + if "import(" in text: + users.append(rel) + # 서버 Node 진입만 정적(브라우저 번들 밖) + assert offenders == ["common_util/common_util_spreadsheet_node.ts"], offenders + assert users and all(u.startswith("M02_MasterTemplete/") for u in users), users diff --git a/resources/tester/spreadsheet/test_spreadsheet_store.py b/resources/tester/spreadsheet/test_spreadsheet_store.py new file mode 100644 index 00000000..4e690eff --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_store.py @@ -0,0 +1,138 @@ +"""F — 산출근거 저장(basis 종류) · 옛 산출근거 옮김 · [저장] 때 서버 계산값 · 임시 시험 주소. + +서버 Node 풀이는 `test_spreadsheet_node.py` 가 따로 봄 — 여기는 `with_server_values` 를 바꿔 끼워 +「브라우저 값은 버리고 서버 값만 적는다」 · 「못 풀면 저장을 막는다」 만 잼. +""" + +import json +import shutil + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util import common_util_spreadsheet as spreadsheet +from M02_MasterTemplete import M02_MasterTemplete_Router as router_module +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers + +REAL = store.FOLDER +SERVER = {"s1": {"A1": {"수": "42"}}} + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + for kind in store.KINDS: + (tmp_path / kind).mkdir() + shutil.copy(REAL / "table/구조물집계표.json", tmp_path / "table") + monkeypatch.setattr(store, "FOLDER", tmp_path) + monkeypatch.setattr(router_module, "TRIAL_PATH", tmp_path / "trial.json") + + def fake(book): + clean = {k: v for k, v in book.items() if k != "계산값"} + return {**clean, "계산값": SERVER} + + monkeypatch.setattr(spreadsheet, "with_server_values", fake) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _old_structure(folder, column, basis): + doc = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도번": "구-01"} + doc.update({"도면": {"format": 6, "entities": []}, "산출근거": basis}) + (folder / "structure" / f"{column}.json").write_text( + json.dumps(doc, ensure_ascii=False), encoding="utf-8" + ) + + +def test_옛_산출근거를_따로_파일로_옮김(client, tmp_path): + old = { + "열": [ + {"id": "work", "머리": ["공종"]}, + {"id": "detail", "머리": ["산출", "내역"]}, + {"id": "qty", "머리": ["수량(m당)"]}, + ], + "줄": [{"id": "1", "값": {"work": "터파기", "qty": 1.5}}, {"id": "2", "값": {}}], + "보기": {"열너비": {"detail": 210}}, + } + _old_structure(tmp_path, "pp_len", old) + done = store.migrate_basis() + assert done["옮김"] == ["pp_len"] + columns = store._column_ids() + assert set(done["새로"]) == set(columns) - {"pp_len"} + structure = json.loads((tmp_path / "structure/pp_len.json").read_text(encoding="utf-8")) + assert "산출근거" not in structure and structure["도번"] == "구-01" + basis = store.read("basis", "pp_len")["문서"] + sheet = basis["시트"][0] + assert (basis["종류"], basis["열"], sheet["id"]) == ("통합문서", "pp_len", "s1") + assert sheet["칸"] == { + "A1": {"값": "공종", "서식": 1}, + "B1": {"값": "산출 내역", "서식": 1}, + "C1": {"값": "수량(m당)", "서식": 1}, + "A2": {"값": "터파기"}, + "C2": {"값": 1.5}, + } + assert sheet["열"] == {"B": {"폭": 30.0}} + assert "계산값" not in basis # 식 없는 새 문서 — 첫 [저장] 때 서버가 적음 + # 두 번 돌려도 같음 + before = {p.name: p.read_bytes() for p in (tmp_path / "basis").iterdir()} + assert store.migrate_basis() == {"옮김": [], "새로": []} + assert before == {p.name: p.read_bytes() for p in (tmp_path / "basis").iterdir()} + + +def test_새_구조물_도면은_산출근거_따로(client): + made = client.post("/api/m02/structures/rv").json()["문서"] + assert "산출근거" not in made + basis = client.get("/api/m02/templates/basis/rv").json()["문서"] + heads = [basis["시트"][0]["칸"][f"{c}1"]["값"] for c in "ABCDE"] + assert heads == ["공종", "규격", "산출 내역", "단위", "수량(m당)"] + + +def test_저장은_서버_계산값만_적음(client): + store.ensure_basis("rv") + got = client.get("/api/m02/templates/basis/rv").json() + doc = {**got["문서"], "계산값": {"s1": {"A1": {"수": "브라우저"}}}} + saved = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": doc}) + assert saved.status_code == 200 + assert client.get("/api/m02/templates/basis/rv").json()["문서"]["계산값"] == SERVER + # 뼈대 없는 문서 · 판이 다르면 막음 + bad = client.put("/api/m02/templates/basis/rv", json={"판": saved.json()["판"], "문서": {}}) + assert bad.status_code == 400 + stale = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": doc}) + assert stale.status_code == 409 + + +def test_못_풀면_저장을_막음(client, monkeypatch): + store.ensure_basis("rv") + got = client.get("/api/m02/templates/basis/rv").json() + + def broken(_book): + raise spreadsheet.RecalcError("스프레드시트 계산 실패 — 시험") + + monkeypatch.setattr(spreadsheet, "with_server_values", broken) + r = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": got["문서"]}) + assert r.status_code == 503 + assert client.get("/api/m02/templates/basis/rv").json()["판"] == got["판"] + + +def test_층_규칙과_프로젝트_복사(client, tmp_path, monkeypatch): + store.ensure_basis("rv") + doc = store.read("basis", "rv")["문서"] + layers.check_skeleton("basis", doc) + for bad in ({}, {**doc, "시트": {}}): + with pytest.raises(ValueError): + layers.check_skeleton("basis", bad) + layers.check_skeleton("structure", {"도면": {"entities": []}}) # 산출근거 없어도 됨 + monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path) + copied = layers.seed_project(tmp_path / "project") + assert "basis/rv" in copied["작업본"] and "basis/rv" in copied["초기"] + + +def test_임시_시험_주소(client): + first = client.get("/api/m02/spreadsheet-trial").json()["문서"] + assert [s["이름"] for s in first["시트"]] == ["시트1", "시트2", "시트3"] + first["시트"][0]["칸"] = {"A1": {"식": "1+41"}} + saved = client.put("/api/m02/spreadsheet-trial", json={"문서": first}).json()["문서"] + assert saved["계산값"] == SERVER + assert client.get("/api/m02/spreadsheet-trial").json()["문서"] == saved 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 시험 끝"); diff --git a/resources/tester/test_m02_structure.py b/resources/tester/test_m02_structure.py index 9876b26b..9fda2eba 100644 --- a/resources/tester/test_m02_structure.py +++ b/resources/tester/test_m02_structure.py @@ -1,4 +1,5 @@ -"""M02 구조물 도면(structure) — 새로 · 도번 자동 · 409 · 저장 · 지운 뒤 번호 · 프로젝트 복사.""" +"""M02 구조물 도면(structure) — 새로 · 도번 자동 · 409 · 저장 · 지운 뒤 번호 · 프로젝트 복사. +산출근거는 따로 파일(basis) — `resources/tester/spreadsheet/test_spreadsheet_store.py`.""" import shutil @@ -36,11 +37,9 @@ def test_새로_도번_자동_409_없는_열(client): ) # 도각 없는 전용 화면 — 그린 만큼이 설계 영역 assert doc["도면"] == {"format": 6, "entities": []} - basis = doc["산출근거"] - assert [c["머리"][0] for c in basis["열"]] == ["공종", "규격", "산출 내역", "단위", "수량(m당)"] - # 빈 줄 몇 개를 미리 둠 — 줄 더하기 안 눌러도 바로 입력 - assert basis["줄"] == [{"id": "1", "값": {}}, {"id": "2", "값": {}}, {"id": "3", "값": {}}] - assert basis["종류"] == "표" + # 산출근거는 구조물 문서 밖 — 같은 열 id 로 따로 한 통합문서 + assert "산출근거" not in doc + assert client.get("/api/m02/templates/basis/pp_len").json()["문서"]["종류"] == "통합문서" assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "구-02" assert client.post("/api/m02/structures/pp_len").status_code == 409 @@ -56,10 +55,10 @@ def test_새로_도번_자동_409_없는_열(client): def test_저장_뼈대_거름(client): got = client.post("/api/m02/structures/pp_len").json() doc = got["문서"] - doc["산출근거"]["줄"] = [{"id": "r1", "값": {"work": "관 부설"}}] + doc["도면"]["entities"] = [{"type": "line"}] saved = client.put("/api/m02/templates/structure/pp_len", json={"판": got["판"], "문서": doc}) assert saved.status_code == 200 and saved.json()["판"] != got["판"] - for bad in ({}, {**doc, "도면": {}}, {**doc, "산출근거": {"열": "x"}}, {**doc, "도면": []}): + for bad in ({}, {**doc, "도면": {}}, {**doc, "도면": {"entities": "x"}}, {**doc, "도면": []}): r = client.put( "/api/m02/templates/structure/pp_len", json={"판": saved.json()["판"], "문서": bad} ) @@ -90,4 +89,4 @@ def test_프로젝트_복사와_층_저장에_실림(client, tmp_path, monkeypat assert ("structure", "pp_len") in {(r["종류"], r["이름"]) for r in layers.list_templates(work)} layers.check_skeleton("structure", doc) with pytest.raises(ValueError): - layers.check_skeleton("structure", {**doc, "산출근거": None}) + layers.check_skeleton("structure", {**doc, "도면": None}) diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index e18cd0a5..73be180e 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -46,11 +46,11 @@ def test_소스가_700줄을_넘지_않음(): ["createStructure", "fetchStructureNumbers", "readTemplate", "saveTemplate"], ), ("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"]), - ("../ui_template/sheet/ui_template_sheet.ts", ["createSheet"]), + ("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"]), ], ) def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: list[str]): - """sub2·sub3 가 이름을 바꾸면 여기서 먼저 걸림.""" + """도면 부품 · 스프레드시트(D) 가 이름을 바꾸면 여기서 먼저 걸림.""" body = _text(M02 / source) panel = _text(PANEL) for name in names: diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 397138cd..d9c8a3c0 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -63,6 +63,8 @@ export interface CadLoadExtra { hostTitle?: string; /** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡긴다 — 켜면 `onHostSave` 로 옴. */ hostSave?: boolean; + /** 리본을 접힌 채로 연다 — 안 보내면 펼침 그대로. */ + ribbonCollapsed?: boolean; } export interface CadHost {