From cd824aca251d1da129a225a8780f8f61f2dec0a8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:12:39 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(spreadsheet):=20F=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=84=EC=82=B0=20=C2=B7=20=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=20=EB=94=B0=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=C2=B7=20M02=20=EB=81=BC=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EA=B8=88?= =?UTF-8?q?=EB=B9=9B=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common_util_spreadsheet_node.ts · common_util_spreadsheet.py · build:spreadsheet — [저장] 때 화면과 같은 TS 엔진을 Node 로 돌려 계산값 정본(브라우저 값 버림 · 못 풀면 503) - M02 새 종류 basis — resources/master_template/basis/<열 id>.json(구조물집계표 열마다) · 층 복사 · 뼈대 검사 · 옛 구조물 문서 산출근거 49 벌 옮김(migrate_basis) · 새 열도 같이 만듦 - 상세 산출근거 카드 = 새 스프레드시트(import() 로 늦게 받음) · basis 읽기 · 저장 - ⚠ 임시 [스프레드시트 시험] 단추 — 빈 통합문서(시트 셋) · 저장 자리 tmp/spreadsheet_trial.json · 1단계 검증 뒤 지움 - 시험 resources/tester/spreadsheet/ — 저장 · 서버 다리 · 크기 예산 · M02 만 늦게 불러옴 · 실무 구조도 xlsx 세 벌 수식 2,699 칸 엑셀 캐시값 대조(엔진 빈 몸이면 건너뜀) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp --- .gitignore | 2 + .../M02_MasterTemplete_Api_Fetch.ts | 15 +- .../M02_MasterTemplete_Router.py | 35 +++++ .../M02_MasterTemplete_Router_Layers.py | 7 + .../M02_MasterTemplete_Spreadsheet_Trial.css | 25 ++++ .../M02_MasterTemplete_Spreadsheet_Trial.ts | 89 +++++++++++ .../M02_MasterTemplete_Store.py | 133 ++++++++++++++--- .../M02_MasterTemplete_Structure.ts | 86 ++++++----- .../M02_MasterTemplete_UI_Main.ts | 19 ++- M02_MasterTemplete/M02_Template_Layers.py | 16 +- common_util/common_util_spreadsheet.py | 56 +++++++ common_util/common_util_spreadsheet_node.ts | 33 +++++ package.json | 3 +- resources/master_template/basis/be.json | 56 +++++++ resources/master_template/basis/bg.json | 56 +++++++ resources/master_template/basis/bm_area.json | 56 +++++++ resources/master_template/basis/bm_len.json | 56 +++++++ resources/master_template/basis/bx_len.json | 56 +++++++ resources/master_template/basis/bx_wing.json | 56 +++++++ resources/master_template/basis/cs.json | 56 +++++++ resources/master_template/basis/ec.json | 56 +++++++ resources/master_template/basis/etc.json | 56 +++++++ resources/master_template/basis/fb.json | 56 +++++++ resources/master_template/basis/fp_l.json | 56 +++++++ resources/master_template/basis/fp_w.json | 56 +++++++ resources/master_template/basis/gr_curb.json | 56 +++++++ resources/master_template/basis/gr_rail.json | 56 +++++++ resources/master_template/basis/gr_sign.json | 56 +++++++ resources/master_template/basis/h_break.json | 56 +++++++ .../master_template/basis/h_ford_basin.json | 56 +++++++ resources/master_template/basis/h_intake.json | 56 +++++++ .../master_template/basis/h_pg_rail.json | 56 +++++++ .../master_template/basis/h_pv_rail.json | 56 +++++++ resources/master_template/basis/h_rip_c.json | 56 +++++++ resources/master_template/basis/h_rip_m.json | 56 +++++++ .../master_template/basis/h_rockfall.json | 56 +++++++ .../master_template/basis/h_soil_ditch.json | 56 +++++++ .../master_template/basis/h_stone_bed.json | 56 +++++++ .../master_template/basis/h_stone_ch.json | 56 +++++++ resources/master_template/basis/h_waste.json | 56 +++++++ resources/master_template/basis/memo.json | 56 +++++++ resources/master_template/basis/mr.json | 56 +++++++ resources/master_template/basis/ms.json | 56 +++++++ resources/master_template/basis/no.json | 56 +++++++ resources/master_template/basis/od_cnt.json | 56 +++++++ resources/master_template/basis/od_len.json | 56 +++++++ resources/master_template/basis/pg_in.json | 56 +++++++ resources/master_template/basis/pp_cp.json | 56 +++++++ resources/master_template/basis/pp_len.json | 56 +++++++ resources/master_template/basis/ps.json | 56 +++++++ resources/master_template/basis/pv_a.json | 56 +++++++ resources/master_template/basis/pv_b.json | 56 +++++++ resources/master_template/basis/pv_jt.json | 56 +++++++ resources/master_template/basis/pv_l.json | 56 +++++++ resources/master_template/basis/pv_w.json | 56 +++++++ resources/master_template/basis/rf.json | 56 +++++++ resources/master_template/basis/rv.json | 56 +++++++ resources/master_template/basis/rw.json | 56 +++++++ resources/master_template/basis/sg.json | 56 +++++++ resources/master_template/basis/sta.json | 56 +++++++ resources/master_template/basis/ta.json | 56 +++++++ resources/master_template/basis/wy.json | 56 +++++++ resources/master_template/structure/be.json | 61 -------- resources/master_template/structure/bg.json | 61 -------- .../master_template/structure/bm_area.json | 61 -------- .../master_template/structure/bm_len.json | 61 -------- .../master_template/structure/bx_len.json | 61 -------- .../master_template/structure/bx_wing.json | 61 -------- resources/master_template/structure/cs.json | 61 -------- resources/master_template/structure/ec.json | 61 -------- resources/master_template/structure/etc.json | 61 -------- resources/master_template/structure/fb.json | 61 -------- resources/master_template/structure/fp_l.json | 61 -------- resources/master_template/structure/fp_w.json | 61 -------- .../master_template/structure/gr_curb.json | 61 -------- .../master_template/structure/gr_rail.json | 61 -------- .../master_template/structure/gr_sign.json | 61 -------- .../master_template/structure/h_break.json | 61 -------- .../structure/h_ford_basin.json | 61 -------- .../master_template/structure/h_intake.json | 61 -------- .../master_template/structure/h_pg_rail.json | 61 -------- .../master_template/structure/h_pv_rail.json | 61 -------- .../master_template/structure/h_rip_c.json | 61 -------- .../master_template/structure/h_rip_m.json | 61 -------- .../master_template/structure/h_rockfall.json | 61 -------- .../structure/h_soil_ditch.json | 61 -------- .../structure/h_stone_bed.json | 61 -------- .../master_template/structure/h_stone_ch.json | 61 -------- .../master_template/structure/h_waste.json | 61 -------- resources/master_template/structure/memo.json | 61 -------- resources/master_template/structure/mr.json | 61 -------- resources/master_template/structure/ms.json | 61 -------- resources/master_template/structure/no.json | 61 -------- .../master_template/structure/od_cnt.json | 61 -------- .../master_template/structure/od_len.json | 61 -------- .../master_template/structure/pg_in.json | 61 -------- .../master_template/structure/pp_cp.json | 61 -------- .../master_template/structure/pp_len.json | 61 -------- resources/master_template/structure/ps.json | 61 -------- resources/master_template/structure/pv_a.json | 61 -------- resources/master_template/structure/pv_b.json | 61 -------- .../master_template/structure/pv_jt.json | 61 -------- resources/master_template/structure/pv_l.json | 61 -------- resources/master_template/structure/pv_w.json | 61 -------- resources/master_template/structure/rf.json | 61 -------- resources/master_template/structure/rv.json | 61 -------- resources/master_template/structure/rw.json | 61 -------- resources/master_template/structure/sg.json | 61 -------- resources/master_template/structure/sta.json | 61 -------- resources/master_template/structure/ta.json | 61 -------- resources/master_template/structure/wy.json | 61 -------- .../tester/spreadsheet/spreadsheet_size.mjs | 56 +++++++ .../spreadsheet/test_spreadsheet_golden.py | 129 ++++++++++++++++ .../spreadsheet/test_spreadsheet_node.py | 37 +++++ .../spreadsheet/test_spreadsheet_size.py | 74 ++++++++++ .../spreadsheet/test_spreadsheet_store.py | 138 ++++++++++++++++++ resources/tester/test_m02_structure.py | 17 +-- resources/tester/test_m02_structure_panel.py | 4 +- 118 files changed, 3642 insertions(+), 3065 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.ts create mode 100644 common_util/common_util_spreadsheet.py create mode 100644 common_util/common_util_spreadsheet_node.ts create mode 100644 resources/master_template/basis/be.json create mode 100644 resources/master_template/basis/bg.json create mode 100644 resources/master_template/basis/bm_area.json create mode 100644 resources/master_template/basis/bm_len.json create mode 100644 resources/master_template/basis/bx_len.json create mode 100644 resources/master_template/basis/bx_wing.json create mode 100644 resources/master_template/basis/cs.json create mode 100644 resources/master_template/basis/ec.json create mode 100644 resources/master_template/basis/etc.json create mode 100644 resources/master_template/basis/fb.json create mode 100644 resources/master_template/basis/fp_l.json create mode 100644 resources/master_template/basis/fp_w.json create mode 100644 resources/master_template/basis/gr_curb.json create mode 100644 resources/master_template/basis/gr_rail.json create mode 100644 resources/master_template/basis/gr_sign.json create mode 100644 resources/master_template/basis/h_break.json create mode 100644 resources/master_template/basis/h_ford_basin.json create mode 100644 resources/master_template/basis/h_intake.json create mode 100644 resources/master_template/basis/h_pg_rail.json create mode 100644 resources/master_template/basis/h_pv_rail.json create mode 100644 resources/master_template/basis/h_rip_c.json create mode 100644 resources/master_template/basis/h_rip_m.json create mode 100644 resources/master_template/basis/h_rockfall.json create mode 100644 resources/master_template/basis/h_soil_ditch.json create mode 100644 resources/master_template/basis/h_stone_bed.json create mode 100644 resources/master_template/basis/h_stone_ch.json create mode 100644 resources/master_template/basis/h_waste.json create mode 100644 resources/master_template/basis/memo.json create mode 100644 resources/master_template/basis/mr.json create mode 100644 resources/master_template/basis/ms.json create mode 100644 resources/master_template/basis/no.json create mode 100644 resources/master_template/basis/od_cnt.json create mode 100644 resources/master_template/basis/od_len.json create mode 100644 resources/master_template/basis/pg_in.json create mode 100644 resources/master_template/basis/pp_cp.json create mode 100644 resources/master_template/basis/pp_len.json create mode 100644 resources/master_template/basis/ps.json create mode 100644 resources/master_template/basis/pv_a.json create mode 100644 resources/master_template/basis/pv_b.json create mode 100644 resources/master_template/basis/pv_jt.json create mode 100644 resources/master_template/basis/pv_l.json create mode 100644 resources/master_template/basis/pv_w.json create mode 100644 resources/master_template/basis/rf.json create mode 100644 resources/master_template/basis/rv.json create mode 100644 resources/master_template/basis/rw.json create mode 100644 resources/master_template/basis/sg.json create mode 100644 resources/master_template/basis/sta.json create mode 100644 resources/master_template/basis/ta.json create mode 100644 resources/master_template/basis/wy.json create mode 100644 resources/tester/spreadsheet/spreadsheet_size.mjs create mode 100644 resources/tester/spreadsheet/test_spreadsheet_golden.py create mode 100644 resources/tester/spreadsheet/test_spreadsheet_node.py create mode 100644 resources/tester/spreadsheet/test_spreadsheet_size.py create mode 100644 resources/tester/spreadsheet/test_spreadsheet_store.py 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/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_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..f6d2ac29 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; @@ -145,19 +151,34 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane 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 +187,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 +204,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 +236,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/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_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/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: From 736dac4eb5a477875c380f8e69fcf1ca207be1e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:14:31 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(spreadsheet):=20=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=20=EC=8A=A4=ED=94=84=EB=A0=88=EB=93=9C?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20A=20=EC=97=94=EC=A7=84=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=E2=80=94=20=EC=A3=BC=EC=86=8C=20=C2=B7=20=ED=8C=8C?= =?UTF-8?q?=EC=84=9C=20=C2=B7=20=ED=92=80=EC=9D=B4=20=C2=B7=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=20=EC=9E=AC=EA=B3=84=EC=82=B0=20=C2=B7=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EC=98=AE=EA=B9=80=20=C2=B7=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - address · parser — A1 ↔ 행열 · 낱말(편집기 색칠용 자리) · 엑셀 우선순위 나무(-2^2=4 · % · & · 비교) · 나무 → 글 - eval — 분수 풀이 · 오류 값 전파(왼쪽 먼저) · 형 바꾸기(빈 칸 · 수 글 · 참거짓) · 일반 형식 글 15 자리 · 무리수 15 자리 - graph — 의존 그래프 · 고친 칸에 딸린 칸만 재계산 · 순환 = 고리 칸마다 #CYCLE! + 까닭 - refshift — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 · 시트 지우기 참조 옮김(지운 칸 #REF!) · 복사 상대 옮김 · 구글 R1C1 → A1 - commands · history · recalc — 명령마다 되돌림 짝 · 병합 규칙 · 서식 한 벌 표 · 묶음 원자 · Node 한 벌 풀이 - 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py — 엑셀 규칙표 9 묶음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- A00_Common/spreadsheet/spreadsheet_address.ts | 106 ++- .../spreadsheet/spreadsheet_commands.ts | 611 +++++++++++++++- A00_Common/spreadsheet/spreadsheet_eval.ts | 271 ++++++- A00_Common/spreadsheet/spreadsheet_graph.ts | 315 ++++++++- A00_Common/spreadsheet/spreadsheet_history.ts | 38 +- A00_Common/spreadsheet/spreadsheet_parser.ts | 310 +++++++- A00_Common/spreadsheet/spreadsheet_recalc.ts | 10 +- .../spreadsheet/spreadsheet_refshift.ts | 180 ++++- .../spreadsheet/helper_spreadsheet_engine.cjs | 665 ++++++++++++++++++ .../spreadsheet/test_spreadsheet_engine.py | 35 + 10 files changed, 2430 insertions(+), 111 deletions(-) create mode 100644 resources/tester/spreadsheet/helper_spreadsheet_engine.cjs create mode 100644 resources/tester/spreadsheet/test_spreadsheet_engine.py 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_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_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_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_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_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/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/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] == [] From d38ef573e02d66770bc3251209c4e928a9684708 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:20:16 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(spreadsheet):=202=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EB=8B=A4=EC=84=AF=20=E2=80=94=20=EC=B0=BE=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=20=EA=B3=A8=EB=9D=BC?= =?UTF-8?q?=EC=84=9C=20=C2=B7=20=EC=84=9C=EC=8B=9D=20=EB=B6=93=20=C2=B7=20?= =?UTF-8?q?=ED=8F=AD=20=EC=9E=90=EB=8F=99=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EB=8F=84=EC=9B=80=EB=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 11-8 스프레드시트 2단계. 0 계약(spreadsheet_types.ts · spreadsheet_view_types.ts) 타입만 보고 새 파일 다섯 짬 — 잇기는 1단계 끝에 D(sub7)가 파일 머리 주석대로: - spreadsheet_find.ts 찾기 · 바꾸기(대소문자 · 전체칸 · 수식 안) - spreadsheet_paste_special.ts 붙여넣기 골라서(값만 · 서식만 · 수식만) - spreadsheet_format_painter.ts 서식 붓(칸→행→열→기본 차례로 서식을 찾음) - spreadsheet_autofit.ts 열 머리 두 번 눌러 폭 자동 맞춤(어림 재기 · 실제 재기 자리 열어 둠) - spreadsheet_func_help.ts 함수 자동 완성 · 인자 도움말(1단계 함수 48 개) 여러 범위 · 확대 · 눈금선 끄기는 이미 계약에 있어 새 파일 없이 붙일 자리만 확인(autofit.ts 머리). 시험 = resources/tester/spreadsheet/test_stage2_*.ts(공용 견본 · 가짜 컨텍스트는 test_stage2_helpers.ts). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr --- A00_Common/spreadsheet/spreadsheet_autofit.ts | 61 ++++++ A00_Common/spreadsheet/spreadsheet_find.ts | 179 ++++++++++++++++++ .../spreadsheet/spreadsheet_format_painter.ts | 95 ++++++++++ .../spreadsheet/spreadsheet_func_help.ts | 94 +++++++++ .../spreadsheet/spreadsheet_paste_special.ts | 140 ++++++++++++++ .../tester/spreadsheet/test_stage2_autofit.ts | 32 ++++ .../tester/spreadsheet/test_stage2_find.ts | 60 ++++++ .../spreadsheet/test_stage2_format_painter.ts | 46 +++++ .../spreadsheet/test_stage2_func_help.ts | 48 +++++ .../tester/spreadsheet/test_stage2_helpers.ts | 166 ++++++++++++++++ .../spreadsheet/test_stage2_paste_special.ts | 51 +++++ 11 files changed, 972 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_autofit.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_find.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_format_painter.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_func_help.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_paste_special.ts create mode 100644 resources/tester/spreadsheet/test_stage2_autofit.ts create mode 100644 resources/tester/spreadsheet/test_stage2_find.ts create mode 100644 resources/tester/spreadsheet/test_stage2_format_painter.ts create mode 100644 resources/tester/spreadsheet/test_stage2_func_help.ts create mode 100644 resources/tester/spreadsheet/test_stage2_helpers.ts create mode 100644 resources/tester/spreadsheet/test_stage2_paste_special.ts 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_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_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/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 시험 끝"); From 279ef5fb4fea2a9195500e8e1e74e99e4781cfcc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:23:32 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(spreadsheet):=20A=20=EC=97=94=EC=A7=84?= =?UTF-8?q?=20=EB=BA=80=20=EC=85=8B=20=EC=B1=84=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=20=EC=98=AE=EA=B9=80=20=E2=80=94=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=8D=94=ED=95=A8(Sheet.=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=C2=B7=20Sheet.comments)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계약 더함: Sheet.기본 { 서식 · 열폭 · 행높이 } — 모두 고르기 서식 · 폭 · 높이 · 칸 서식 물려받기 = 칸 → 행 → 열 → 시트 기본 → 0 - 계약 더함: Sheet.comments — A1 → 메모 글 · 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 · 덮은 칸 메모는 지움) - 행 · 열 넣기 = 위(왼) 줄 칸 서식 · 높이 · 폭 따라감(숨김은 안 따라감 · 첫 줄 앞 넣기는 안 따라감) - 범위를 칸 하나 자리에 넣으면 엑셀 암묵 교차(한 열 범위 = 식 든 행 · 한 행 범위 = 식 든 열) - 풀이 차례를 반복 위상 순서로 — 2만 칸 사슬도 스택 안 넘침 - 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py 규칙 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- .../spreadsheet/spreadsheet_commands.ts | 88 +++++++++++++++++-- A00_Common/spreadsheet/spreadsheet_eval.ts | 33 +++++-- A00_Common/spreadsheet/spreadsheet_graph.ts | 67 +++++++++++--- A00_Common/spreadsheet/spreadsheet_types.ts | 7 +- .../spreadsheet/helper_spreadsheet_engine.cjs | 82 ++++++++++++++++- 5 files changed, 246 insertions(+), 31 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index 1d3606b4..5eee4ddb 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -84,9 +84,25 @@ function internStyle(book: Workbook, style: CellStyle): number { return book.서식.length - 1; } -/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 0 */ +function setComments(s: Sheet, notes: Record) { + if (Object.keys(notes).length) s.comments = notes; + else delete s.comments; +} + +/** 시트 통째 서식 */ +const sheetStyle = (s: Sheet) => s.기본?.서식 ?? 0; + +/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 시트 · 0 */ const inherited = (s: Sheet, r: number, c: number) => - s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? 0; + s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? sheetStyle(s); + +/** 시트 `기본` 한 칸 — undefined 면 지움 · 빈 `기본` 도 지움 */ +function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value: number | undefined) { + const d = (s.기본 ??= {}); + if (value === undefined) delete d[key]; + else d[key] = value; + if (!Object.keys(d).length) delete s.기본; +} const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c }); @@ -179,8 +195,8 @@ function restyle( const setInfo = (axis: "행" | "열", key: string) => { const table = (s[axis] ??= {}); const info = (table[key] ??= {}); - const idx = f(info.서식 ?? 0); - if (idx) info.서식 = idx; + const idx = f(info.서식 ?? sheetStyle(s)); + if (idx !== sheetStyle(s)) info.서식 = idx; else delete info.서식; if (!Object.keys(info).length) delete table[key]; }; @@ -201,8 +217,9 @@ function restyle( const fullRows = isFull(rg, true); const fullCols = isFull(rg, false); if (fullRows && fullCols) { - // ponytail: 시트 통째 서식은 든 칸 · 있는 행 · 열 서식만 — 시트 기본 서식 칸이 계약에 없음 whole = true; + const idx = f(sheetStyle(s)); + setSheetDefault(s, "서식", idx || undefined); for (const k of Object.keys(s.행 ?? {})) setInfo("행", k); for (const k of Object.keys(s.열 ?? {})) setInfo("열", k); } else if (fullRows || fullCols) { @@ -265,6 +282,15 @@ function insertDelete( if (i !== undefined) cells[rows ? toA1(i, p.c) : toA1(p.r, i)] = cell; } s.칸 = cells; + if (s.comments) { + const notes: Record = {}; + for (const [a1, text] of Object.entries(s.comments)) { + const p = parseA1(a1); + const i = p && span(rows ? p.r : p.c); + if (p && i !== undefined && i !== null) notes[rows ? toA1(i, p.c) : toA1(p.r, i)] = text; + } + setComments(s, notes); + } const merges: CellRange[] = []; for (const m of mergesOf(s)) { @@ -289,6 +315,23 @@ function insertDelete( else delete s[axis]; } + // 넣은 줄은 위(왼) 줄 서식 · 폭 · 높이를 따라감(엑셀 「위와 같은 서식」) — 첫 줄 앞 넣기는 안 따라감 + if (insert && at > 0) { + for (const [a1, cell] of Object.entries(s.칸)) { + const p = parseA1(a1)!; + if ((rows ? p.r : p.c) !== at - 1 || cell.서식 === undefined) continue; + for (let k = 0; k < n; k++) + s.칸[rows ? toA1(at + k, p.c) : toA1(p.r, at + k)] = { 서식: cell.서식 }; + } + const info = s[axis]?.[rows ? String(at) : colName(at - 1)]; + if (info) { + const { 숨김: _hidden, ...look } = info; + if (Object.keys(look).length) + for (let k = 0; k < n; k++) + s[axis]![rows ? String(at + k + 1) : colName(at + k)] = { ...look }; + } + } + const key = rows ? "행" : "열"; const frozen = s.틀고정?.[key]; if (frozen && at < frozen) @@ -326,6 +369,23 @@ function move(book: Workbook, cmd: Of<"옮기기">): CommandEffect { rewriteAll(book, cmd); for (const [a1] of cellsIn(t, [dst])) delete t.칸[a1]; for (const [r, c, cell] of moved) t.칸[toA1(r, c)] = cell; + // 메모도 칸과 같이 — 덮은 자리 메모는 지움 + const carriedNotes: [string, string][] = []; + const srcNotes = { ...s.comments }; + for (const [a1, text] of Object.entries(srcNotes)) { + const p = parseA1(a1); + if (p && inRange(src, p.r, p.c)) { + carriedNotes.push([toA1(p.r + dr, p.c + dc), text]); + delete srcNotes[a1]; + } + } + setComments(s, srcNotes); + const dstNotes = { ...t.comments }; + for (const a1 of Object.keys(dstNotes)) { + const p = parseA1(a1); + if (p && inRange(dst, p.r, p.c)) delete dstNotes[a1]; + } + setComments(t, { ...dstNotes, ...Object.fromEntries(carriedNotes) }); const sm = mergesOf(s); const carried = sm.filter((m) => rangeInside(m, src)); @@ -410,6 +470,20 @@ function setAxis( return { 종류: "묶음", 명령: [...groups].map(([g, list]) => undoOf(list, JSON.parse(g))) }; } +/** 모두 고르기 폭 · 높이 — 시트 `기본` 에 두고 줄마다 값은 지움(엑셀 기본 폭 · 높이) */ +function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): CommandEffect { + const before = clone(s); + const prop = axis === "열" ? "폭" : "높이"; + setSheetDefault(s, axis === "열" ? "열폭" : "행높이", value ?? undefined); + const table = s[axis] as Record> | undefined; + for (const [k, info] of Object.entries(table ?? {})) { + delete info[prop]; + if (!Object.keys(info).length) delete table![k]; + } + if (table && !Object.keys(table).length) delete s[axis]; + return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false }; +} + // ── 시트 ──────────────────────────────────────────────────────────────────── function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect { @@ -506,6 +580,7 @@ export function applyCommand(book: Workbook, command: Command): CommandEffect { return unmerge(book, command); case "열폭": { const s = sheetOf(book, command.시트); + if (new Set(command.열).size >= MAX_COLS) return sheetSize(s, "열", command.폭); return plain( setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({ 종류: "열폭", @@ -517,6 +592,7 @@ export function applyCommand(book: Workbook, command: Command): CommandEffect { } case "행높이": { const s = sheetOf(book, command.시트); + if (new Set(command.행).size >= MAX_ROWS) return sheetSize(s, "행", command.높이); return plain( setAxis(s, "행", command.행, "높이", command.높이, (행, 높이) => ({ 종류: "행높이", @@ -611,7 +687,7 @@ export function compactStyles(book: Workbook): void { 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.열 ?? {})]) + for (const info of [...Object.values(s.행 ?? {}), ...Object.values(s.열 ?? {}), s.기본 ?? {}]) if (info.서식 !== undefined) info.서식 = map(info.서식); } book.서식 = next; diff --git a/A00_Common/spreadsheet/spreadsheet_eval.ts b/A00_Common/spreadsheet/spreadsheet_eval.ts index 7b48adfd..bb2ea3da 100644 --- a/A00_Common/spreadsheet/spreadsheet_eval.ts +++ b/A00_Common/spreadsheet/spreadsheet_eval.ts @@ -66,6 +66,25 @@ function single(value: EvalResult): Scalar { : err("#VALUE!", "범위를 값 하나로 못 씀"); } +/** 엔진이 만든 범위 — 적힌 자리(끝 자름 전) · 암묵 교차에 씀 */ +export interface PlacedRange extends RangeValue { + r0: number; + c0: number; + r1: number; + c1: number; +} + +/** 범위 → 칸 하나 — 1×1 이면 그 칸 · 아니면 식 든 행(한 열 범위) · 열(한 행 범위)과 겹친 칸(엑셀 암묵 교차) */ +export function intersect(value: EvalResult, at: { r: number; c: number }): Scalar { + if (!isRange(value) || (value.rows === 1 && value.cols === 1)) return single(value); + const p = value as Partial; + if (p.r0 !== undefined && p.c0 !== undefined && p.r1 !== undefined && p.c1 !== undefined) { + if (p.c0 === p.c1 && at.r >= p.r0 && at.r <= p.r1) return value.at(at.r - p.r0, 0); + if (p.r0 === p.r1 && at.c >= p.c0 && at.c <= p.c1) return value.at(0, at.c - p.c0); + } + return err("#VALUE!", "범위가 식 든 행 · 열과 안 겹침"); +} + /** 수로 — 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` · 범위는 칸 하나일 때만 */ export function asNumber(value: EvalResult): Frac | ErrorValue { const v = single(value); @@ -237,15 +256,15 @@ export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult { 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 v = intersect(evaluate(node.arg, ctx), ctx); + if (node.op === "+") return 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); + const left = intersect(evaluate(node.left, ctx), ctx); + const right = intersect(evaluate(node.right, ctx), ctx); if (node.op === "&") { const a = asText(left); if (isError(a)) return a; @@ -258,10 +277,8 @@ export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult { 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); + if (isError(left)) return left; + return isError(right) ? right : compareOp(node.op, left, right); } case "call": { const fn = getFunction(node.name); diff --git a/A00_Common/spreadsheet/spreadsheet_graph.ts b/A00_Common/spreadsheet/spreadsheet_graph.ts index 92ed4dc7..9487d333 100644 --- a/A00_Common/spreadsheet/spreadsheet_graph.ts +++ b/A00_Common/spreadsheet/spreadsheet_graph.ts @@ -8,7 +8,8 @@ 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 { err, evaluate, inputValue, intersect, toCalcValue } from "./spreadsheet_eval"; +import type { PlacedRange } from "./spreadsheet_eval"; import { parseFormula } from "./spreadsheet_parser"; import type { CalcEngine, @@ -19,7 +20,6 @@ import type { EvalResult, FormulaNode, ParseResult, - RangeValue, Scalar, SheetCellAddress, Workbook, @@ -175,7 +175,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { if (at) index(idx, cellId(at.r, at.c), cell); } } - for (const key of precs.keys()) compute(key); + for (const key of precs.keys()) ensure(key); } function ctxFor(sheet: string, r: number, c: number): EvalContext { @@ -188,7 +188,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { return vals.get(key) ?? null; } - function range(sheet: string, rg: CellRange): RangeValue { + function range(sheet: string, rg: CellRange): PlacedRange { const s = sheets.get(sheet); // ponytail: 행 · 열 전체 범위는 값 든 끝까지만 — INDEX(A:A, 끝 너머)는 #REF! 로 갈림 const r1 = Math.min(rg.r1, Math.max(rg.r0, s?.maxR ?? 0)); @@ -198,9 +198,54 @@ export function createCalcEngine(book: Workbook): CalcEngine { rows: r1 - rg.r0 + 1, cols: c1 - rg.c0 + 1, at: (i, j) => read(sheet, rg.r0 + i, rg.c0 + j), + ...rg, }; } + /** 가리키는 식 칸들(범위는 넓이 · 든 칸 수 중 작은 쪽으로 훑음) */ + function formulaPrecs(key: string): string[] { + const out: string[] = []; + for (const d of precs.get(key) ?? []) { + const s = sheets.get(d.sheet); + if (!s) continue; + const r1 = Math.min(d.range.r1, s.maxR); + const c1 = Math.min(d.range.c1, s.maxC); + if ((r1 - d.range.r0 + 1) * (c1 - d.range.c0 + 1) <= s.cells.size) { + for (let r = d.range.r0; r <= r1; r++) + for (let c = d.range.c0; c <= c1; c++) { + const k = keyOf(d.sheet, cellId(r, c)); + if (precs.has(k)) out.push(k); + } + } else + for (const id of s.cells.keys()) { + const k = keyOf(d.sheet, id); + const { r, c } = fromCellId(id); + if (precs.has(k) && inRange(d.range, r, c)) out.push(k); + } + } + return out; + } + + /** 앞 식 칸부터 반복으로 풂 — 깊은 사슬에도 스택이 안 넘침 · 고리는 compute 가 잡음 */ + function ensure(key: string): Scalar { + const todo: [string, boolean][] = [[key, false]]; + const seen = new Set(); + while (todo.length) { + const [k, ready] = todo.pop()!; + if (state.get(k) === DONE) continue; + if (ready) { + compute(k); + continue; + } + if (seen.has(k)) continue; + seen.add(k); + todo.push([k, true]); + for (const d of formulaPrecs(k)) + if (!seen.has(d) && state.get(d) !== DONE) todo.push([d, false]); + } + return vals.get(key) ?? null; + } + /** 식 칸 하나 풂 — 풀이 중인 칸을 다시 만나면 그 사이가 고리 */ function compute(key: string): Scalar { const st = state.get(key); @@ -217,7 +262,6 @@ export function createCalcEngine(book: Workbook): CalcEngine { 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); @@ -249,10 +293,8 @@ export function createCalcEngine(book: Workbook): CalcEngine { 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; + const one = intersect(v, ctx); + return one === null ? ZERO : one; } function dependents(key: string, out: Set) { @@ -291,12 +333,13 @@ export function createCalcEngine(book: Workbook): CalcEngine { state.delete(k); cycle.delete(k); } - for (const k of dirty) if (precs.has(k)) compute(k); + for (const k of dirty) if (precs.has(k)) ensure(k); return [...dirty].map(splitKey); }, rebuild, value(sheet, r, c) { - return read(sheet, r, c); + const key = keyOf(sheet, cellId(r, c)); + return precs.has(key) ? ensure(key) : (vals.get(key) ?? null); }, precedents(sheet, r, c) { return (precs.get(keyOf(sheet, cellId(r, c))) ?? []).map((d) => ({ @@ -308,7 +351,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { const out: CalcValues = {}; for (const key of precs.keys()) { const { 시트, r, c } = splitKey(key); - (out[시트] ??= {})[toA1(r, c)] = toCalcValue(compute(key)); + (out[시트] ??= {})[toA1(r, c)] = toCalcValue(ensure(key)); } return out; }, diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index c4e84397..0612f6b1 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -78,7 +78,7 @@ export interface Cell { 값?: CellInput; /** A1 식 — 앞 `=` 뺌(`ROUNDDOWN((G31+J31/2)*P31,2)`). 행열을 넣고 지우면 글을 고쳐 적음. */ 식?: string; - /** `서식` 표 번호 — 없으면 행 · 열 서식 · 그다음 0(기본). */ + /** `서식` 표 번호 — 없으면 행 · 열 서식 · 시트 `기본.서식` · 그다음 0(기본). */ 서식?: number; } @@ -142,6 +142,11 @@ export interface Sheet { /** 위에서 고정할 행 수 · 왼쪽에서 고정할 열 수. */ 틀고정?: { 행?: number; 열?: number }; 보기?: { 눈금선?: boolean }; + /** 시트 통째(모두 고르기) — 서식 번호 · 열 폭(글자 단위) · 행 높이(pt). 칸 · 행 · 열 값이 없을 때 씀 · + * 없으면 통합문서 `기본`. */ + 기본?: { 서식?: number; 열폭?: number; 행높이?: number }; + /** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */ + comments?: Record; } /** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */ diff --git a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs index afa240df..3abde1ea 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs @@ -364,10 +364,14 @@ section("graph", () => { 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 ib = book({ A1: 1, A2: 5, A3: 7, C5: 3, D5: 4, B2: "=A1:A3*2", B3: "=A1:A3", B7: "=A1:A3", D1: "=C5:D5+1", E1: "=C5:D5", F9: "=A:A+1", B4: "=-A1:A3" }); + const ie = G.createCalcEngine(ib); + eq("암묵 교차", ["B2", "B3", "B7", "D1", "E1", "F9", "B4"].map((a) => val(ie, "s1", a)), ["10", "7", "#VALUE!", "5", "#VALUE!", "1", "#VALUE!"]); // 긴 사슬(끌어 풀기 재귀 깊이) 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"); + for (let i = 2; i <= 20000; i++) chain[`A${i}`] = `=A${i - 1}+1`; + eq("사슬 2만", val(G.createCalcEngine(book(chain)), "s1", "A20000"), "20000"); }); // ── 7. 참조 옮김 엑셀 규칙표 ──────────────────────────────────────────────── @@ -508,7 +512,7 @@ section("commands", () => { roundTrip("행넣기", { 종류: "행넣기", 시트: "s1", at: 3, 수: 1 }, (b) => { const c = s1(b).칸; eq("행넣기 칸 · 식 · 병합", [c.H4, c.H5.식, c.H6.식, c.H7.식, c.J5.식, s1(b).병합], [ - undefined, + { 서식: 2 }, "ROUNDDOWN((J5+J6)/2*J7,2)", "ROUNDDOWN(H5*0.3,2)", "SUM(H5:H6)", @@ -564,6 +568,75 @@ section("commands", () => { { 종류: "병합", 시트: "s1", 범위: A.parseRange("K2:L2") }, ], }); + // 넣은 줄은 위(왼) 줄 서식 · 높이 · 폭을 따라감 + roundTrip("행넣기 위 줄 따라감", { 종류: "행넣기", 시트: "s1", at: 1, 수: 2 }, (b) => { + const t = s1(b); + eq("행 높이 · 칸 서식", [t.행["2"], t.행["3"], t.칸.A2, t.칸.A3, t.칸.A1.값], [ + { 높이: 20 }, + { 높이: 20 }, + { 서식: 1 }, + { 서식: 1 }, + "□수로형 토사개거 산출근거", + ]); + }); + roundTrip("열넣기 왼 줄 따라감", { 종류: "열넣기", 시트: "s1", at: 3, 수: 1 }, (b) => { + const t = s1(b); + eq("열 폭 · 칸 서식", [t.열.D, t.칸.D3, t.칸.D4, t.칸.E3.서식], [{ 폭: 6 }, { 서식: 2 }, { 서식: 4 }, 2]); + }); + roundTrip("숨긴 줄은 숨김 안 따라감", { 종류: "열넣기", 시트: "s1", at: 10, 수: 1 }, (b) => + eq("숨김", [s1(b).열.K, s1(b).열.J], [undefined, { 숨김: true }]), + ); + // 시트 통째 서식 · 폭 · 높이 + roundTrip("시트 통째 서식", { 종류: "서식", 시트: "s2", 범위: [A.parseRange("1:1048576")], 바꿀: { 글꼴: "굴림", 크기: 9 } }, (b, fx) => { + const t = b.시트[1]; + const st = b.서식[t.기본.서식]; + eq("시트 기본 서식", [st.글꼴, st.크기, b.서식[t.칸.B1.서식].크기, b.서식[t.칸.B1.서식].형식, t.칸.A1.서식, fx.rebuild], [ + "굴림", + 9, + 9, + "0.00", + undefined, + true, + ]); + }); + roundTrip("시트 통째 뒤 서식지움", { + 종류: "묶음", + 명령: [ + { 종류: "서식", 시트: "s2", 범위: [A.parseRange("1:1048576")], 바꿀: { 크기: 9 } }, + { 종류: "서식지움", 시트: "s2", 범위: [A.parseRange("A1:A1")] }, + ], + }, (b) => eq("시트 기본을 덮는 0", b.시트[1].칸.A1.서식, 0)); + const allCols = Array.from({ length: 16384 }, (_, i) => i); + roundTrip("시트 통째 열 폭", { 종류: "열폭", 시트: "s1", 열: allCols, 폭: 4 }, (b) => { + const t = s1(b); + eq("기본 열폭 · 줄 폭 지움", [t.기본, t.열.A, t.열.J], [{ 열폭: 4 }, undefined, { 숨김: true }]); + }); + roundTrip("시트 통째 행 높이", { 종류: "행높이", 시트: "s1", 행: Array.from({ length: 1048576 }, (_, i) => i), 높이: 15 }, (b) => + eq("기본 행높이", [s1(b).기본, s1(b).행], [{ 행높이: 15 }, undefined]), + ); + // 메모 — 칸과 같이 옮김 · 지운 칸 메모는 지움 + const withNotes = (cmd, check) => { + const b = fresh(); + b.시트[0].comments = { H4: "터파기 풀이", J5: "밑폭", A1: "제목" }; + const before = sheetsJson(b); + const fx = C.applyCommand(b, cmd); + check(b); + C.applyCommand(b, fx.undo); + eq(`메모 되돌림 ${cmd.종류}`, sheetsJson(b) === before, true); + }; + withNotes({ 종류: "행넣기", 시트: "s1", at: 3, 수: 2 }, (b) => + eq("메모 행넣기", s1(b).comments, { H6: "터파기 풀이", J7: "밑폭", A1: "제목" }), + ); + withNotes({ 종류: "행지우기", 시트: "s1", at: 3, 수: 1 }, (b) => + eq("메모 행지우기", s1(b).comments, { J4: "밑폭", A1: "제목" }), + ); + withNotes({ 종류: "열지우기", 시트: "s1", at: 7, 수: 3 }, (b) => eq("메모 열지우기", s1(b).comments, { A1: "제목" })); + withNotes({ 종류: "옮기기", 시트: "s1", 범위: A.parseRange("H4:J5"), 대상시트: "s2", r: 0, c: 0 }, (b) => { + eq("메모 옮기기", [s1(b).comments, b.시트[1].comments], [{ A1: "제목" }, { A1: "터파기 풀이", C2: "밑폭" }]); + }); + withNotes({ 종류: "옮기기", 시트: "s1", 범위: A.parseRange("J5"), 대상시트: "s1", r: 3, c: 7 }, (b) => + eq("메모 덮어씀", canon(s1(b).comments), canon({ H4: "밑폭", A1: "제목" })), + ); // 막힘 const blocked = [ ["병합 가운데 칸 쓰기", { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 1 } } }], @@ -616,8 +689,9 @@ section("commands", () => { const cs = fresh(); cs.서식.push({ 굵게: true }, { 크기: 12, 굵게: true }); cs.시트[0].칸.K1 = { 값: 1, 서식: 8 }; + cs.시트[1].기본 = { 서식: 8 }; C.compactStyles(cs); - eq("compactStyles", [cs.서식.length, cs.시트[0].칸.K1.서식, cs.시트[0].칸.A1.서식], [7, 1, 1]); + eq("compactStyles", [cs.서식.length, cs.시트[0].칸.K1.서식, cs.시트[0].칸.A1.서식, cs.시트[1].기본.서식], [7, 1, 1, 1]); }); // ── 9. 되돌리기 스택 · 엔진과 잇기 · 한 벌 풀이 ────────────────────────────── From 0d2d5614aaea5f313fb904e8fb02dfce7897cb05 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:24:39 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat(spreadsheet):=202=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=97=AC=EC=84=AF=EC=A7=B8=20=E2=80=94=20=EB=A9=94=EB=AA=A8(?= =?UTF-8?q?=ED=92=8D=EC=84=A0=20=EC=84=A4=EB=AA=85=20=C2=B7=20=EB=B9=A8?= =?UTF-8?q?=EA=B0=84=20=EC=84=B8=EB=AA=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브레인 답(2026-09-27) — 저장 자리는 `Sheet.comments?: Record`(sub3 가 0 계약에 보탬 · 이 창은 손대지 않고 `SheetWithComments` 로 모양만 가정). 명령 전용 「메모」 종류가 아직 없어 `시트통째` 명령에 얹음(전용 명령이 생기면 `applyComments` 한 곳만 고치면 됨). 행열 넣기·지우기 때 메모 주소 옮김은 A 의 refshift 를 부르는 자리로 파일 머리에 적어 둠(지금은 비움). 시험 harness(test_stage2_helpers.ts) 에 「시트통째」 명령 적용도 더함(다른 2단계 시험도 씀). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr --- .../spreadsheet/spreadsheet_comments.ts | 139 ++++++++++++++++++ .../spreadsheet/test_stage2_comments.ts | 59 ++++++++ .../tester/spreadsheet/test_stage2_helpers.ts | 4 + 3 files changed, 202 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_comments.ts create mode 100644 resources/tester/spreadsheet/test_stage2_comments.ts diff --git a/A00_Common/spreadsheet/spreadsheet_comments.ts b/A00_Common/spreadsheet/spreadsheet_comments.ts new file mode 100644 index 00000000..0c282a82 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_comments.ts @@ -0,0 +1,139 @@ +/* ============================================================================= + * spreadsheet_comments.ts (2단계 · 주인 sub_laptop_1) + * 메모(풍선 설명) · 칸 위쪽 빨간 세모 표시. 저장 자리 — 브레인 답(2026-09-27): + * `Sheet.comments?: Record` (열쇠 = A1 주소 · 값 = 메모 글 · 빈 글이면 지움). + * 이 칸은 sub3 가 0 계약(spreadsheet_types.ts)에 보태는 중 — 여기서는 고치지 않고 모양만 가정 + * (`SheetWithComments` 로 좁혀 씀 · sub3 커밋이 오면 구조가 그대로 맞음). + * + * 문서를 바꾸는 길은 명령뿐(view_types.ts 머리) — 「메모」 전용 명령이 아직 없어 `시트통째` + * (있는 시트를 통째로 바꿔 넣는 명령)로 얹음. 메모 전용 명령이 생기면 이 파일의 `applyComments` + * 한 곳만 고치면 됨. + * + * 행 · 열 넣기 · 지우기 때 메모 주소 옮김 — 지금은 안 함(사각지대로 남김). A 가 + * `spreadsheet_refshift.ts` 를 완성하면 그 파일의 참조 옮김 함수(예: `shiftAddress` 류)를 + * 이 파일 `remapComments` 자리에 불러 A1 주소들을 옮기게 잇기(지우는 행 · 열에 있던 메모는 + * 참조 지움과 같은 규칙으로 버림). 지금은 `remapComments` 를 D · A 가 채울 자리로 비워 둠. + * + * 잇는 법(D · sub7 · C 격자) — `attachComments(ctx)` 를 한 번 붙이고: + * 1) C 가 칸을 그릴 때 `hasComment(sheet, r, c)` 가 참이면 칸 위 오른쪽에 빨간 세모(4~6px)를 그림. + * 2) 그 칸에 마우스가 올라가면(hover) `showBalloon(r, c)` · 벗어나면 `hideBalloon()`. + * 3) 우클릭 메뉴 [메모 삽입 · 메모 편집 · 메모 삭제] 는 `setComment` · `deleteComment` 를 부름. + * ========================================================================== */ + +import type { Sheet, Workbook } from "./spreadsheet_types"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; +import { el } from "@ui/ui_template_elements"; + +/** 0 계약이 아직 안 실은 칸 — sub3 커밋 뒤엔 진짜 `Sheet` 가 이 모양을 그대로 가짐. */ +export interface SheetWithComments extends Sheet { + comments?: Record; +} + +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}`; +}; + +/** 자리 이동(행열 넣기 · 지우기) 뒤 메모 주소를 옮김 — A `spreadsheet_refshift` 완성 뒤 채울 자리. + * `shift` 는 옛 주소 → 새 주소(지워진 자리면 null). 지금은 호출부가 없어 안 씀(사각지대 기록용). */ +export function remapComments( + comments: Record, + shift: (old: { r: number; c: number }) => { r: number; c: number } | null, +): Record { + const next: Record = {}; + for (const [key, text] of Object.entries(comments)) { + const at = parseA1(key); + const moved = shift(at); + if (moved) next[a1(moved.r, moved.c)] = text; + } + return next; +} + +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 }; +} + +export function getComment(sheet: Sheet, r: number, c: number): string | null { + const comments = (sheet as SheetWithComments).comments; + return comments?.[a1(r, c)] ?? null; +} + +export function hasComment(sheet: Sheet, r: number, c: number): boolean { + return getComment(sheet, r, c) !== null; +} + +export function listComments(sheet: Sheet): { r: number; c: number; 글: string }[] { + const comments = (sheet as SheetWithComments).comments ?? {}; + return Object.entries(comments).map(([key, 글]) => ({ ...parseA1(key), 글 })); +} + +/** `시트통째` 명령으로 메모 칸만 바꿔 넣음 — 그 밖 시트 내용은 그대로. */ +function applyComments(ctx: SpreadsheetContext, next: Record): void { + const sheet = ctx.sheet() as SheetWithComments; + const cleaned: Record = {}; + for (const [key, text] of Object.entries(next)) if (text) cleaned[key] = text; + const nextSheet: SheetWithComments = { ...sheet, comments: cleaned }; + ctx.dispatch({ 종류: "시트통째", 시트: nextSheet }); +} + +/** 글이 비면 메모를 지움(값과 같은 규칙 — `spreadsheet_types.ts` 「빈 칸 안 적음」). */ +export function setComment(ctx: SpreadsheetContext, r: number, c: number, text: string): void { + const sheet = ctx.sheet() as SheetWithComments; + const next = { ...(sheet.comments ?? {}) }; + if (text) next[a1(r, c)] = text; + else delete next[a1(r, c)]; + applyComments(ctx, next); +} + +export function deleteComment(ctx: SpreadsheetContext, r: number, c: number): void { + setComment(ctx, r, c, ""); +} + +export interface CommentsHandle extends PartHandle { + showBalloon(r: number, c: number): void; + hideBalloon(): void; +} + +/** 풍선 뿌리 하나를 만들어 `ctx.root` 에 붙임 — 세모 그리기 자체는 C(격자)가 `hasComment` 로 판단해서 함. */ +export function attachComments(ctx: SpreadsheetContext): CommentsHandle { + const balloon = el("div", { className: "spreadsheet-comment-balloon", attrs: { hidden: "" } }); + ctx.root.append(balloon); + + return { + root: balloon, + showBalloon(r: number, c: number): void { + const text = getComment(ctx.sheet(), r, c); + if (!text) return void this.hideBalloon(); + balloon.textContent = text; + balloon.removeAttribute("hidden"); + const box = ctx.grid.cellBox(r, c); + balloon.style.left = `${box.x + box.w}px`; + balloon.style.top = `${box.y}px`; + }, + hideBalloon(): void { + balloon.setAttribute("hidden", ""); + }, + refresh(): void { + /* 문서가 바뀌면 지금 보이는 풍선 글도 새로 — 열린 풍선이 없으면 할 일 없음 */ + }, + destroy(): void { + balloon.remove(); + }, + }; +} + +/** 시트 전체 메모 개수(시험 · 실무 대조용) */ +export function countComments(book: Workbook, sheetId: string): number { + const sheet = book.시트.find((s) => s.id === sheetId); + return sheet ? Object.keys((sheet as SheetWithComments).comments ?? {}).length : 0; +} diff --git a/resources/tester/spreadsheet/test_stage2_comments.ts b/resources/tester/spreadsheet/test_stage2_comments.ts new file mode 100644 index 00000000..2f9b72d3 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_comments.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * test_stage2_comments.ts — spreadsheet_comments 시험. 돌리기: npx tsx <이 파일> (el 때문에 @ui 필요). + * ========================================================================== */ + +import { + countComments, + deleteComment, + getComment, + hasComment, + listComments, + remapComments, + setComment, + type SheetWithComments, +} from "../../../A00_Common/spreadsheet/spreadsheet_comments.ts"; +import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts"; + +const sheet = makeSheet("s1", { A1: v(1), B2: v(2) }) as SheetWithComments; +sheet.comments = { A1: "돌뒷길이+직고*0.1" }; +const book = makeBook([sheet]); + +assertEqual(getComment(sheet, 0, 0), "돌뒷길이+직고*0.1", "getComment 있음"); +assertEqual(getComment(sheet, 1, 1), null, "getComment 없으면 null"); +assertEqual(hasComment(sheet, 0, 0), true, "hasComment 참"); +assertEqual(hasComment(sheet, 5, 5), false, "hasComment 거짓"); +assertEqual(countComments(book, "s1"), 1, "countComments"); +assertEqual(listComments(sheet), [{ r: 0, c: 0, 글: "돌뒷길이+직고*0.1" }], "listComments"); + +const ctx = makeCtx(book, "s1"); +setComment(ctx, 1, 1, "새 메모"); +const updated = ctx.applied.at(-1); +assertEqual(updated?.종류, "시트통째", "setComment 는 시트통째 명령으로 나감"); +assertEqual( + (updated as { 시트: SheetWithComments }).시트.comments, + { A1: "돌뒷길이+직고*0.1", B2: "새 메모" }, + "메모 칸에 더해짐(다른 메모는 그대로)", +); + +setComment(ctx, 0, 0, ""); +const cleared = ctx.applied.at(-1); +assertEqual( + (cleared as { 시트: SheetWithComments }).시트.comments, + { B2: "새 메모" }, + "빈 글로 지움(안 적음)", +); + +const moved = remapComments({ A1: "위쪽", C3: "아래쪽" }, (at) => + at.r === 0 ? { r: at.r + 1, c: at.c } : at, +); +assertEqual(moved, { A2: "위쪽", C3: "아래쪽" }, "remapComments 옮김 함수대로 자리를 바꿈"); + +const dropped = remapComments({ A1: "지워질 것" }, () => null); +assertEqual(dropped, {}, "remapComments — shift 가 null 이면 버림"); + +deleteComment(ctx, 1, 1); +const afterDelete = (ctx.applied.at(-1) as { 시트: SheetWithComments }).시트.comments; +assertEqual(afterDelete?.B2, undefined, "deleteComment 로 지워짐"); + +if (process.exitCode) process.exit(process.exitCode); +console.log("spreadsheet_comments 시험 끝"); diff --git a/resources/tester/spreadsheet/test_stage2_helpers.ts b/resources/tester/spreadsheet/test_stage2_helpers.ts index df5f1190..b088f82b 100644 --- a/resources/tester/spreadsheet/test_stage2_helpers.ts +++ b/resources/tester/spreadsheet/test_stage2_helpers.ts @@ -90,6 +90,10 @@ export function makeCtx( sheet.열 = sheet.열 ?? {}; for (const c of cmd.열) sheet.열[colLetters(c)] = { ...sheet.열[colLetters(c)], 폭: cmd.폭 ?? undefined }; + } else if (cmd.종류 === "시트통째") { + const idx = book.시트.findIndex((s) => s.id === cmd.시트.id); + if (idx >= 0) book.시트[idx] = cmd.시트; + if (cmd.서식) book.서식 = cmd.서식; } }; From b58073730fb3b00be76528a36701c747d5a44d78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:25:30 +0900 Subject: [PATCH 6/9] =?UTF-8?q?test(spreadsheet):=20A=20=EC=97=94=EC=A7=84?= =?UTF-8?q?=20=EB=AC=B4=EA=B2=8C=20=EC=8B=9C=ED=97=98=20=E2=80=94=20?= =?UTF-8?q?=EC=B9=B8=201=EB=A7=8C=20=C2=B7=20=EC=8B=9D=205=EC=B2=9C=20?= =?UTF-8?q?=EC=9E=AC=EA=B3=84=EC=82=B0=20=C2=B7=206=EB=A7=8C=208=EC=B2=9C?= =?UTF-8?q?=20=EC=B9=B8=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=82=98=EB=88=97=EC=85=88=20=EC=82=AC=EC=8A=AC=20=EB=B6=84?= =?UTF-8?q?=EB=AA=A8=20=EC=83=81=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bench_spreadsheet_engine.cjs — 얕은 시트 · 깊은 사슬 5천 · 큰 시트(271 행 × 253 열) · 소수 역수 합 · 곱나눗셈 사슬 수치 - test_spreadsheet_bench.py — 딸린 칸 수 · 시간 한도(실측 몇 배) · 분모 41 자리 안 · double 과 상대 오차 1e-12 안 - helper_spreadsheet_engine.cjs — 적재 · 가짜 함수를 무게 시험이 같이 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- .../spreadsheet/bench_spreadsheet_engine.cjs | 113 ++++++++++++++++++ .../spreadsheet/helper_spreadsheet_engine.cjs | 4 + .../spreadsheet/test_spreadsheet_bench.py | 54 +++++++++ 3 files changed, 171 insertions(+) create mode 100644 resources/tester/spreadsheet/bench_spreadsheet_engine.cjs create mode 100644 resources/tester/spreadsheet/test_spreadsheet_bench.py diff --git a/resources/tester/spreadsheet/bench_spreadsheet_engine.cjs b/resources/tester/spreadsheet/bench_spreadsheet_engine.cjs new file mode 100644 index 00000000..72175187 --- /dev/null +++ b/resources/tester/spreadsheet/bench_spreadsheet_engine.cjs @@ -0,0 +1,113 @@ +/* 스프레드시트 엔진 A 무게 시험 — 큰 시트 불러오기 · 칸 하나 고칠 때 재계산 · 나눗셈 사슬 분모. + * 수치를 JSON 으로 냄 → `test_spreadsheet_bench.py` 가 한도 안인지 봄. 적재 · 가짜 함수는 엔진 시험 헬퍼 것. + */ +const { A, C, E, F, G } = require("./helper_spreadsheet_engine.cjs"); + +const ms = (fn) => { + const t = process.hrtime.bigint(); + const out = fn(); + return [Number(process.hrtime.bigint() - t) / 1e6, out]; +}; +const round = (x) => Math.round(x * 100) / 100; + +/** 칸 표 → 시트 하나 통합문서 JSON 글(불러오기 = JSON.parse + 엔진 만들기) */ +function bookText(cells) { + const 칸 = {}; + for (const [k, v] of Object.entries(cells)) 칸[k] = typeof v === "string" ? { 식: v } : { 값: v }; + return JSON.stringify({ 종류: "통합문서", 판: 1, 열: "b", 서식: [{}], 시트: [{ id: "s1", 이름: "무게", 칸 }] }); +} + +/** 불러오기 · 칸 하나 고치기(가운데 · 맨 앞) 시간 */ +function scenario(cells, edits) { + const text = bookText(cells); + const [parseMs, book] = ms(() => JSON.parse(text)); + const [loadMs, engine] = ms(() => G.createCalcEngine(book)); + const out = { cells: Object.keys(cells).length, kb: Math.round(text.length / 1024), parseMs: round(parseMs), loadMs: round(loadMs) }; + for (const [label, a1, value] of edits) { + const times = []; + let redone = 0; + for (let i = 0; i < 5; i++) { + const fx = C.applyCommand(book, { 종류: "칸", 시트: "s1", 칸: { [a1]: { 값: value + i } } }); + const [t, list] = ms(() => engine.update(fx.cells)); + times.push(t); + redone = list.length; + } + times.sort((x, y) => x - y); + out[label] = { ms: round(times[2]), redone }; + } + return [out, engine]; +} + +const N = 5000; +const result = {}; + +// ① 얕은 시트 — 칸 1만(값 5천 · 식 5천) · 식마다 이웃 두 칸 · 끝에 합계 하나 +{ + const cells = {}; + for (let i = 1; i <= N; i++) cells[`A${i}`] = (i % 97) / 10; + for (let i = 1; i < N; i++) cells[`B${i}`] = `ROUNDDOWN((A${i}+A${i + 1}/2)*1.3,2)`; + cells[`B${N}`] = `SUM(B1:B${N - 1})`; + const [out, engine] = scenario(cells, [ + ["editMiddle", "A2500", 3], + ["editFirst", "A1", 3], + ]); + out.total = E.toCalcValue(engine.value("s1", N - 1, 1)); + result.shallow = out; +} + +// ② 깊은 사슬 — 칸 1만 · 식 5천이 한 줄로 이어짐(맨 앞 고치면 5천 칸 모두 다시) +{ + const cells = {}; + for (let i = 1; i <= N; i++) cells[`A${i}`] = 1; + cells.B1 = "A1"; + for (let i = 2; i <= N; i++) cells[`B${i}`] = `B${i - 1}+A${i}*1.1`; + const [out, engine] = scenario(cells, [ + ["editLast", `A${N}`, 2], + ["editFirst", "A1", 2], + ]); + out.end = E.toCalcValue(engine.value("s1", N - 1, 1)); + result.chain = out; +} + +// ③ 큰 시트 불러오기 — 실무 최대 모양(271 행 × 253 열) 에 가까운 6만 8천 칸 · 절반이 식 +{ + const cells = {}; + for (let r = 1; r <= 271; r++) + for (let c = 0; c < 253; c++) { + const a1 = A.toA1(r - 1, c); + cells[a1] = c % 2 === 0 ? r + c / 100 : `${A.toA1(r - 1, c - 1)}*2+${A.toA1(Math.max(0, r - 2), c - 1)}/3`; + } + const [out] = scenario(cells, [["editOne", "A1", 5]]); + result.bigSheet = out; +} + +// ④ 나눗셈 사슬 — 분모가 커지는 식(1/소수 더하기 · 소수로 나누기) · 상한(10^40) 넘으면 30 자리 십진으로 굳힘 +{ + const primes = []; + for (let n = 2; primes.length < 2000; n++) if (primes.every((p) => n % p)) primes.push(n); + const cells = { A1: 1, C1: 1 }; + for (let i = 2; i <= 2000; i++) { + cells[`A${i}`] = `A${i - 1}+1/${primes[i - 1]}`; // 역수 합 — 분모가 소수 곱으로 부풂 + cells[`C${i}`] = `C${i - 1}/${primes[(i * 7) % 2000]}*${primes[(i * 13) % 2000]}`; // 곱 · 나눗셈 사슬 + } + const book = JSON.parse(bookText(cells)); + const [loadMs, engine] = ms(() => G.createCalcEngine(book)); + let maxDigits = 0; + for (let i = 1; i <= 2000; i++) + for (const c of [0, 2]) { + const v = engine.value("s1", i - 1, c); + maxDigits = Math.max(maxDigits, v.d.toString().length); + } + let dbl = 1; + for (let i = 2; i <= 2000; i++) dbl += 1 / primes[i - 1]; + const exact = Number(F.fracToString(engine.value("s1", 1999, 0))); + result.divChain = { + cells: 4000, + loadMs: round(loadMs), + maxDenominatorDigits: maxDigits, + sum: F.fracToString(engine.value("s1", 1999, 0)), + relErrVsDouble: Math.abs(exact - dbl) / dbl, + }; +} + +process.stdout.write(JSON.stringify(result)); diff --git a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs index 3abde1ea..0747dd8a 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs @@ -98,6 +98,10 @@ const C = load("spreadsheet_commands"); const H = load("spreadsheet_history"); const RC = load("spreadsheet_recalc"); +// 무게 시험(`bench_spreadsheet_engine.cjs`)이 같은 적재 · 가짜 함수를 씀 — 불려 올 때는 시험을 안 돌림 +module.exports = { A, C, E, F, G }; +if (require.main !== module) return; + const results = {}; let group = ""; const fails = () => (results[group] ??= []); diff --git a/resources/tester/spreadsheet/test_spreadsheet_bench.py b/resources/tester/spreadsheet/test_spreadsheet_bench.py new file mode 100644 index 00000000..130bed31 --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_bench.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""산출근거 스프레드시트 엔진 A 무게 시험 — 칸 1만 · 식 5천 시트에서 칸 하나 고칠 때 재계산 · +6만 8천 칸 불러오기 · 분모가 커지는 나눗셈 사슬(상한 10^40 넘으면 30 자리 십진으로 굳힘). + +Node 헬퍼(`bench_spreadsheet_engine.cjs`)가 수치를 냄. 한도는 이 PC 실측의 몇 배로 넉넉히 둠 — +실측이 한도 가까이 오르면 느려진 것. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def bench(): + proc = subprocess.run( + ["node", os.path.join(HERE, "bench_spreadsheet_engine.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_얕은_시트_칸_하나는_딸린_칸만(bench): + s = bench["shallow"] + assert s["editMiddle"]["redone"] == 4 # 고친 칸 + 이웃 식 둘 + 합계 + assert s["editMiddle"]["ms"] < 100 + assert s["loadMs"] < 3000 + + +def test_깊은_사슬_5천_칸(bench): + c = bench["chain"] + assert c["editFirst"]["redone"] == 5001 + assert c["editFirst"]["ms"] < 1000 + assert c["editLast"]["redone"] == 2 + + +def test_큰_시트_불러오기(bench): + b = bench["bigSheet"] + assert b["cells"] > 60000 + assert b["parseMs"] + b["loadMs"] < 15000 + + +def test_나눗셈_사슬_분모_상한(bench): + d = bench["divChain"] + assert d["maxDenominatorDigits"] <= 41 + assert d["relErrVsDouble"] < 1e-12 From bfd71238297ea9a4471a51716b399cb7f91579ba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:33:26 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat(spreadsheet):=20E=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=20=EB=AA=A8=EC=9D=8C=20=C2=B7=20=EC=8B=9C=ED=8A=B8=20=ED=83=AD?= =?UTF-8?q?=20=C2=B7=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=C2=B7=20=ED=81=B4=EB=A6=BD=EB=B3=B4=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도구 모음: 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 · 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식 · 되돌리기 - 시트 탭: 더하기 · 이름 바꾸기 · 지우기 · 복사 · 끌어 옮기기 - 우클릭 메뉴: 칸 · 행 머리 · 열 머리별 명령(공용 `ui_template_context_menu` 재사용) - 클립보드: 안 → 안 서식 · 병합 · 수식 상대 이동 왕복 · 밖 → 안(엑셀 값 + 「수식 표시」 · 구글 R1C1) · 안 → 밖(TSV + 인라인 서식 HTML) - E 화면 시험 틀(A · B 몫 가짜 대역) · TSV 파싱 Node 시험 추가 --- .../spreadsheet/spreadsheet_clipboard.ts | 359 ++++++++++++++++- A00_Common/spreadsheet/spreadsheet_menu.ts | 206 +++++++++- A00_Common/spreadsheet/spreadsheet_tabs.ts | 162 +++++++- A00_Common/spreadsheet/spreadsheet_text.ts | 86 ++++ .../spreadsheet/spreadsheet_toolbar.css | 217 ++++++++++ A00_Common/spreadsheet/spreadsheet_toolbar.ts | 371 +++++++++++++++++- resources/tester/spreadsheet/e_harness.ts | 277 +++++++++++++ .../tester/spreadsheet/fakes/fake_address.ts | 78 ++++ .../tester/spreadsheet/fakes/fake_commands.ts | 10 + .../tester/spreadsheet/fakes/fake_numfmt.ts | 17 + .../tester/spreadsheet/fakes/fake_refshift.ts | 41 ++ resources/tester/spreadsheet/index.html | 52 +++ .../test_spreadsheet_clipboard_text.mjs | 63 +++ .../tester/spreadsheet/vite.harness.config.ts | 35 ++ 14 files changed, 1937 insertions(+), 37 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_text.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_toolbar.css create mode 100644 resources/tester/spreadsheet/e_harness.ts create mode 100644 resources/tester/spreadsheet/fakes/fake_address.ts create mode 100644 resources/tester/spreadsheet/fakes/fake_commands.ts create mode 100644 resources/tester/spreadsheet/fakes/fake_numfmt.ts create mode 100644 resources/tester/spreadsheet/fakes/fake_refshift.ts create mode 100644 resources/tester/spreadsheet/index.html create mode 100644 resources/tester/spreadsheet/test_spreadsheet_clipboard_text.mjs create mode 100644 resources/tester/spreadsheet/vite.harness.config.ts diff --git a/A00_Common/spreadsheet/spreadsheet_clipboard.ts b/A00_Common/spreadsheet/spreadsheet_clipboard.ts index add6efd6..19f3cd96 100644 --- a/A00_Common/spreadsheet/spreadsheet_clipboard.ts +++ b/A00_Common/spreadsheet/spreadsheet_clipboard.ts @@ -2,32 +2,359 @@ * spreadsheet_clipboard.ts (주인 E) * 복사 · 잘라내기 · 붙여넣기 — `copy` · `cut` · `paste` 사건만(허락 창 없음 · navigator.clipboard 안 씀). * 형 · 수식 살리기 규칙은 `spreadsheet_types.ts` `ClipBlock` 머리. 잘라 붙이기(안쪽) = `옮기기` 명령 · - * 나머지 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 병합 명령 묶음. - * 편집 중(`ctx.editor.editing()`)이면 사건을 편집기에 맡김. 0 계약 머리 — 몸은 E 가 채움. + * 나머지 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 서식 · 병합 명령 묶음. + * 편집 중(`ctx.editor.editing()`)이면 사건을 편집기에 맡김. * ========================================================================== */ -import type { CellAddress, CellRange, ClipBlock, Workbook } from "./spreadsheet_types"; +import { showToast } from "@ui/ui_template_elements"; +import { colName, parseRange, toA1 } from "./spreadsheet_address"; +import { formatValue } from "./spreadsheet_numfmt"; +import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift"; +import { st } from "./spreadsheet_text"; +import type { + BorderLine, + Cell, + CellAddress, + CellInput, + CellRange, + CellStyle, + ClipBlock, + ClipCell, + Command, + Scalar, + Sheet, + Workbook, +} from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; -const todo = (): never => { - throw new Error("spreadsheet_clipboard: 아직 없음(E)"); +/* ── 안 → 밖 ─────────────────────────────────────────────────────────────── */ + +const BORDER_CSS: Record = { + hair: "dotted", + thin: "solid", + medium: "solid", + thick: "solid", + double: "double", + dotted: "dotted", + dashed: "dashed", }; -/** `ctx.root` 의 copy · cut · paste 를 받음(root null) */ -export function attachClipboard(_ctx: SpreadsheetContext): PartHandle { - return todo(); +function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle { + const cell = sheet.칸[toA1(r, c)]; + const idx = cell?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? 0; + return book.서식[idx] ?? {}; } -/** 클립보드 글 → 뭉치(HTML 먼저 · 없으면 TSV) · 읽을 것 없으면 null · `at` = 붙일 왼위(구글 R1C1 풀이) */ -export function parseClipboard(_html: string, _text: string, _at: CellAddress): ClipBlock | null { - return todo(); +function styleToCss(style: CellStyle): string { + const parts: string[] = []; + if (style.글꼴) parts.push(`font-family:${style.글꼴}`); + if (style.크기) parts.push(`font-size:${style.크기}pt`); + if (style.굵게) parts.push("font-weight:bold"); + if (style.기울임) parts.push("font-style:italic"); + const deco = [style.밑줄 && "underline", style.취소선 && "line-through"] + .filter(Boolean) + .join(" "); + if (deco) parts.push(`text-decoration:${deco}`); + if (style.글자색) parts.push(`color:${style.글자색}`); + if (style.채움) parts.push(`background-color:${style.채움}`); + if (style.가로 && style.가로 !== "general") { + parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`); + } + if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`); + if (style.줄바꿈) parts.push("white-space:pre-wrap"); + if (style.테두리) { + (["위", "아래", "왼", "오른"] as const).forEach((side) => { + const s = style.테두리?.[side]; + if (!s) return; + const prop = { + 위: "border-top", + 아래: "border-bottom", + 왼: "border-left", + 오른: "border-right", + }[side]; + parts.push(`${prop}:1px ${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`); + }); + } + return parts.join(";"); } -/** 범위 → 클립보드 글 둘(TSV = 보이는 값 · HTML = 인라인 서식 표 + 안쪽 뭉치 JSON) */ +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */ +function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock { + const { r0, c0, r1, c1 } = range; + const 칸: Record = {}; + for (let r = r0; r <= r1; r++) { + for (let c = c0; c <= c1; c++) { + const cell = sheet.칸[toA1(r, c)]; + if (!cell) continue; + const clip: ClipCell = {}; + if (cell.값 !== undefined) clip.값 = cell.값; + if (cell.식 !== undefined) clip.식 = cell.식; + if (cell.서식 !== undefined) clip.서식 = book.서식[cell.서식]; + if (Object.keys(clip).length) 칸[`${r - r0},${c - c0}`] = clip; + } + } + const 병합 = (sheet.병합 ?? []) + .map((m) => parseRange(m)) + .filter((m): m is CellRange => !!m && m.r0 >= r0 && m.c0 >= c0 && m.r1 <= r1 && m.c1 <= c1) + .map((m) => ({ r0: m.r0 - r0, c0: m.c0 - c0, r1: m.r1 - r0, c1: m.c1 - c0 })); + return { rows: r1 - r0 + 1, cols: c1 - c0 + 1, 칸, 병합, 원점: { r: r0, c: c0 }, 출처: "aislo" }; +} + +/** 범위 → 클립보드 글 둘(TSV = 보이는 값 · HTML = 인라인 서식 표 + 안쪽 뭉치 JSON). */ export function blockToClipboard( - _book: Workbook, - _sheet: string, - _range: CellRange, + book: Workbook, + sheetId: string, + range: CellRange, + valueAt: (r: number, c: number) => Scalar, ): { html: string; text: string } { - return todo(); + const sheet = book.시트.find((s) => s.id === sheetId); + if (!sheet) return { html: "", text: "" }; + const { r0, c0, r1, c1 } = range; + const aisloBlock = buildAisloBlock(book, sheet, range); + const rowsHtml: string[] = []; + const tsvRows: string[] = []; + const skip = new Set(); + for (let r = r0; r <= r1; r++) { + const cellsHtml: string[] = []; + const tsvCells: string[] = []; + for (let c = c0; c <= c1; c++) { + const key = `${r},${c}`; + if (skip.has(key)) continue; + const style = styleAt(book, sheet, r, c); + const text = formatValue(valueAt(r, c), style.형식).글; + tsvCells.push(text.replace(/\t/g, " ")); + let span = ""; + const merge = (sheet.병합 ?? []) + .map((m) => parseRange(m)) + .find((m): m is CellRange => !!m && m.r0 === r && m.c0 === c && m.r1 <= r1 && m.c1 <= c1); + if (merge) { + const rs = merge.r1 - merge.r0 + 1; + const cs = merge.c1 - merge.c0 + 1; + if (rs > 1) span += ` rowspan="${rs}"`; + if (cs > 1) span += ` colspan="${cs}"`; + for (let dr = 0; dr < rs; dr++) + for (let dc = 0; dc < cs; dc++) skip.add(`${r + dr},${c + dc}`); + } + cellsHtml.push( + `${escapeHtml(text)}`, + ); + } + rowsHtml.push(`${cellsHtml.join("")}`); + tsvRows.push(tsvCells.join("\t")); + } + const table = `${rowsHtml.join("")}
`; + const html = `${table}`; + return { html, text: tsvRows.join("\r\n") }; +} + +/* ── 밖 → 안 ─────────────────────────────────────────────────────────────── */ + +/** 「1,234.56」 · 「TRUE」 는 값으로 · 나머지는 글로. */ +function coerceValue(text: string): CellInput { + const t = text.trim(); + if (/^[+-]?\d[\d,]*(\.\d+)?$/.test(t)) { + const n = Number(t.replace(/,/g, "")); + if (Number.isFinite(n)) return n; + } + if (/^(TRUE|FALSE)$/i.test(t)) return t.toUpperCase() === "TRUE"; + return text; +} + +/** 「=」 뒤에 글이 있고 식으로 보이면 식 — 엑셀 「수식 표시」 복사 대응(「=」 한 글자는 글). */ +function cellFromText(text: string): ClipCell { + if (text.startsWith("=") && text.length > 1) return { 식: text.slice(1) }; + return { 값: coerceValue(text) }; +} + +interface TableWalk { + rows: number; + cols: number; + owner: Map; + merges: CellRange[]; +} + +function walkTable(table: HTMLTableElement): TableWalk { + const occupied = new Set(); + const owner = new Map(); + const merges: CellRange[] = []; + const rows = Array.from(table.rows); + rows.forEach((row, r) => { + let c = 0; + Array.from(row.cells).forEach((cellEl) => { + while (occupied.has(`${r},${c}`)) c++; + const rowspan = cellEl.rowSpan || 1; + const colspan = cellEl.colSpan || 1; + owner.set(`${r},${c}`, cellEl); + for (let dr = 0; dr < rowspan; dr++) { + for (let dc = 0; dc < colspan; dc++) occupied.add(`${r + dr},${c + dc}`); + } + if (rowspan > 1 || colspan > 1) { + merges.push({ r0: r, c0: c, r1: r + rowspan - 1, c1: c + colspan - 1 }); + } + c += colspan; + }); + }); + let cols = 0; + for (const key of occupied) { + const c = Number(key.split(",")[1]); + if (c + 1 > cols) cols = c + 1; + } + return { rows: rows.length, cols, owner, merges }; +} + +function parseHtmlTable(html: string, at: CellAddress): ClipBlock | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + const table = doc.querySelector("table"); + if (!table) return null; + const walk = walkTable(table); + const sheetsMode = /data-sheets-formula/.test(html); + const 칸: Record = {}; + for (const [key, cellEl] of walk.owner) { + const [r, c] = key.split(",").map(Number); + const text = cellEl.textContent ?? ""; + const formula = sheetsMode ? cellEl.getAttribute("data-sheets-formula") : null; + if (formula) { + const target = { r: at.r + r, c: at.c + c }; + 칸[key] = { 식: r1c1ToA1(formula.replace(/^=/, ""), target) }; + continue; + } + if (!text) continue; + 칸[key] = cellFromText(text); + } + return { + rows: walk.rows, + cols: walk.cols, + 칸, + 병합: walk.merges, + 원점: null, + 출처: sheetsMode ? "google" : "excel", + }; +} + +function parseTsv(text: string): ClipBlock { + const lines = text.replace(/\r\n/g, "\n").split("\n"); + while (lines.length && lines[lines.length - 1] === "") lines.pop(); + const grid = lines.map((line) => line.split("\t")); + let cols = 0; + for (const row of grid) if (row.length > cols) cols = row.length; + const 칸: Record = {}; + grid.forEach((row, r) => { + row.forEach((text, c) => { + if (text === "") return; + 칸[`${r},${c}`] = cellFromText(text); + }); + }); + return { rows: grid.length, cols, 칸, 병합: [], 원점: null, 출처: "text" }; +} + +/** 클립보드 글 → 뭉치(HTML 먼저 · 없으면 TSV) · 읽을 것 없으면 null · `at` = 붙일 왼위(구글 R1C1 풀이). */ +export function parseClipboard(html: string, text: string, at: CellAddress): ClipBlock | null { + const aislo = //.exec(html); + if (aislo) { + try { + return JSON.parse(decodeURIComponent(aislo[1])) as ClipBlock; + } catch { + // 손상됐으면 아래 일반 경로로 폴백. + } + } + if (/ = {}; + const styleCmds: Command[] = []; + for (const [key, clip] of Object.entries(block.칸)) { + const [lr, lc] = key.split(",").map(Number); + const r = at.r + lr; + const c = at.c + lc; + const cell: Cell = {}; + if (clip.값 !== undefined) cell.값 = clip.값; + if (clip.식 !== undefined) cell.식 = shift ? moveFormula(clip.식, shift.dr, shift.dc) : clip.식; + 칸[toA1(r, c)] = Object.keys(cell).length ? cell : null; + if (clip.서식) { + styleCmds.push({ + 종류: "서식", + 시트: ctx.selection.시트, + 범위: [{ r0: r, c0: c, r1: r, c1: c }], + 바꿀: clip.서식, + }); + } + } + const commands: Command[] = [{ 종류: "칸", 시트: ctx.selection.시트, 칸 }, ...styleCmds]; + for (const m of block.병합) { + commands.push({ + 종류: "병합", + 시트: ctx.selection.시트, + 범위: { r0: at.r + m.r0, c0: at.c + m.c0, r1: at.r + m.r1, c1: at.c + m.c1 }, + }); + } + ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }); +} + +/** `ctx.root` 의 copy · cut · paste 를 받음(root null) */ +export function attachClipboard(ctx: SpreadsheetContext): PartHandle { + function onCopy(ev: ClipboardEvent): void { + if (ctx.editor.editing()) return; + const range = activeRange(ctx); + const { html, text } = blockToClipboard(ctx.book, ctx.selection.시트, range, (r, c) => + ctx.engine.value(ctx.selection.시트, r, c), + ); + ev.clipboardData?.setData("text/html", html); + ev.clipboardData?.setData("text/plain", text); + ev.preventDefault(); + } + + function onCut(ev: ClipboardEvent): void { + if (ctx.editor.editing() || ctx.readOnly) return; + onCopy(ev); + ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [activeRange(ctx)] }); + } + + function onPaste(ev: ClipboardEvent): void { + if (ctx.editor.editing() || ctx.readOnly) return; + const html = ev.clipboardData?.getData("text/html") ?? ""; + const text = ev.clipboardData?.getData("text/plain") ?? ""; + const at = ctx.selection.활성; + const block = parseClipboard(html, text, at); + if (!block) { + showToast(st("ClipboardPasteFailed"), "error"); + return; + } + ev.preventDefault(); + pasteBlock(ctx, block, at); + } + + ctx.root.addEventListener("copy", onCopy); + ctx.root.addEventListener("cut", onCut); + ctx.root.addEventListener("paste", onPaste); + + return { + root: null, + refresh() {}, + destroy() { + ctx.root.removeEventListener("copy", onCopy); + ctx.root.removeEventListener("cut", onCut); + ctx.root.removeEventListener("paste", onPaste); + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index 6b99c139..e9ae8e67 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -2,16 +2,210 @@ * spreadsheet_menu.ts (주인 E) * 우클릭 메뉴 — 칸 · 행 머리 · 열 머리마다: 잘라내기 · 복사 · (붙여넣기 = 「Ctrl+V 를 쓸 것」 안내) · * 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기. - * 0 계약 머리 — 몸은 E 가 채움. + * `ui_template_context_menu.ts` 재사용 — 우클릭 밖 자리는 그냥 눌러 선택부터 옮김. * ========================================================================== */ +import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { showToast } from "@ui/ui_template_elements"; +import { rangeToA1 } from "./spreadsheet_address"; +import { st } from "./spreadsheet_text"; +import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; -const todo = (): never => { - throw new Error("spreadsheet_menu: 아직 없음(E)"); -}; +import "./spreadsheet_toolbar.css"; + +function within(range: CellRange, r: number, c: number): boolean { + return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1; +} /** `ctx.root` 의 contextmenu 를 받음(root null) */ -export function attachMenu(_ctx: SpreadsheetContext): PartHandle { - return todo(); +export function attachMenu(ctx: SpreadsheetContext): PartHandle { + if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} }; + + const menu = createMapContextMenu("ss"); + ctx.root.append(menu.element); + + function isMerged(range: CellRange): boolean { + return (ctx.sheet().병합 ?? []).includes(rangeToA1(range)); + } + + function cutCopy(kind: "cut" | "copy"): void { + document.execCommand(kind); + } + + function cellMenuItems(range: CellRange): MapContextMenuItem[] { + return [ + [st("MenuCut"), () => cutCopy("cut")], + [st("MenuCopy"), () => cutCopy("copy")], + [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], + [ + st("MenuClearContent"), + () => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }), + ], + [ + st("MenuClearFormat"), + () => ctx.dispatch({ 종류: "서식지움", 시트: ctx.selection.시트, 범위: [range] }), + ], + [ + isMerged(range) ? st("MenuUnmerge") : st("MenuMerge"), + () => + ctx.dispatch( + isMerged(range) + ? { 종류: "병합풀기", 시트: ctx.selection.시트, 범위: range } + : { 종류: "병합", 시트: ctx.selection.시트, 범위: range }, + ), + ], + ]; + } + + function rowMenuItems(rows: number[]): MapContextMenuItem[] { + const at = Math.min(...rows); + const 수 = rows.length; + return [ + [st("MenuCopy"), () => cutCopy("copy")], + [ + st("MenuInsertRow"), + () => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }), + ], + [ + st("MenuDeleteRow"), + () => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }), + ], + [ + st("MenuHide"), + () => + ctx.dispatch({ + 종류: "숨김", + 시트: ctx.selection.시트, + 축: "행", + 번호: rows, + 숨김: true, + }), + ], + [ + st("MenuUnhide"), + () => + ctx.dispatch({ + 종류: "숨김", + 시트: ctx.selection.시트, + 축: "행", + 번호: rows, + 숨김: false, + }), + ], + ]; + } + + function colMenuItems(cols: number[]): MapContextMenuItem[] { + const at = Math.min(...cols); + const 수 = cols.length; + return [ + [st("MenuCopy"), () => cutCopy("copy")], + [ + st("MenuInsertCol"), + () => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }), + ], + [ + st("MenuDeleteCol"), + () => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }), + ], + [ + st("MenuHide"), + () => + ctx.dispatch({ + 종류: "숨김", + 시트: ctx.selection.시트, + 축: "열", + 번호: cols, + 숨김: true, + }), + ], + [ + st("MenuUnhide"), + () => + ctx.dispatch({ + 종류: "숨김", + 시트: ctx.selection.시트, + 축: "열", + 번호: cols, + 숨김: false, + }), + ], + ]; + } + + function range(r0: number, c0: number, r1: number, c1: number): CellRange { + return { r0, c0, r1, c1 }; + } + + function onContextMenu(ev: MouseEvent): void { + const hit = ctx.grid.hitTest(ev.clientX, ev.clientY); + if (!hit) return; + ev.preventDefault(); + const box = ctx.root.getBoundingClientRect(); + const x = ev.clientX - box.left; + const y = ev.clientY - box.top; + + if (hit.kind === "rowHead") { + const covered = ctx.selection.범위.find( + (rg) => rg.c0 === 0 && rg.c1 >= MAX_COLS - 1 && within(rg, hit.r, 0), + ); + const rows = covered + ? Array.from({ length: covered.r1 - covered.r0 + 1 }, (_, i) => covered.r0 + i) + : [hit.r]; + if (!covered) + ctx.select({ + 시트: ctx.selection.시트, + 범위: [range(hit.r, 0, hit.r, MAX_COLS - 1)], + 활성: { r: hit.r, c: 0 }, + 기준: { r: hit.r, c: 0 }, + }); + menu.open(x, y, rowMenuItems(rows)); + return; + } + + if (hit.kind === "colHead") { + const covered = ctx.selection.범위.find( + (rg) => rg.r0 === 0 && rg.r1 >= MAX_ROWS - 1 && within(rg, 0, hit.c), + ); + const cols = covered + ? Array.from({ length: covered.c1 - covered.c0 + 1 }, (_, i) => covered.c0 + i) + : [hit.c]; + if (!covered) + ctx.select({ + 시트: ctx.selection.시트, + 범위: [range(0, hit.c, MAX_ROWS - 1, hit.c)], + 활성: { r: 0, c: hit.c }, + 기준: { r: 0, c: hit.c }, + }); + menu.open(x, y, colMenuItems(cols)); + return; + } + + if (hit.kind === "cell") { + const covered = ctx.selection.범위.some((rg) => within(rg, hit.r, hit.c)); + if (!covered) { + ctx.select({ + 시트: ctx.selection.시트, + 범위: [range(hit.r, hit.c, hit.r, hit.c)], + 활성: { r: hit.r, c: hit.c }, + 기준: { r: hit.r, c: hit.c }, + }); + } + const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c); + menu.open(x, y, cellMenuItems(active)); + } + } + + ctx.root.addEventListener("contextmenu", onContextMenu); + + return { + root: menu.element, + refresh() {}, + destroy() { + ctx.root.removeEventListener("contextmenu", onContextMenu); + menu.close(); + menu.element.remove(); + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_tabs.ts b/A00_Common/spreadsheet/spreadsheet_tabs.ts index a52e143d..c82fda65 100644 --- a/A00_Common/spreadsheet/spreadsheet_tabs.ts +++ b/A00_Common/spreadsheet/spreadsheet_tabs.ts @@ -1,16 +1,164 @@ /* ============================================================================= * spreadsheet_tabs.ts (주인 E) - * 시트 탭 — 고르기(`ctx.showSheet`) · 더하기 · 이름 바꾸기(두 번 누르기) · 지우기 · 끌어 옮기기 · 복사. + * 시트 탭 — 고르기(`ctx.showSheet`) · 더하기 · 이름 바꾸기(두 번 누르기) · 지우기 · + * 끌어 옮기기 · 복사. 우클릭 메뉴는 `ui_template_context_menu.ts` 재사용. * 이름 바꾸기 · 지우기 · 옮기기는 명령(`시트이름` · `시트지우기` · `시트옮기기` · `시트더하기`). - * 0 계약 머리 — 몸은 E 가 채움. * ========================================================================== */ +import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { emptySheet } from "./spreadsheet_commands"; +import { st } from "./spreadsheet_text"; +import type { Sheet, Workbook } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; -const todo = (): never => { - throw new Error("spreadsheet_tabs: 아직 없음(E)"); -}; +import "./spreadsheet_toolbar.css"; -export function mountTabs(_ctx: SpreadsheetContext): PartHandle { - return todo(); +/** 없는 `s숫자` 하나 고름. */ +function nextSheetId(book: Workbook): string { + const used = new Set(book.시트.map((s) => s.id)); + let n = 1; + while (used.has(`s${n}`)) n++; + return `s${n}`; +} + +/** 「시트2」 · 「시트2 (2)」 처럼 안 겹치는 이름. */ +function nextSheetName(book: Workbook, base: string): string { + const used = new Set(book.시트.map((s) => s.이름)); + if (!used.has(base)) return base; + let n = 2; + while (used.has(`${base} (${n})`)) n++; + return `${base} (${n})`; +} + +export function mountTabs(ctx: SpreadsheetContext): PartHandle { + const menu = createMapContextMenu("ss"); + const root = el("div", { className: "ss-tabs", children: [menu.element] }); + + let dragging: string | null = null; + + function tabEl(id: string): HTMLElement | null { + return root.querySelector(`[data-sheet-id="${CSS.escape(id)}"]`); + } + + function render(): void { + root.replaceChildren(menu.element, ...ctx.book.시트.map(renderTab), addButton()); + } + + function renderTab(sheet: Sheet): HTMLElement { + const tab = el("div", { + className: `ss-tabs__tab${sheet.id === ctx.book.활성 ? " is-active" : ""}`, + text: sheet.이름, + attrs: { "data-sheet-id": sheet.id }, + }); + tab.draggable = true; + + tab.addEventListener("click", () => ctx.showSheet(sheet.id)); + tab.addEventListener("dblclick", () => beginRename(sheet)); + tab.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + const box = root.getBoundingClientRect(); + openMenu(sheet, ev.clientX - box.left, ev.clientY - box.top); + }); + + tab.addEventListener("dragstart", (ev) => { + dragging = sheet.id; + ev.dataTransfer?.setData("text/plain", sheet.id); + }); + tab.addEventListener("dragover", (ev) => { + if (!dragging || dragging === sheet.id) return; + ev.preventDefault(); + tab.classList.add("is-dragover"); + }); + tab.addEventListener("dragleave", () => tab.classList.remove("is-dragover")); + tab.addEventListener("drop", (ev) => { + ev.preventDefault(); + tab.classList.remove("is-dragover"); + if (!dragging || dragging === sheet.id) return; + const 자리 = ctx.book.시트.findIndex((s) => s.id === sheet.id); + ctx.dispatch({ 종류: "시트옮기기", 시트: dragging, 자리 }); + dragging = null; + }); + + return tab; + } + + function beginRename(sheet: Sheet): void { + const tab = tabEl(sheet.id); + if (!tab) return; + const input = el("input", { attrs: { value: sheet.이름 } }) as HTMLInputElement; + tab.replaceChildren(input); + input.focus(); + input.select(); + const commit = (): void => { + const name = input.value.trim(); + if (name && name !== sheet.이름) + ctx.dispatch({ 종류: "시트이름", 시트: sheet.id, 이름: name }); + else render(); + }; + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") input.blur(); + if (ev.key === "Escape") { + input.removeEventListener("blur", commit); + render(); + } + }); + input.addEventListener("blur", commit); + } + + function openMenu(sheet: Sheet, x: number, y: number): void { + const items: MapContextMenuItem[] = [ + [st("SheetRename"), () => beginRename(sheet)], + [ + st("SheetDuplicate"), + () => { + const copy: Sheet = { + ...structuredClone(sheet), + id: nextSheetId(ctx.book), + 이름: nextSheetName(ctx.book, sheet.이름), + }; + const 자리 = ctx.book.시트.findIndex((s) => s.id === sheet.id) + 1; + ctx.dispatch({ 종류: "시트더하기", 시트: copy, 자리 }); + }, + ], + [ + st("SheetDelete"), + async () => { + if (ctx.book.시트.length <= 1) { + showToast(st("SheetDeleteLastError"), "error"); + return; + } + if (await showConfirmDialog(st("SheetDeleteConfirm"))) { + ctx.dispatch({ 종류: "시트지우기", 시트: sheet.id }); + } + }, + ], + ]; + menu.open(x, y, items); + } + + function addButton(): HTMLElement { + const btn = el("button", { + className: "ss-tabs__add", + text: "+", + attrs: { type: "button", title: st("SheetAdd") }, + }); + btn.addEventListener("click", () => { + const name = nextSheetName(ctx.book, "Sheet"); + const sheet = emptySheet(nextSheetId(ctx.book), name); + ctx.dispatch({ 종류: "시트더하기", 시트: sheet, 자리: ctx.book.시트.length }); + }); + return btn; + } + + render(); + + return { + root, + refresh: render, + destroy() { + menu.close(); + root.remove(); + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts new file mode 100644 index 00000000..67546a24 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -0,0 +1,86 @@ +/* ============================================================================= + * spreadsheet_text.ts (주인 E) + * 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 글자 — [한국어, 영어]. + * `ui_template_sheet_text.ts` 와 같은 방식(`st(key)`). 0 계약 밖(E 만 씀). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +const TEXT = { + Undo: ["되돌리기", "Undo"], + Redo: ["다시", "Redo"], + FontName: ["글꼴", "Font"], + FontSize: ["크기", "Size"], + Bold: ["굵게", "Bold"], + Italic: ["기울임", "Italic"], + Underline: ["밑줄", "Underline"], + Strike: ["취소선", "Strikethrough"], + TextColor: ["글자색", "Text color"], + FillColor: ["채움색", "Fill color"], + ColorClear: ["지움", "Clear"], + HAlign: ["가로 정렬", "Horizontal align"], + VAlign: ["세로 정렬", "Vertical align"], + Align_general: ["일반", "General"], + Align_left: ["왼쪽", "Left"], + Align_center: ["가운데", "Center"], + Align_right: ["오른쪽", "Right"], + Align_distributed: ["균등 분할", "Distributed"], + Align_centerContinuous: ["선택 영역 가운데", "Center across"], + VAlign_top: ["위", "Top"], + VAlign_center: ["가운데", "Middle"], + VAlign_bottom: ["아래", "Bottom"], + Wrap: ["줄 바꿈", "Wrap text"], + Merge: ["병합", "Merge"], + Unmerge: ["병합 해제", "Unmerge"], + Border: ["테두리", "Border"], + Border_none: ["없음", "None"], + Border_all: ["모두", "All"], + Border_outer: ["바깥", "Outer"], + Border_top: ["위", "Top"], + Border_bottom: ["아래", "Bottom"], + Border_left: ["왼", "Left"], + Border_right: ["오른", "Right"], + BorderLine: ["선", "Line"], + BorderColor: ["색", "Color"], + NumberFormat: ["숫자 형식", "Number format"], + NumberFormat_general: ["일반", "General"], + NumberFormat_integer: ["정수 0", "Integer"], + NumberFormat_decimal2: ["소수 0.00", "Decimal 0.00"], + NumberFormat_thousands: ["천 단위 #,##0", "Thousands"], + NumberFormat_thousands2: ["천 단위 #,##0.00", "Thousands 0.00"], + NumberFormat_percent: ["백분율 0%", "Percent"], + NumberFormat_accounting: ["회계", "Accounting"], + NumberFormat_text: ["글자 @", "Text"], + NumberFormat_hint: [ + "이름 골라 쓰거나 엑셀 형식 코드를 바로 침", + "Pick a preset or type an Excel format code", + ], + SheetAdd: ["시트 더하기", "Add sheet"], + SheetRename: ["이름 바꾸기", "Rename"], + SheetDelete: ["지우기", "Delete"], + SheetDuplicate: ["복사", "Duplicate"], + SheetRenamePrompt: ["새 시트 이름", "New sheet name"], + SheetDeleteConfirm: [ + "이 시트를 지울까요? 되돌릴 수 없습니다.", + "Delete this sheet? This cannot be undone.", + ], + SheetDeleteLastError: ["마지막 시트는 지울 수 없음", "Cannot delete the last sheet"], + MenuCut: ["잘라내기", "Cut"], + MenuCopy: ["복사", "Copy"], + MenuPasteHint: ["Ctrl+V 를 쓸 것", "Use Ctrl+V"], + MenuInsertRow: ["행 넣기", "Insert row"], + MenuDeleteRow: ["행 지우기", "Delete row"], + MenuInsertCol: ["열 넣기", "Insert column"], + MenuDeleteCol: ["열 지우기", "Delete column"], + MenuHide: ["숨기기", "Hide"], + MenuUnhide: ["숨김 취소", "Unhide"], + MenuClearContent: ["내용 지우기", "Clear content"], + MenuClearFormat: ["서식 지우기", "Clear format"], + MenuMerge: ["병합", "Merge"], + MenuUnmerge: ["병합 해제", "Unmerge"], + ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"], +} as const; + +export function st(key: keyof typeof TEXT): string { + return TEXT[key][currentLanguageIndex] ?? TEXT[key][0]; +} diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.css b/A00_Common/spreadsheet/spreadsheet_toolbar.css new file mode 100644 index 00000000..864f26b1 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.css @@ -0,0 +1,217 @@ +/* ============================================================================= + * spreadsheet_toolbar.css (주인 E) + * 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 안내 토스트 — 화면 스타일 한 벌. + * 색 · 간격은 테마 토큰만(`ui_template_theme.css`). + * ========================================================================== */ + +.ss-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.ss-toolbar__group { + position: relative; + display: flex; + align-items: center; + gap: var(--spacing-4); + padding-right: var(--spacing-8); + border-right: 1px solid var(--color-border); +} + +.ss-toolbar__group:last-child { + border-right: 0; +} + +.ss-toolbar .ui-btn { + padding: 4px 8px; + font-size: 0.82rem; +} + +.ss-toolbar .ui-btn.is-on { + background: var(--color-mist-violet); + box-shadow: inset 0 0 0 1px var(--color-royal-amethyst); +} + +.ss-toolbar__select { + height: 28px; + padding: 0 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font-size: 0.8rem; +} + +.ss-toolbar__input { + height: 28px; + width: 72px; + padding: 0 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font-size: 0.8rem; +} + +.ss-toolbar__input--font { + width: 96px; +} + +.ss-toolbar__input--size { + width: 48px; +} + +.ss-toolbar__input--numfmt { + width: 140px; +} + +.ss-toolbar__color { + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: none; + cursor: pointer; +} + +.ss-toolbar__clear { + padding: 0 4px; + border: 0; + background: none; + color: var(--color-text-muted); + font-size: 0.72rem; + cursor: pointer; +} + +.ss-toolbar__popover { + position: absolute; + z-index: var(--z-dropdown); + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 180px; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.ss-toolbar__popover[hidden] { + display: none; +} + +.ss-toolbar__popover-row { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + +.ss-toolbar__popover-presets { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--spacing-4); +} + +/* ── 시트 탭 ─────────────────────────────────────────────────────────────── */ + +.ss-tabs { + position: relative; + display: flex; + align-items: center; + gap: 2px; + overflow-x: auto; + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.ss-tabs__tab { + display: flex; + align-items: center; + gap: var(--spacing-4); + padding: 4px 10px; + border: 1px solid var(--color-border); + border-bottom: 0; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + background: var(--color-paper); + color: var(--color-text-secondary); + font-size: 0.82rem; + white-space: nowrap; + cursor: pointer; +} + +.ss-tabs__tab.is-active { + background: var(--color-canvas); + color: var(--color-text-body); + font-weight: 600; +} + +.ss-tabs__tab.is-dragover { + box-shadow: inset 2px 0 0 var(--color-accent); +} + +.ss-tabs__tab input { + width: 96px; + border: 1px solid var(--color-focus-ring); + border-radius: var(--radius-sm); + font: inherit; +} + +.ss-tabs__add { + padding: 2px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-secondary); + cursor: pointer; +} + +/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) ───────────── */ + +.ss__context-menu { + position: absolute; + z-index: var(--z-dropdown); + display: flex; + flex-direction: column; + min-width: 160px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.ss__context-menu[hidden] { + display: none; +} + +.ss__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-body); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.ss__context-menu-item:hover { + background: var(--color-surface-sunken, var(--color-mist-violet)); +} + +.ss__context-menu-item.is-danger { + color: var(--color-danger); +} + +.ss__context-menu-sep { + margin: 2px 4px; + border-top: 1px solid var(--color-border); +} diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index 8bd4cef7..d388fcd3 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -1,17 +1,372 @@ /* ============================================================================= * spreadsheet_toolbar.ts (주인 E) - * 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 글자색 · 채움색 · 가로 · 세로 정렬 · - * 줄 바꿈 · 테두리(위치 × 선) · 숫자 형식 · 병합 · 틀고정 · 눈금선. 모두 `ctx.dispatch` 명령으로. + * 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 · + * 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식. + * 모두 `ctx.dispatch` 명령으로 — 이 파일은 문서를 직접 안 건드림. * 활성 칸 서식을 `refresh()` 때 단추 상태로. readOnly 면 안 붙임(root null). - * 0 계약 머리 — 몸은 E 가 채움. * ========================================================================== */ +import { createButton, el } from "@ui/ui_template_elements"; +import { colName, rangeToA1, toA1 } from "./spreadsheet_address"; +import { st } from "./spreadsheet_text"; +import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; -const todo = (): never => { - throw new Error("spreadsheet_toolbar: 아직 없음(E)"); -}; +import "./spreadsheet_toolbar.css"; -export function mountToolbar(_ctx: SpreadsheetContext): PartHandle { - return todo(); +type Side = "위" | "아래" | "왼" | "오른"; +type BorderPreset = "none" | "all" | "outer" | "top" | "bottom" | "left" | "right"; + +const FONTS = [ + "굴림", + "굴림체", + "맑은 고딕", + "나눔고딕", + "돋움", + "Arial", + "Calibri", + "Times New Roman", +]; + +const NUMFMT_PRESETS: [label: string, code: string][] = [ + [st("NumberFormat_general"), ""], + [st("NumberFormat_integer"), "0"], + [st("NumberFormat_decimal2"), "0.00"], + [st("NumberFormat_thousands"), "#,##0"], + [st("NumberFormat_thousands2"), "#,##0.00"], + [st("NumberFormat_percent"), "0%"], + [st("NumberFormat_accounting"), '_-* #,##0.00_-;-* #,##0.00_-;_-* "-"_-;_-@_-'], + [st("NumberFormat_text"), "@"], +]; + +const BORDER_LINES: BorderLine[] = [ + "hair", + "thin", + "medium", + "thick", + "double", + "dotted", + "dashed", +]; + +/** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 그다음 기본(0). */ +function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle { + const sheet = ctx.sheet(); + const cell = sheet.칸[toA1(r, c)]; + const idx = cell?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? 0; + return ctx.book.서식[idx] ?? {}; +} + +export function mountToolbar(ctx: SpreadsheetContext): PartHandle { + if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} }; + + const root = el("div", { className: "ss-toolbar" }); + + const group = (...children: HTMLElement[]): HTMLElement => + el("div", { className: "ss-toolbar__group", children }); + + const patch = (change: { [K in keyof CellStyle]?: CellStyle[K] | null }): void => { + ctx.dispatch({ + 종류: "서식", + 시트: ctx.selection.시트, + 범위: ctx.selection.범위, + 바꿀: change, + }); + }; + + // ── 되돌리기 · 다시 ──────────────────────────────────────────────────── + const undoBtn = createButton({ label: "↶", variant: "ghost", onClick: () => ctx.undo() }); + undoBtn.title = st("Undo"); + const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() }); + redoBtn.title = st("Redo"); + + // ── 글꼴 · 크기 ──────────────────────────────────────────────────────── + const fontInput = el("input", { + className: "ss-toolbar__input ss-toolbar__input--font", + attrs: { list: "ss-toolbar-fonts", "aria-label": st("FontName") }, + }) as HTMLInputElement; + const fontList = el("datalist", { + attrs: { id: "ss-toolbar-fonts" }, + children: FONTS.map((name) => el("option", { attrs: { value: name } })), + }); + fontInput.append(fontList); + const commitFont = (): void => patch({ 글꼴: fontInput.value.trim() || null }); + fontInput.addEventListener("change", commitFont); + + const sizeInput = el("input", { + className: "ss-toolbar__input ss-toolbar__input--size", + attrs: { type: "number", min: "1", max: "409", step: "0.5", "aria-label": st("FontSize") }, + }) as HTMLInputElement; + sizeInput.addEventListener("change", () => { + const n = Number(sizeInput.value); + patch({ 크기: sizeInput.value && Number.isFinite(n) && n > 0 ? n : null }); + }); + + // ── 굵게 · 기울임 · 밑줄 · 취소선 ────────────────────────────────────── + const boldBtn = createButton({ label: "B", variant: "ghost" }); + boldBtn.title = st("Bold"); + const italicBtn = createButton({ label: "I", variant: "ghost" }); + italicBtn.title = st("Italic"); + const underlineBtn = createButton({ label: "U", variant: "ghost" }); + underlineBtn.title = st("Underline"); + const strikeBtn = createButton({ label: "S", variant: "ghost" }); + strikeBtn.title = st("Strike"); + const toggles: [HTMLButtonElement, keyof CellStyle][] = [ + [boldBtn, "굵게"], + [italicBtn, "기울임"], + [underlineBtn, "밑줄"], + [strikeBtn, "취소선"], + ]; + for (const [btn, key] of toggles) { + btn.addEventListener("click", () => patch({ [key]: !btn.classList.contains("is-on") })); + } + + // ── 글자색 · 채움색 ──────────────────────────────────────────────────── + const textColor = el("input", { + className: "ss-toolbar__color", + attrs: { type: "color", "aria-label": st("TextColor"), value: "#000000" }, + }) as HTMLInputElement; + textColor.addEventListener("change", () => patch({ 글자색: textColor.value })); + const textColorClear = el("button", { + className: "ss-toolbar__clear", + text: "✕", + attrs: { type: "button", title: st("ColorClear") }, + }); + textColorClear.addEventListener("click", () => patch({ 글자색: null })); + + const fillColor = el("input", { + className: "ss-toolbar__color", + attrs: { type: "color", "aria-label": st("FillColor"), value: "#ffffff" }, + }) as HTMLInputElement; + fillColor.addEventListener("change", () => patch({ 채움: fillColor.value })); + const fillColorClear = el("button", { + className: "ss-toolbar__clear", + text: "✕", + attrs: { type: "button", title: st("ColorClear") }, + }); + fillColorClear.addEventListener("click", () => patch({ 채움: null })); + + // ── 가로 · 세로 정렬 · 줄 바꿈 ───────────────────────────────────────── + const HALIGN_LABEL = { + general: st("Align_general"), + left: st("Align_left"), + center: st("Align_center"), + right: st("Align_right"), + distributed: st("Align_distributed"), + centerContinuous: st("Align_centerContinuous"), + } as const; + const VALIGN_LABEL = { + top: st("VAlign_top"), + center: st("VAlign_center"), + bottom: st("VAlign_bottom"), + } as const; + + const hAlignSelect = el("select", { + className: "ss-toolbar__select", + attrs: { "aria-label": st("HAlign") }, + children: (Object.keys(HALIGN_LABEL) as (keyof typeof HALIGN_LABEL)[]).map((v) => + el("option", { attrs: { value: v }, text: HALIGN_LABEL[v] }), + ), + }) as HTMLSelectElement; + hAlignSelect.addEventListener("change", () => + patch({ 가로: hAlignSelect.value as CellStyle["가로"] }), + ); + + const vAlignSelect = el("select", { + className: "ss-toolbar__select", + attrs: { "aria-label": st("VAlign") }, + children: (Object.keys(VALIGN_LABEL) as (keyof typeof VALIGN_LABEL)[]).map((v) => + el("option", { attrs: { value: v }, text: VALIGN_LABEL[v] }), + ), + }) as HTMLSelectElement; + vAlignSelect.addEventListener("change", () => + patch({ 세로: vAlignSelect.value as CellStyle["세로"] }), + ); + + const wrapBtn = createButton({ label: st("Wrap"), variant: "ghost" }); + wrapBtn.addEventListener("click", () => patch({ 줄바꿈: !wrapBtn.classList.contains("is-on") })); + + // ── 병합 ─────────────────────────────────────────────────────────────── + const mergeBtn = createButton({ label: st("Merge"), variant: "ghost" }); + mergeBtn.addEventListener("click", () => { + const range = ctx.selection.범위[0]; + if (!range) return; + const merged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range)); + ctx.dispatch({ 종류: merged ? "병합풀기" : "병합", 시트: ctx.selection.시트, 범위: range }); + }); + + // ── 테두리 ─────────────────────────────────────────────────────────── + const borderBtn = createButton({ label: st("Border"), variant: "ghost" }); + const borderPop = el("div", { className: "ss-toolbar__popover", attrs: { hidden: "true" } }); + const lineSelect = el("select", { + className: "ss-toolbar__select", + children: BORDER_LINES.map((v) => el("option", { attrs: { value: v }, text: v })), + }) as HTMLSelectElement; + const lineColor = el("input", { + className: "ss-toolbar__color", + attrs: { type: "color", value: "#000000" }, + }) as HTMLInputElement; + const presetsRow = el("div", { className: "ss-toolbar__popover-presets" }); + const presets: [BorderPreset, string][] = [ + ["none", st("Border_none")], + ["all", st("Border_all")], + ["outer", st("Border_outer")], + ["top", st("Border_top")], + ["bottom", st("Border_bottom")], + ["left", st("Border_left")], + ["right", st("Border_right")], + ]; + for (const [preset, label] of presets) { + const b = createButton({ + label, + variant: "ghost", + onClick: () => { + applyBorderPreset(preset); + borderPop.hidden = true; + }, + }); + presetsRow.append(b); + } + borderPop.append( + el("div", { + className: "ss-toolbar__popover-row", + children: [el("span", { text: st("BorderLine") }), lineSelect], + }), + el("div", { + className: "ss-toolbar__popover-row", + children: [el("span", { text: st("BorderColor") }), lineColor], + }), + presetsRow, + ); + borderBtn.addEventListener("click", (ev) => { + ev.stopPropagation(); + borderPop.hidden = !borderPop.hidden; + }); + borderPop.addEventListener("click", (ev) => ev.stopPropagation()); + const closeBorderPop = (): void => { + borderPop.hidden = true; + }; + document.addEventListener("click", closeBorderPop); + + function cellsFor( + range: CellRange, + edge: BorderPreset, + ): { r: number; c: number; sides: Side[] }[] { + const { r0, c0, r1, c1 } = range; + const out: { r: number; c: number; sides: Side[] }[] = []; + for (let r = r0; r <= r1; r++) { + for (let c = c0; c <= c1; c++) { + const sides: Side[] = []; + if (edge === "all") sides.push("위", "아래", "왼", "오른"); + else { + if ((edge === "outer" || edge === "top") && r === r0) sides.push("위"); + if ((edge === "outer" || edge === "bottom") && r === r1) sides.push("아래"); + if ((edge === "outer" || edge === "left") && c === c0) sides.push("왼"); + if ((edge === "outer" || edge === "right") && c === c1) sides.push("오른"); + } + if (sides.length) out.push({ r, c, sides }); + } + } + return out; + } + + function applyBorderPreset(preset: BorderPreset): void { + const range = ctx.selection.범위[0]; + if (!range) return; + if (preset === "none") { + ctx.dispatch({ + 종류: "서식", + 시트: ctx.selection.시트, + 범위: ctx.selection.범위, + 바꿀: { 테두리: null }, + }); + return; + } + const style: BorderSide = { 선: lineSelect.value as BorderLine, 색: lineColor.value }; + const cmds: Command[] = cellsFor(range, preset).map(({ r, c, sides }) => { + const cur = { ...(styleAt(ctx, r, c).테두리 ?? {}) }; + for (const s of sides) cur[s] = style; + return { + 종류: "서식", + 시트: ctx.selection.시트, + 범위: [{ r0: r, c0: c, r1: r, c1: c }], + 바꿀: { 테두리: cur }, + }; + }); + if (cmds.length === 1) ctx.dispatch(cmds[0]); + else if (cmds.length > 1) ctx.dispatch({ 종류: "묶음", 명령: cmds }); + } + + // ── 숫자 형식 ───────────────────────────────────────────────────────── + const numfmtInput = el("input", { + className: "ss-toolbar__input ss-toolbar__input--numfmt", + attrs: { + list: "ss-toolbar-numfmt", + "aria-label": st("NumberFormat"), + title: st("NumberFormat_hint"), + }, + }) as HTMLInputElement; + const numfmtList = el("datalist", { + attrs: { id: "ss-toolbar-numfmt" }, + children: NUMFMT_PRESETS.map(([label, code]) => + el("option", { + attrs: { value: code }, + text: `${label} (${code || st("NumberFormat_general")})`, + }), + ), + }); + numfmtInput.append(numfmtList); + numfmtInput.addEventListener("change", () => patch({ 형식: numfmtInput.value || null })); + + root.append( + group(undoBtn, redoBtn), + group(fontInput, sizeInput), + group(boldBtn, italicBtn, underlineBtn, strikeBtn), + group(textColor, textColorClear, fillColor, fillColorClear), + group(hAlignSelect, vAlignSelect, wrapBtn), + group(mergeBtn), + group(borderBtn, borderPop), + group(numfmtInput), + ); + + function refresh(): void { + undoBtn.disabled = !ctx.history.canUndo(); + redoBtn.disabled = !ctx.history.canRedo(); + const { r, c } = ctx.selection.활성; + const style = styleAt(ctx, r, c); + fontInput.value = style.글꼴 ?? ""; + sizeInput.value = style.크기 != null ? String(style.크기) : ""; + boldBtn.classList.toggle("is-on", !!style.굵게); + italicBtn.classList.toggle("is-on", !!style.기울임); + underlineBtn.classList.toggle("is-on", !!style.밑줄); + strikeBtn.classList.toggle("is-on", !!style.취소선); + textColor.value = style.글자색 ?? "#000000"; + fillColor.value = style.채움 ?? "#ffffff"; + hAlignSelect.value = style.가로 ?? "general"; + vAlignSelect.value = style.세로 ?? "top"; + wrapBtn.classList.toggle("is-on", !!style.줄바꿈); + numfmtInput.value = style.형식 ?? ""; + const range = ctx.selection.범위[0]; + mergeBtn.textContent = ""; + mergeBtn.append( + el("span", { + className: "ui-btn__label", + text: + range && (ctx.sheet().병합 ?? []).includes(rangeToA1(range)) + ? st("Unmerge") + : st("Merge"), + }), + ); + } + + refresh(); + + return { + root, + refresh, + destroy() { + document.removeEventListener("click", closeBorderPop); + root.remove(); + }, + }; } diff --git a/resources/tester/spreadsheet/e_harness.ts b/resources/tester/spreadsheet/e_harness.ts new file mode 100644 index 00000000..e6cee49e --- /dev/null +++ b/resources/tester/spreadsheet/e_harness.ts @@ -0,0 +1,277 @@ +/* ============================================================================= + * e_harness.ts — E(도구 모음 · 탭 · 메뉴 · 클립보드) 화면 시험 틀. + * A(주소 · 명령 · 참조 옮김) · B(숫자 형식) 는 아직 0 계약 몸만 있어 `fakes/`(진짜 대신 최소 + * 흉내)를 `vite.harness.config.ts` 별칭으로 끼움. dispatch 는 이 파일이 직접 문서에 적용해 + * 화면에 곧바로 비침 — 진짜 A `applyCommand` 가 아님(시험 전용). + * ========================================================================== */ + +import { mountToolbar } from "../../../A00_Common/spreadsheet/spreadsheet_toolbar"; +import { mountTabs } from "../../../A00_Common/spreadsheet/spreadsheet_tabs"; +import { attachMenu } from "../../../A00_Common/spreadsheet/spreadsheet_menu"; +import { + attachClipboard, + parseClipboard, +} from "../../../A00_Common/spreadsheet/spreadsheet_clipboard"; +import type { + Cell, + CellRange, + Command, + Sheet, + Workbook, +} from "../../../A00_Common/spreadsheet/spreadsheet_types"; +import type { + GridHandle, + Selection, + SpreadsheetContext, +} from "../../../A00_Common/spreadsheet/spreadsheet_view_types"; +import { colName, rangeToA1, toA1 } from "./fakes/fake_address"; + +const log: string[] = []; +const logEl = document.querySelector("#log")!; +function say(msg: string): void { + log.push(msg); + logEl.textContent = log.join("\n"); +} + +const book: Workbook = { + 종류: "통합문서", + 판: 1, + 열: "test", + 서식: [{}, { 굵게: true }], + 시트: [ + { + id: "s1", + 이름: "측구수로400", + 칸: { + A1: { 값: "터파기 계산서", 서식: 1 }, + B1: { 값: 12.5 }, + C1: { 식: "B1*2" }, + }, + 병합: [], + }, + ], + 활성: "s1", +}; + +function forEachCell(range: CellRange, fn: (r: number, c: number) => void): void { + for (let r = range.r0; r <= range.r1; r++) for (let c = range.c0; c <= range.c1; c++) fn(r, c); +} + +function styleIndexAt(sheet: Sheet, r: number, c: number): number { + return ( + sheet.칸[toA1(r, c)]?.서식 ?? + sheet.열?.[colName(c)]?.서식 ?? + sheet.행?.[String(r + 1)]?.서식 ?? + 0 + ); +} + +function internStyle(book: Workbook, style: Record): number { + const json = JSON.stringify(style); + let idx = book.서식.findIndex((s) => JSON.stringify(s) === json); + if (idx === -1) { + book.서식.push(style as never); + idx = book.서식.length - 1; + } + return idx; +} + +/** dispatch 가 흉내로 적용 — 진짜 A `applyCommand` 아님(시험 전용, 모양만 맞춤). */ +function applyMock(cmd: Command): void { + switch (cmd.종류) { + case "묶음": + cmd.명령.forEach(applyMock); + return; + case "서식": { + const sheet = book.시트.find((s) => s.id === cmd.시트)!; + cmd.범위.forEach((range) => + forEachCell(range, (r, c) => { + const cur = { ...(book.서식[styleIndexAt(sheet, r, c)] ?? {}) }; + for (const [k, v] of Object.entries(cmd.바꿀)) { + if (v === null) delete (cur as Record)[k]; + else (cur as Record)[k] = v; + } + const key = toA1(r, c); + sheet.칸[key] = { ...sheet.칸[key], 서식: internStyle(book, cur) }; + }), + ); + return; + } + case "서식지움": { + const sheet = book.시트.find((s) => s.id === cmd.시트)!; + cmd.범위.forEach((range) => + forEachCell(range, (r, c) => { + const key = toA1(r, c); + if (sheet.칸[key]) delete sheet.칸[key].서식; + }), + ); + return; + } + case "내용지움": { + const sheet = book.시트.find((s) => s.id === cmd.시트)!; + cmd.범위.forEach((range) => + forEachCell(range, (r, c) => { + const key = toA1(r, c); + const cell = sheet.칸[key]; + if (!cell) return; + delete cell.값; + delete cell.식; + if (!Object.keys(cell).length) delete sheet.칸[key]; + }), + ); + return; + } + case "칸": { + const sheet = book.시트.find((s) => s.id === cmd.시트)!; + for (const [key, val] of Object.entries(cmd.칸)) { + if (val === null) delete sheet.칸[key]; + else sheet.칸[key] = val as Cell; + } + return; + } + case "병합": { + const sheet = book.시트.find((s) => s.id === cmd.시트)!; + sheet.병합 = [...(sheet.병합 ?? []), rangeToA1(cmd.범위)]; + return; + } + case "병합풀기": { + const sheet = book.시트.find((s) => s.id === cmd.시트)!; + const a1 = rangeToA1(cmd.범위); + sheet.병합 = (sheet.병합 ?? []).filter((m) => m !== a1); + return; + } + case "시트더하기": + book.시트.splice(cmd.자리, 0, cmd.시트); + return; + case "시트지우기": + book.시트 = book.시트.filter((s) => s.id !== cmd.시트); + if (book.활성 === cmd.시트) book.활성 = book.시트[0]?.id; + return; + case "시트이름": + book.시트.find((s) => s.id === cmd.시트)!.이름 = cmd.이름; + return; + case "시트옮기기": { + const idx = book.시트.findIndex((s) => s.id === cmd.시트); + const [sheet] = book.시트.splice(idx, 1); + book.시트.splice(cmd.자리, 0, sheet); + return; + } + default: + return; + } +} + +const selection: Selection = { + 시트: "s1", + 범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }], + 활성: { r: 0, c: 0 }, + 기준: { r: 0, c: 0 }, +}; + +const root = document.querySelector("#ss-root")!; + +const grid: GridHandle = { + root, + render() {}, + invalidate() {}, + renderSelection() {}, + setRefHighlights() {}, + cellBox: () => ({ x: 0, y: 0, w: 60, h: 20 }), + hitTest: (clientX) => { + const box = root.getBoundingClientRect(); + const x = clientX - box.left; + if (x < 30) return { kind: "rowHead", r: 0, c: 0 }; + if (x < 60) return { kind: "colHead", r: 0, c: 0 }; + return { kind: "cell", r: selection.활성.r, c: selection.활성.c }; + }, + reveal() {}, + visibleRange: () => ({ r0: 0, c0: 0, r1: 10, c1: 10 }), + editorSlot: () => ({ layer: root, box: { x: 0, y: 0, w: 60, h: 20 } }), + destroy() {}, +}; + +const ctx: SpreadsheetContext = { + root, + book, + readOnly: false, + engine: { + update: () => [], + rebuild() {}, + value: (sheetId, r, c) => { + const sheet = book.시트.find((s) => s.id === sheetId); + const cell = sheet?.칸[toA1(r, c)]; + if (!cell) return null; + if (cell.식 !== undefined) return { n: 999n, d: 1n }; + if (typeof cell.값 === "number") return { n: BigInt(Math.round(cell.값 * 100)), d: 100n }; + if (cell.값 === undefined) return null; + return cell.값; + }, + precedents: () => [], + snapshot: () => ({}), + }, + history: { + push() {}, + undo: () => null, + redo: () => null, + canUndo: () => false, + canRedo: () => false, + }, + selection, + sheet: () => book.시트.find((s) => s.id === selection.시트)!, + dispatch(command) { + say(`dispatch: ${JSON.stringify(command)}`); + applyMock(command); + toolbar.refresh(); + tabs.refresh(); + }, + undo() { + say("undo"); + }, + redo() { + say("redo"); + }, + select(sel) { + Object.assign(selection, sel); + say(`select: ${JSON.stringify(sel)}`); + toolbar.refresh(); + }, + showSheet(sheetId) { + book.활성 = sheetId; + selection.시트 = sheetId; + say(`showSheet: ${sheetId}`); + tabs.refresh(); + toolbar.refresh(); + }, + grid, + editor: { + editing: () => false, + begin() {}, + commit() {}, + cancel() {}, + sync() {}, + }, +}; + +const toolbar = mountToolbar(ctx); +const tabs = mountTabs(ctx); +attachMenu(ctx); +attachClipboard(ctx); + +document.querySelector("#toolbar-slot")!.append(toolbar.root!); +document.querySelector("#tabs-slot")!.append(tabs.root!); + +say("mounted — 도구 모음 · 탭 · 메뉴 · 클립보드 붙음"); + +(window as unknown as { __ctx: unknown; __book: unknown }).__ctx = ctx; +(window as unknown as { __ctx: unknown; __book: unknown }).__book = book; + +// 클립보드 밖 → 안 시험 — 자리 표시자에 실제로 붙여넣기(Ctrl+V)를 받아 결과를 보임. +const pasteZone = document.querySelector("#paste-zone")!; +const pasteOut = document.querySelector("#paste-out")!; +pasteZone.addEventListener("paste", (ev) => { + ev.preventDefault(); + const html = ev.clipboardData?.getData("text/html") ?? ""; + const text = ev.clipboardData?.getData("text/plain") ?? ""; + const block = parseClipboard(html, text, { r: 0, c: 0 }); + pasteOut.textContent = `html:\n${html}\n\ntext:\n${text}\n\nblock:\n${JSON.stringify(block, null, 2)}`; +}); diff --git a/resources/tester/spreadsheet/fakes/fake_address.ts b/resources/tester/spreadsheet/fakes/fake_address.ts new file mode 100644 index 00000000..b46dc19c --- /dev/null +++ b/resources/tester/spreadsheet/fakes/fake_address.ts @@ -0,0 +1,78 @@ +/* ============================================================================= + * fake_address.ts — E 시험 전용 가짜(A 몫 `spreadsheet_address.ts` 대역). + * A 가 아직 없어(0 계약 직후) 도구 모음 · 탭 · 메뉴가 `toA1` 등을 부르면 던지므로 + * 화면 시험에서만 `vite.harness.config.ts` 별칭으로 이것을 끼움. 진짜 코드는 안 건드림. + * ========================================================================== */ + +import type { CellAddress, CellRange } from "../../../../A00_Common/spreadsheet/spreadsheet_types"; + +export function colName(c: number): string { + let n = c + 1; + let s = ""; + while (n > 0) { + const rem = (n - 1) % 26; + s = String.fromCharCode(65 + rem) + s; + n = Math.floor((n - 1) / 26); + } + return s; +} + +export function colIndex(letters: string): number { + let n = 0; + for (const ch of letters.toUpperCase()) { + const d = ch.charCodeAt(0) - 64; + if (d < 1 || d > 26) return -1; + n = n * 26 + d; + } + return n - 1; +} + +export function toA1(r: number, c: number): string { + return `${colName(c)}${r + 1}`; +} + +export function parseA1(text: string): CellAddress | null { + const m = /^\$?([A-Za-z]+)\$?(\d+)$/.exec(text.trim()); + if (!m) return null; + const c = colIndex(m[1]); + const r = Number(m[2]) - 1; + if (c < 0 || r < 0) return null; + return { r, c }; +} + +export function rangeToA1(range: CellRange): string { + const a = toA1(range.r0, range.c0); + const b = toA1(range.r1, range.c1); + return a === b ? a : `${a}:${b}`; +} + +export function parseRange(text: string): CellRange | null { + const t = text.trim(); + if (t.includes(":")) { + const [a, b] = t.split(":"); + const pa = parseA1(a); + const pb = parseA1(b); + if (!pa || !pb) return null; + 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), + }; + } + const p = parseA1(t); + return p ? { r0: p.r, c0: p.c, r1: p.r, c1: p.c } : null; +} + +export function quoteSheet(name: string): string { + return /^[A-Za-z0-9_]+$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`; +} + +const MAX_COLS = 16_384; +export function cellId(r: number, c: number): number { + return r * MAX_COLS + c; +} + +export function fromCellId(id: number): CellAddress { + return { r: Math.floor(id / MAX_COLS), c: id % MAX_COLS }; +} diff --git a/resources/tester/spreadsheet/fakes/fake_commands.ts b/resources/tester/spreadsheet/fakes/fake_commands.ts new file mode 100644 index 00000000..822b604c --- /dev/null +++ b/resources/tester/spreadsheet/fakes/fake_commands.ts @@ -0,0 +1,10 @@ +/* ============================================================================= + * fake_commands.ts — E 시험 전용 가짜(A 몫 `spreadsheet_commands.ts` 대역). + * 탭의 [+] 가 부르는 `emptySheet` 만 흉내(진짜 `applyCommand` 는 안 씀 · dispatch 는 harness 가 spy). + * ========================================================================== */ + +import type { Sheet } from "../../../../A00_Common/spreadsheet/spreadsheet_types"; + +export function emptySheet(id: string, name: string): Sheet { + return { id, 이름: name, 칸: {} }; +} diff --git a/resources/tester/spreadsheet/fakes/fake_numfmt.ts b/resources/tester/spreadsheet/fakes/fake_numfmt.ts new file mode 100644 index 00000000..72bcb3f1 --- /dev/null +++ b/resources/tester/spreadsheet/fakes/fake_numfmt.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * fake_numfmt.ts — E 시험 전용 가짜(B 몫 `spreadsheet_numfmt.ts` 대역). + * 클립보드 「안 → 밖」 시험이 보이는 글을 만들 수 있도록 아주 단순한 형식만 흉내. + * ========================================================================== */ + +import type { FormattedText, Scalar } from "../../../../A00_Common/spreadsheet/spreadsheet_types"; + +export function formatValue(value: Scalar, code: string | undefined): FormattedText { + if (value === null) return { 글: "", 정렬: "left" }; + if (typeof value === "boolean") return { 글: value ? "TRUE" : "FALSE", 정렬: "center" }; + if (typeof value === "string") return { 글: value, 정렬: "left" }; + if (typeof value === "object" && "error" in value) return { 글: value.error, 정렬: "center" }; + const num = Number(value.n) / Number(value.d); + const text = + code === "0.00" ? num.toFixed(2) : code === "0" ? String(Math.round(num)) : String(num); + return { 글: text, 정렬: "right" }; +} diff --git a/resources/tester/spreadsheet/fakes/fake_refshift.ts b/resources/tester/spreadsheet/fakes/fake_refshift.ts new file mode 100644 index 00000000..e6240b6f --- /dev/null +++ b/resources/tester/spreadsheet/fakes/fake_refshift.ts @@ -0,0 +1,41 @@ +/* ============================================================================= + * fake_refshift.ts — E 시험 전용 가짜(A 몫 `spreadsheet_refshift.ts` 대역). + * 클립보드 붙여넣기 시험이 상대 참조 옮김 · 구글 R1C1 을 확인할 수 있도록 아주 단순히 흉내. + * ========================================================================== */ + +import { colIndex, colName } from "./fake_address"; +import type { CellAddress } from "../../../../A00_Common/spreadsheet/spreadsheet_types"; + +/** 복사 · 붙여넣기 — `$` 없는 A1 참조만 (dr, dc) 옮김. */ +export function moveFormula(formula: string, dr: number, dc: number): string { + return formula.replace(/(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g, (_m, absCol, col, absRow, row) => { + let c = colIndex(col); + let r = Number(row) - 1; + if (!absCol) c += dc; + if (!absRow) r += dr; + if (c < 0 || r < 0) return "#REF!"; + return `${absCol}${colName(c)}${absRow}${r + 1}`; + }); +} + +/** 구글 `R[0]C[-1]` 꼴 → 붙일 자리(`at`) 기준 A1 식. */ +export function r1c1ToA1(formula: string, at: CellAddress): string { + return formula.replace( + /R(\[-?\d+\]|\d+)?C(\[-?\d+\]|\d+)?/g, + (_m, rp: string | undefined, cp: string | undefined) => { + const row = + rp === undefined + ? at.r + 1 + : rp.startsWith("[") + ? at.r + 1 + Number(rp.slice(1, -1)) + : Number(rp); + const col = + cp === undefined + ? at.c + 1 + : cp.startsWith("[") + ? at.c + 1 + Number(cp.slice(1, -1)) + : Number(cp); + return `${colName(col - 1)}${row}`; + }, + ); +} diff --git a/resources/tester/spreadsheet/index.html b/resources/tester/spreadsheet/index.html new file mode 100644 index 00000000..dde6ee7e --- /dev/null +++ b/resources/tester/spreadsheet/index.html @@ -0,0 +1,52 @@ + + + + + + E 화면 시험 — 도구 모음 · 탭 · 메뉴 · 클립보드 + + + + +

E 화면 시험 틀

+
+
+

격자 자리 표시자 — 왼쪽 30px = 행 머리 · 다음 30px = 열 머리 · 나머지 = 칸(우클릭 시험)

+
+
+

붙여넣기 시험 — 여기 눌러 포커스 뒤 Ctrl+V

+
여기 클릭 뒤 Ctrl+V
+

+    

로그

+

+    
+  
+
diff --git a/resources/tester/spreadsheet/test_spreadsheet_clipboard_text.mjs b/resources/tester/spreadsheet/test_spreadsheet_clipboard_text.mjs
new file mode 100644
index 00000000..cd1d385f
--- /dev/null
+++ b/resources/tester/spreadsheet/test_spreadsheet_clipboard_text.mjs
@@ -0,0 +1,63 @@
+/* spreadsheet_clipboard.ts 의 TSV(밖 → 안, DOM 없이 되는 부분)만 — 값 · 참거짓 · 「엑셀 수식
+   표시」 식 잡기(끝수 · 「=」 한 글자는 글). HTML(DOMParser) 쪽은 ORCA 화면 시험으로.
+   진짜 앱 별칭(`@ui` 등)까지 그대로 vite 로 번들해 Node 로 돌린다(`build:formula` 방식) —
+   A(주소) · B(형식) 는 부르지 않는 길만 시험하므로 0 계약 몸(던짐)이어도 됨. */
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { pathToFileURL } from "node:url";
+
+const out = mkdtempSync(join(tmpdir(), "aislo-ss-clip-"));
+execFileSync(
+  process.execPath,
+  [
+    "./config/node_modules/vite/bin/vite.js",
+    "build",
+    "--configLoader",
+    "runner",
+    "--ssr",
+    "spreadsheet/spreadsheet_clipboard.ts",
+    "--outDir",
+    out,
+  ],
+  { stdio: "inherit" },
+);
+const { parseClipboard } = await import(pathToFileURL(join(out, "spreadsheet_clipboard.js")).href);
+
+const at = { r: 0, c: 0 };
+
+// 값 · 참거짓 잡기
+{
+  const block = parseClipboard("", "10\t20\nTRUE\t3.5", at);
+  assert.equal(block.rows, 2);
+  assert.equal(block.cols, 2);
+  assert.deepEqual(block.칸["0,0"], { 값: 10 });
+  assert.deepEqual(block.칸["1,0"], { 값: true });
+  assert.deepEqual(block.칸["1,1"], { 값: 3.5 });
+  assert.equal(block.출처, "text");
+  assert.equal(block.원점, null);
+}
+
+// 「=」 뒤에 글이 있으면 식 · 한 글자 「=」 는 글(엑셀 「수식 표시」 대응)
+{
+  const block = parseClipboard("", "=SUM(A1:A2)\t=", at);
+  assert.deepEqual(block.칸["0,0"], { 식: "SUM(A1:A2)" });
+  assert.deepEqual(block.칸["0,1"], { 값: "=" });
+}
+
+// 빈 칸은 안 적음(성긴 모양) · 끝 빈 줄 버림
+{
+  const block = parseClipboard("", "a\t\tb\n\n", at);
+  assert.equal(Object.keys(block.칸).length, 2);
+  assert.equal(block.rows, 1);
+  assert.equal(block.cols, 3);
+}
+
+// 읽을 것 없으면 null
+{
+  assert.equal(parseClipboard("", "", at), null);
+}
+
+console.log("OK — 클립보드 TSV 값 · 참거짓 · 「수식 표시」 식 잡기");
diff --git a/resources/tester/spreadsheet/vite.harness.config.ts b/resources/tester/spreadsheet/vite.harness.config.ts
new file mode 100644
index 00000000..8b47d9a0
--- /dev/null
+++ b/resources/tester/spreadsheet/vite.harness.config.ts
@@ -0,0 +1,35 @@
+import { resolve } from "node:path";
+import { tmpdir } from "node:os";
+import { fileURLToPath } from "node:url";
+
+const __dirname = fileURLToPath(new URL(".", import.meta.url));
+const repoRoot = resolve(__dirname, "../../..");
+
+/* =============================================================================
+ * vite.harness.config.ts — E 화면 시험 전용 빌드(정본 `vite.config.ts` 는 안 건드림).
+ * A(`spreadsheet_address`) · A(`spreadsheet_commands`) 가 아직 0 계약 몸만 있어(던짐) 이
+ * 폴더의 가짜로 바꿔 끼움 — E 가 만든 화면 부품만 시험. 진짜 앱 빌드에는 안 쓰임.
+ * ========================================================================== */
+export default {
+  root: __dirname,
+  publicDir: false,
+  resolve: {
+    alias: [
+      { find: "./spreadsheet_address", replacement: resolve(__dirname, "./fakes/fake_address.ts") },
+      {
+        find: "./spreadsheet_commands",
+        replacement: resolve(__dirname, "./fakes/fake_commands.ts"),
+      },
+      { find: "./spreadsheet_numfmt", replacement: resolve(__dirname, "./fakes/fake_numfmt.ts") },
+      {
+        find: "./spreadsheet_refshift",
+        replacement: resolve(__dirname, "./fakes/fake_refshift.ts"),
+      },
+      { find: "@ui", replacement: resolve(repoRoot, "./ui_template") },
+    ],
+  },
+  build: {
+    outDir: resolve(tmpdir(), "aislo_e_harness_dist"),
+    emptyOutDir: true,
+  },
+};

From 48a213513217d5663a8932b61127b60be8ef487a Mon Sep 17 00:00:00 2001
From: umsangdon 
Date: Sun, 27 Sep 2026 20:33:55 +0900
Subject: [PATCH 8/9] =?UTF-8?q?feat(spreadsheet):=20D=20=EC=9E=87=EA=B8=B0?=
 =?UTF-8?q?=20=E2=80=94=20=EC=A7=84=EC=A7=9C=20A=20=EC=97=94=EC=A7=84?=
 =?UTF-8?q?=EA=B3=BC=20=EC=9E=AC=EA=B2=80=EC=A6=9D=20=C2=B7=20inspect=20?=
 =?UTF-8?q?=EC=84=A0=ED=83=9D(window.=5F=5FaisloSheet=20=C2=B7=20=EC=9E=84?=
 =?UTF-8?q?=EC=8B=9C)=20=C2=B7=20=EC=8B=9C=ED=97=98=20=ED=8B=80=20?=
 =?UTF-8?q?=ED=95=9C=20=EB=B2=88=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- spreadsheet.ts — `inspect` 선택이면 문맥을 window.__aisloSheet 에 걺(M02 [스프레드시트 시험] 단추 전용 · destroy 때 뗌 · 1단계 검증 뒤 지움)
- 시험 틀 — ssScenario()(페이지 안 한 번에 한글 · 가리키기 · 재계산 · 되돌리기 · Delete · Ctrl+Enter · 수식 입력줄 · F2) · 가짜 격자 data-addr · 진짜 분수 값 보임 · 오류 모음

Co-Authored-By: Claude Opus 5.5 
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
---
 A00_Common/spreadsheet/spreadsheet.ts         |  12 ++
 .../spreadsheet/harness_spreadsheet_d.cjs     | 108 ++++++++++++++++++
 .../spreadsheet/harness_spreadsheet_d.html    |   6 +
 .../spreadsheet/spreadsheet_d_fakes.cjs       |   9 +-
 4 files changed, 133 insertions(+), 2 deletions(-)

diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts
index 061d08b7..25f43b0e 100644
--- a/A00_Common/spreadsheet/spreadsheet.ts
+++ b/A00_Common/spreadsheet/spreadsheet.ts
@@ -48,6 +48,16 @@ export interface SpreadsheetOptions {
   onChange?: (book: Workbook) => void;
   /** 고름이 바뀜 — 시트 id · 활성 칸 A1 · 범위 A1 */
   onSelect?: (selection: { 시트: string; 칸: string; 범위: string }) => void;
+  /** ⚠ 임시 — 브레인 브라우저 검증용: 문맥을 `window.__aisloSheet` 에 걺(book · 고름 · 명령 읽기).
+   * M02 [스프레드시트 시험] 단추만 켬 · 산출근거 카드는 안 켬 · 1단계 검증 뒤 지움. */
+  inspect?: boolean;
+}
+
+declare global {
+  interface Window {
+    /** ⚠ 임시 — `inspect` 로 연 표의 문맥(브레인 검증용) */
+    __aisloSheet?: SpreadsheetContext;
+  }
 }
 
 export interface SpreadsheetHandle {
@@ -139,6 +149,7 @@ export function createSpreadsheet(
 
   grid.render();
   select(ctx.selection);
+  if (opts.inspect) window.__aisloSheet = ctx; // ⚠ 임시 — 브레인 검증용
 
   function notify(message: string): void {
     toast.textContent = message;
@@ -244,6 +255,7 @@ export function createSpreadsheet(
       return ctx.engine.snapshot();
     },
     destroy() {
+      if (window.__aisloSheet === ctx) delete window.__aisloSheet;
       clearTimeout(toastTimer);
       detachKeys();
       detachMouse();
diff --git a/resources/tester/spreadsheet/harness_spreadsheet_d.cjs b/resources/tester/spreadsheet/harness_spreadsheet_d.cjs
index 8d220b14..01fee11f 100644
--- a/resources/tester/spreadsheet/harness_spreadsheet_d.cjs
+++ b/resources/tester/spreadsheet/harness_spreadsheet_d.cjs
@@ -10,8 +10,116 @@ window.ssSample = JSON.parse(JSON.stringify(sample));
 window.ss = createSpreadsheet(host, JSON.parse(JSON.stringify(sample)), {
   onChange: () => window.ssChanges++,
   onSelect: (s) => (window.ssSelect = s),
+  inspect: true,
 });
 window.ssCreate = createSpreadsheet;
 /** 칸 입력 값(A1) */
 window.ssCell = (a1, sheet = "s1") =>
   window.ss.getDoc().시트.find((s) => s.id === sheet).칸[a1] ?? null;
+
+/* 한 번에 도는 시나리오 — 탭이 새로 읽혀도 끊기지 않게 페이지 안에서 합성 사건으로 조작하고 수치를 모음.
+ * (믿음 입력 — 진짜 키 · 마우스 · 한글 — 은 ORCA keypress · type · mouse 로 따로 잼) */
+window.ssScenario = () => {
+  const ta = () => document.querySelector(".ss-input");
+  const at = (a1) => {
+    const d = document.querySelector(`[data-a1="${a1}"]`).getBoundingClientRect();
+    return {
+      clientX: d.left + d.width / 2,
+      clientY: d.top + d.height / 2,
+      bubbles: true,
+      button: 0,
+    };
+  };
+  const grid = () => document.querySelector(".ss-grid-host");
+  const click = (a1, extra = {}) => {
+    grid().dispatchEvent(new MouseEvent("mousedown", { ...at(a1), ...extra }));
+    window.dispatchEvent(new MouseEvent("mouseup", at(a1)));
+  };
+  const dragTo = (from, to) => {
+    grid().dispatchEvent(new MouseEvent("mousedown", from));
+    window.dispatchEvent(new MouseEvent("mousemove", to));
+    window.dispatchEvent(new MouseEvent("mouseup", to));
+  };
+  const key = (k, o = {}) =>
+    document.activeElement.dispatchEvent(
+      new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...o }),
+    );
+  const type = (text) => {
+    const t = document.activeElement;
+    t.setRangeText(text, t.selectionStart, t.selectionEnd, "end");
+    t.dispatchEvent(new Event("input", { bubbles: true }));
+  };
+  const shown = (a1) => document.querySelector(`[data-a1="${a1}"]`).textContent;
+  const sel = () => `${ssSelect.칸}|${ssSelect.범위}`;
+  const out = {};
+  // 한글 첫 글자 — compositionstart 로 편집 시작 · 같은 textarea 그대로
+  click("L2");
+  ta().dispatchEvent(new CompositionEvent("compositionstart", { data: "" }));
+  out.imeEditing =
+    document.querySelector(".ss").classList.contains("ss--editing") &&
+    document.activeElement === ta();
+  type("한");
+  key("Enter");
+  out.ime = [ssCell("L2"), sel()];
+  // 수 · 식 · 참조 가리키기(방향키 + 누르기) · 재계산
+  click("L3");
+  type("2");
+  key("Enter");
+  type("=");
+  key("ArrowUp");
+  out.pointKey = ta().value;
+  type("*");
+  click("J5");
+  out.pointClick = [
+    ta().value,
+    [...document.querySelectorAll(".fg-ref")].map((x) => x.dataset.ref),
+  ];
+  key("Enter");
+  out.calc = [ssCell("L4"), shown("L4")];
+  click("L3");
+  type("5");
+  key("Enter");
+  out.recalc = shown("L4");
+  // 되돌리기 · 다시
+  key("z", { ctrlKey: true });
+  out.undo = [ssCell("L3"), shown("L4")];
+  key("y", { ctrlKey: true });
+  out.redo = [ssCell("L3"), shown("L4")];
+  // Delete(서식 남김) · 되돌림
+  click("B4");
+  key("Delete");
+  out.del = ssCell("B4");
+  key("z", { ctrlKey: true });
+  out.delUndo = ssCell("B4");
+  // 병합 · Shift/Ctrl 방향
+  click("A4");
+  key("ArrowRight", { shiftKey: true });
+  key("ArrowRight", { shiftKey: true });
+  out.shift = sel();
+  click("A3");
+  key("ArrowDown", { ctrlKey: true });
+  out.ctrl = sel();
+  // 끌어 고르기 · Ctrl+Enter
+  dragTo(at("P2"), at("Q3"));
+  out.drag = sel();
+  type("=L3+1");
+  key("Enter", { ctrlKey: true });
+  out.ctrlEnter = ["P2", "Q2", "P3", "Q3"].map((k) => ssCell(k)?.식);
+  // 수식 입력줄로 고치기
+  click("N2");
+  const bar = document.querySelector(".ss-formula");
+  bar.focus();
+  bar.value = "=J4*10";
+  bar.dispatchEvent(new Event("input", { bubbles: true }));
+  key("Enter");
+  out.bar = [ssCell("N2"), shown("N2"), sel()];
+  // F2 — 방향키가 글자 자리만
+  click("N2");
+  key("F2");
+  const before = sel();
+  key("ArrowLeft");
+  out.f2 = [before === sel(), document.querySelector(".ss").classList.contains("ss--editing")];
+  key("Escape");
+  out.errors = window.ssErrors;
+  return JSON.stringify(out);
+};
diff --git a/resources/tester/spreadsheet/harness_spreadsheet_d.html b/resources/tester/spreadsheet/harness_spreadsheet_d.html
index be9d4089..19349816 100644
--- a/resources/tester/spreadsheet/harness_spreadsheet_d.html
+++ b/resources/tester/spreadsheet/harness_spreadsheet_d.html
@@ -23,6 +23,12 @@
   
     
+