From dd7300aa0234762e39d8df09026bda3ba57524cc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:16:46 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=C2=B7=EB=8F=84=EB=A9=B4=20=EB=B9=88=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=97=90=20=EA=B8=B0=EB=B3=B8=20=EB=8F=84=EB=A9=B4=EC=B8=B5=20?= =?UTF-8?q?=EC=B1=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store.py create_structure · UI_Side.ts stubDoc(도면 양식 「새로」) 둘 다 빈 CAD 문서에 기본 도면층 하나(id·name "기본") 넣음 — 없으면 sub1 이 고친 CAD import 가드가 대신 채우지만 뿌리도 처음부터 채움 - 이미 만들어진 구조물 도면 49개(resources/master_template/structure/*.json) 도면.layers 채움(한 번 도는 정리) — 다른 칸·도번은 그대로 · 줄바꿈도 그대로 - 시험(test_m02_structure.py) — 새 문서 layers 1개 확인으로 바꿈 검증: M02 시험 28개 · 루트 npm run typecheck 통과. ORCA 로 확인 — 빈 구조물 도면(rv) 오류 없이 열림 · 도면 양식 「새로」 빈 도면 오류 없이 열림 · 실제 선 하나 넣어 저장 → 다시 열기 → 화면에 그대로 · [저장] 단추로도 저장됨 (검증 뒤 rv 는 원래 빈 문서로 되돌림 · 검증용 임시 양식은 지움). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7 --- M02_MasterTemplete/M02_MasterTemplete_Store.py | 6 +++++- M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts | 8 +++++++- resources/master_template/structure/be.json | 10 +++++++++- resources/master_template/structure/bg.json | 10 +++++++++- resources/master_template/structure/bm_area.json | 10 +++++++++- resources/master_template/structure/bm_len.json | 10 +++++++++- resources/master_template/structure/bx_len.json | 10 +++++++++- resources/master_template/structure/bx_wing.json | 10 +++++++++- resources/master_template/structure/cs.json | 10 +++++++++- resources/master_template/structure/ec.json | 10 +++++++++- resources/master_template/structure/etc.json | 10 +++++++++- resources/master_template/structure/fb.json | 10 +++++++++- resources/master_template/structure/fp_l.json | 10 +++++++++- resources/master_template/structure/fp_w.json | 10 +++++++++- resources/master_template/structure/gr_curb.json | 10 +++++++++- resources/master_template/structure/gr_rail.json | 10 +++++++++- resources/master_template/structure/gr_sign.json | 10 +++++++++- resources/master_template/structure/h_break.json | 10 +++++++++- resources/master_template/structure/h_ford_basin.json | 10 +++++++++- resources/master_template/structure/h_intake.json | 10 +++++++++- resources/master_template/structure/h_pg_rail.json | 10 +++++++++- resources/master_template/structure/h_pv_rail.json | 10 +++++++++- resources/master_template/structure/h_rip_c.json | 10 +++++++++- resources/master_template/structure/h_rip_m.json | 10 +++++++++- resources/master_template/structure/h_rockfall.json | 10 +++++++++- resources/master_template/structure/h_soil_ditch.json | 10 +++++++++- resources/master_template/structure/h_stone_bed.json | 10 +++++++++- resources/master_template/structure/h_stone_ch.json | 10 +++++++++- resources/master_template/structure/h_waste.json | 10 +++++++++- resources/master_template/structure/memo.json | 10 +++++++++- resources/master_template/structure/mr.json | 10 +++++++++- resources/master_template/structure/ms.json | 10 +++++++++- resources/master_template/structure/no.json | 10 +++++++++- resources/master_template/structure/od_cnt.json | 10 +++++++++- resources/master_template/structure/od_len.json | 10 +++++++++- resources/master_template/structure/pg_in.json | 10 +++++++++- resources/master_template/structure/pp_cp.json | 10 +++++++++- resources/master_template/structure/pp_len.json | 10 +++++++++- resources/master_template/structure/ps.json | 10 +++++++++- resources/master_template/structure/pv_a.json | 10 +++++++++- resources/master_template/structure/pv_b.json | 10 +++++++++- resources/master_template/structure/pv_jt.json | 10 +++++++++- resources/master_template/structure/pv_l.json | 10 +++++++++- resources/master_template/structure/pv_w.json | 10 +++++++++- resources/master_template/structure/rf.json | 10 +++++++++- resources/master_template/structure/rv.json | 10 +++++++++- resources/master_template/structure/rw.json | 10 +++++++++- resources/master_template/structure/sg.json | 10 +++++++++- resources/master_template/structure/sta.json | 10 +++++++++- resources/master_template/structure/ta.json | 10 +++++++++- resources/master_template/structure/wy.json | 10 +++++++++- resources/tester/test_m02_structure.py | 6 ++++-- 52 files changed, 457 insertions(+), 53 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 4522430b..2d0e0a11 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -24,6 +24,10 @@ STRUCTURE_TABLE = "구조물집계표" _BASIS_HEAD = (("work", "공종"), ("spec", "규격"), ("detail", "산출 내역"), ("unit", "단위")) _NUMBER = re.compile(r"^구-(\d+)$") _BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.') +# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties +# of undefined (reading 'length')") · CAD 쪽도 없으면 새로 채우지만(2026-09-27 sub1 고침) +# 뿌리도 처음부터 채움 +_BLANK_LAYER = {"id": "기본", "name": "기본", "isVisible": True, "isLocked": False} # ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로 _LOCK = threading.Lock() @@ -145,7 +149,7 @@ def create_structure(column: str) -> dict[str, Any]: "판": 1, "열": column, "도번": f"구-{number:02d}", - "도면": {"format": 6, "entities": []}, + "도면": {"format": 6, "entities": [], "layers": [dict(_BLANK_LAYER)]}, "산출근거": {"양식": "산출근거", "종류": "표", "판": 1, "열": basis, "줄": basis_rows}, } atomic_write_json(path, doc) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 86334a25..97e462cc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -45,7 +45,13 @@ function stubDoc(kind: Kind, name: string): unknown { 합계줄: [], 보기: {}, } - : { format: 6, entities: [] }; + : { + format: 6, + entities: [], + // 기본 도면층 하나 — 없으면 B07 CAD 가 못 엶("Cannot read properties of + // undefined (reading 'length')", 2026-09-27 실측) + layers: [{ id: "기본", name: "기본", isVisible: true, isLocked: false }], + }; } export interface SideOptions { diff --git a/resources/master_template/structure/be.json b/resources/master_template/structure/be.json index 66d85d4c..44363508 100644 --- a/resources/master_template/structure/be.json +++ b/resources/master_template/structure/be.json @@ -6,7 +6,15 @@ "도번": "구-25", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/bg.json b/resources/master_template/structure/bg.json index 80bb0b4b..02de2357 100644 --- a/resources/master_template/structure/bg.json +++ b/resources/master_template/structure/bg.json @@ -6,7 +6,15 @@ "도번": "구-33", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/bm_area.json b/resources/master_template/structure/bm_area.json index 46cdf626..49a16c8d 100644 --- a/resources/master_template/structure/bm_area.json +++ b/resources/master_template/structure/bm_area.json @@ -6,7 +6,15 @@ "도번": "구-23", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/bm_len.json b/resources/master_template/structure/bm_len.json index a1cf80d4..984072e0 100644 --- a/resources/master_template/structure/bm_len.json +++ b/resources/master_template/structure/bm_len.json @@ -6,7 +6,15 @@ "도번": "구-22", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/bx_len.json b/resources/master_template/structure/bx_len.json index f900a3e7..2bf67cdc 100644 --- a/resources/master_template/structure/bx_len.json +++ b/resources/master_template/structure/bx_len.json @@ -6,7 +6,15 @@ "도번": "구-12", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/bx_wing.json b/resources/master_template/structure/bx_wing.json index 67f21977..4778ca23 100644 --- a/resources/master_template/structure/bx_wing.json +++ b/resources/master_template/structure/bx_wing.json @@ -6,7 +6,15 @@ "도번": "구-13", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/cs.json b/resources/master_template/structure/cs.json index 30d6273d..db1f0bdc 100644 --- a/resources/master_template/structure/cs.json +++ b/resources/master_template/structure/cs.json @@ -6,7 +6,15 @@ "도번": "구-35", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/ec.json b/resources/master_template/structure/ec.json index affd8114..6e82c59e 100644 --- a/resources/master_template/structure/ec.json +++ b/resources/master_template/structure/ec.json @@ -6,7 +6,15 @@ "도번": "구-24", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/etc.json b/resources/master_template/structure/etc.json index fed2e8fa..494b744a 100644 --- a/resources/master_template/structure/etc.json +++ b/resources/master_template/structure/etc.json @@ -6,7 +6,15 @@ "도번": "구-36", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/fb.json b/resources/master_template/structure/fb.json index 080bd463..5a451088 100644 --- a/resources/master_template/structure/fb.json +++ b/resources/master_template/structure/fb.json @@ -6,7 +6,15 @@ "도번": "구-14", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/fp_l.json b/resources/master_template/structure/fp_l.json index 764a2ae2..4f63b5f3 100644 --- a/resources/master_template/structure/fp_l.json +++ b/resources/master_template/structure/fp_l.json @@ -6,7 +6,15 @@ "도번": "구-16", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/fp_w.json b/resources/master_template/structure/fp_w.json index f8ff8360..273a0c0d 100644 --- a/resources/master_template/structure/fp_w.json +++ b/resources/master_template/structure/fp_w.json @@ -6,7 +6,15 @@ "도번": "구-15", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/gr_curb.json b/resources/master_template/structure/gr_curb.json index 360487e7..37644022 100644 --- a/resources/master_template/structure/gr_curb.json +++ b/resources/master_template/structure/gr_curb.json @@ -6,7 +6,15 @@ "도번": "구-30", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/gr_rail.json b/resources/master_template/structure/gr_rail.json index 066402f1..294467dc 100644 --- a/resources/master_template/structure/gr_rail.json +++ b/resources/master_template/structure/gr_rail.json @@ -6,7 +6,15 @@ "도번": "구-29", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/gr_sign.json b/resources/master_template/structure/gr_sign.json index b47412c5..12293769 100644 --- a/resources/master_template/structure/gr_sign.json +++ b/resources/master_template/structure/gr_sign.json @@ -6,7 +6,15 @@ "도번": "구-31", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_break.json b/resources/master_template/structure/h_break.json index 981857b3..ad2c7de4 100644 --- a/resources/master_template/structure/h_break.json +++ b/resources/master_template/structure/h_break.json @@ -6,7 +6,15 @@ "도번": "구-47", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_ford_basin.json b/resources/master_template/structure/h_ford_basin.json index 4be43476..716555bb 100644 --- a/resources/master_template/structure/h_ford_basin.json +++ b/resources/master_template/structure/h_ford_basin.json @@ -6,7 +6,15 @@ "도번": "구-45", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_intake.json b/resources/master_template/structure/h_intake.json index 13c5fb13..7f4611fe 100644 --- a/resources/master_template/structure/h_intake.json +++ b/resources/master_template/structure/h_intake.json @@ -6,7 +6,15 @@ "도번": "구-37", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_pg_rail.json b/resources/master_template/structure/h_pg_rail.json index 93a629e0..0cef504a 100644 --- a/resources/master_template/structure/h_pg_rail.json +++ b/resources/master_template/structure/h_pg_rail.json @@ -6,7 +6,15 @@ "도번": "구-43", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_pv_rail.json b/resources/master_template/structure/h_pv_rail.json index 44f3fac4..d482add1 100644 --- a/resources/master_template/structure/h_pv_rail.json +++ b/resources/master_template/structure/h_pv_rail.json @@ -6,7 +6,15 @@ "도번": "구-42", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_rip_c.json b/resources/master_template/structure/h_rip_c.json index 2b3268d3..358e9d6b 100644 --- a/resources/master_template/structure/h_rip_c.json +++ b/resources/master_template/structure/h_rip_c.json @@ -6,7 +6,15 @@ "도번": "구-38", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_rip_m.json b/resources/master_template/structure/h_rip_m.json index 12564857..12a8d42d 100644 --- a/resources/master_template/structure/h_rip_m.json +++ b/resources/master_template/structure/h_rip_m.json @@ -6,7 +6,15 @@ "도번": "구-39", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_rockfall.json b/resources/master_template/structure/h_rockfall.json index 25195f35..4f9ab417 100644 --- a/resources/master_template/structure/h_rockfall.json +++ b/resources/master_template/structure/h_rockfall.json @@ -6,7 +6,15 @@ "도번": "구-46", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_soil_ditch.json b/resources/master_template/structure/h_soil_ditch.json index d05202cc..3736eff0 100644 --- a/resources/master_template/structure/h_soil_ditch.json +++ b/resources/master_template/structure/h_soil_ditch.json @@ -6,7 +6,15 @@ "도번": "구-44", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_stone_bed.json b/resources/master_template/structure/h_stone_bed.json index f90baf98..552ad655 100644 --- a/resources/master_template/structure/h_stone_bed.json +++ b/resources/master_template/structure/h_stone_bed.json @@ -6,7 +6,15 @@ "도번": "구-41", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_stone_ch.json b/resources/master_template/structure/h_stone_ch.json index 3c6e4922..3c8681a0 100644 --- a/resources/master_template/structure/h_stone_ch.json +++ b/resources/master_template/structure/h_stone_ch.json @@ -6,7 +6,15 @@ "도번": "구-40", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/h_waste.json b/resources/master_template/structure/h_waste.json index 82add70b..232ad307 100644 --- a/resources/master_template/structure/h_waste.json +++ b/resources/master_template/structure/h_waste.json @@ -6,7 +6,15 @@ "도번": "구-48", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/memo.json b/resources/master_template/structure/memo.json index 489e5ef8..294196d7 100644 --- a/resources/master_template/structure/memo.json +++ b/resources/master_template/structure/memo.json @@ -6,7 +6,15 @@ "도번": "구-49", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/mr.json b/resources/master_template/structure/mr.json index 53369635..50c5616b 100644 --- a/resources/master_template/structure/mr.json +++ b/resources/master_template/structure/mr.json @@ -6,7 +6,15 @@ "도번": "구-32", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/ms.json b/resources/master_template/structure/ms.json index f0272a10..daf621cc 100644 --- a/resources/master_template/structure/ms.json +++ b/resources/master_template/structure/ms.json @@ -6,7 +6,15 @@ "도번": "구-20", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/no.json b/resources/master_template/structure/no.json index aa348227..b8f5736e 100644 --- a/resources/master_template/structure/no.json +++ b/resources/master_template/structure/no.json @@ -6,7 +6,15 @@ "도번": "구-01", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/od_cnt.json b/resources/master_template/structure/od_cnt.json index 2d01ffab..2e8b1c25 100644 --- a/resources/master_template/structure/od_cnt.json +++ b/resources/master_template/structure/od_cnt.json @@ -6,7 +6,15 @@ "도번": "구-18", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/od_len.json b/resources/master_template/structure/od_len.json index 52ae094a..55d47902 100644 --- a/resources/master_template/structure/od_len.json +++ b/resources/master_template/structure/od_len.json @@ -6,7 +6,15 @@ "도번": "구-17", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/pg_in.json b/resources/master_template/structure/pg_in.json index 7293a81e..c8db3f47 100644 --- a/resources/master_template/structure/pg_in.json +++ b/resources/master_template/structure/pg_in.json @@ -6,7 +6,15 @@ "도번": "구-11", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/pp_cp.json b/resources/master_template/structure/pp_cp.json index 0bb30dfe..7dc39795 100644 --- a/resources/master_template/structure/pp_cp.json +++ b/resources/master_template/structure/pp_cp.json @@ -6,7 +6,15 @@ "도번": "구-10", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/pp_len.json b/resources/master_template/structure/pp_len.json index c55eb833..63833fb9 100644 --- a/resources/master_template/structure/pp_len.json +++ b/resources/master_template/structure/pp_len.json @@ -6,7 +6,15 @@ "도번": "구-09", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/ps.json b/resources/master_template/structure/ps.json index ceaafad7..6ae5bf4d 100644 --- a/resources/master_template/structure/ps.json +++ b/resources/master_template/structure/ps.json @@ -6,7 +6,15 @@ "도번": "구-34", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/pv_a.json b/resources/master_template/structure/pv_a.json index edb9aed1..6d688784 100644 --- a/resources/master_template/structure/pv_a.json +++ b/resources/master_template/structure/pv_a.json @@ -6,7 +6,15 @@ "도번": "구-07", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/pv_b.json b/resources/master_template/structure/pv_b.json index 04938563..379a96bb 100644 --- a/resources/master_template/structure/pv_b.json +++ b/resources/master_template/structure/pv_b.json @@ -6,7 +6,15 @@ "도번": "구-04", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/pv_jt.json b/resources/master_template/structure/pv_jt.json index ee6a8e75..012c5a81 100644 --- a/resources/master_template/structure/pv_jt.json +++ b/resources/master_template/structure/pv_jt.json @@ -6,7 +6,15 @@ "도번": "구-08", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/pv_l.json b/resources/master_template/structure/pv_l.json index 91980abd..f9505e6e 100644 --- a/resources/master_template/structure/pv_l.json +++ b/resources/master_template/structure/pv_l.json @@ -6,7 +6,15 @@ "도번": "구-05", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/pv_w.json b/resources/master_template/structure/pv_w.json index 91a9fa8c..d54e44cd 100644 --- a/resources/master_template/structure/pv_w.json +++ b/resources/master_template/structure/pv_w.json @@ -6,7 +6,15 @@ "도번": "구-06", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/rf.json b/resources/master_template/structure/rf.json index 50cf9500..150a7647 100644 --- a/resources/master_template/structure/rf.json +++ b/resources/master_template/structure/rf.json @@ -6,7 +6,15 @@ "도번": "구-26", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/rv.json b/resources/master_template/structure/rv.json index 34378219..6dbfba2e 100644 --- a/resources/master_template/structure/rv.json +++ b/resources/master_template/structure/rv.json @@ -6,7 +6,15 @@ "도번": "구-03", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/rw.json b/resources/master_template/structure/rw.json index be6470b2..d71048f5 100644 --- a/resources/master_template/structure/rw.json +++ b/resources/master_template/structure/rw.json @@ -6,7 +6,15 @@ "도번": "구-19", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/sg.json b/resources/master_template/structure/sg.json index 1fa04f0c..3a34aee6 100644 --- a/resources/master_template/structure/sg.json +++ b/resources/master_template/structure/sg.json @@ -6,7 +6,15 @@ "도번": "구-21", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/sta.json b/resources/master_template/structure/sta.json index 855f03db..d456334b 100644 --- a/resources/master_template/structure/sta.json +++ b/resources/master_template/structure/sta.json @@ -6,7 +6,15 @@ "도번": "구-02", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/ta.json b/resources/master_template/structure/ta.json index 577c44a8..6d5fb9bb 100644 --- a/resources/master_template/structure/ta.json +++ b/resources/master_template/structure/ta.json @@ -6,7 +6,15 @@ "도번": "구-28", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/master_template/structure/wy.json b/resources/master_template/structure/wy.json index 1b33e090..f360037b 100644 --- a/resources/master_template/structure/wy.json +++ b/resources/master_template/structure/wy.json @@ -6,7 +6,15 @@ "도번": "구-27", "도면": { "format": 6, - "entities": [] + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] }, "산출근거": { "양식": "산출근거", diff --git a/resources/tester/test_m02_structure.py b/resources/tester/test_m02_structure.py index 9876b26b..b5c03ea9 100644 --- a/resources/tester/test_m02_structure.py +++ b/resources/tester/test_m02_structure.py @@ -34,8 +34,10 @@ def test_새로_도번_자동_409_없는_열(client): "pp_len", "구-01", ) - # 도각 없는 전용 화면 — 그린 만큼이 설계 영역 - assert doc["도면"] == {"format": 6, "entities": []} + # 도각 없는 전용 화면 — 그린 만큼이 설계 영역 · 기본 도면층 하나(없으면 CAD 가 못 엶) + assert doc["도면"]["format"] == 6 + assert doc["도면"]["entities"] == [] + assert len(doc["도면"]["layers"]) == 1 basis = doc["산출근거"] assert [c["머리"][0] for c in basis["열"]] == ["공종", "규격", "산출 내역", "단위", "수량(m당)"] # 빈 줄 몇 개를 미리 둠 — 줄 더하기 안 눌러도 바로 입력 From 6f8ab6e06729aa92e2c8b743bfc6b642910a7f8b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:37:05 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EF=BB=BFtest(spreadsheet):=20=EB=8A=A6?= =?UTF-8?q?=EA=B2=8C=20=EB=B6=88=EB=9F=AC=EC=98=B4=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=8B=9C=ED=97=98=EC=9A=A9=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85(resources/tester)=20=EB=BA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp --- resources/tester/spreadsheet/test_spreadsheet_size.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/tester/spreadsheet/test_spreadsheet_size.py b/resources/tester/spreadsheet/test_spreadsheet_size.py index 4b4d2580..5fad6bd2 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_size.py +++ b/resources/tester/spreadsheet/test_spreadsheet_size.py @@ -60,7 +60,10 @@ def test_M02_만_늦게_불러옴(): check=True, ).stdout.splitlines() for rel in listed: - if rel.startswith("A00_Common/spreadsheet/") or not (ROOT / rel).is_file(): + # 시험용 진입(`resources/tester/`)은 앱 번들 밖 + if rel.startswith(("A00_Common/spreadsheet/", "resources/tester/")): + continue + if not (ROOT / rel).is_file(): continue text = (ROOT / rel).read_text(encoding="utf-8", errors="replace") if "spreadsheet/spreadsheet" not in text: From b93e03e50548427a86354c6490403a94109aba6c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:39:03 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(spreadsheet):=20fill.ts=EB=A5=BC=20A?= =?UTF-8?q?=20=EC=97=94=EC=A7=84=20=EC=8B=A4=20=EA=B5=AC=ED=98=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=87=EA=B8=B0=20=C2=B7=20numfmt=20=EC=8B=A4?= =?UTF-8?q?=EB=AC=B4=20=EC=84=9C=EC=8B=9D=20=EB=8C=80=EC=A1=B0=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fill.ts가 A의 실제 moveFormula·toA1을 쓰도록 정리(식 채우기 시험 통과). 실무 구조도 xlsx 세 벌 실측 형식 코드 66개 대조 — `_x` trailing 공백을 trim이 삼키던 버그, 글 값에 순수 수 형식이 적용되던 버그, `General"단위"` 접미 버릇, `[$통화-로캘]` 태그 미지원을 고침. 분수 형식(`0/0`)은 1단계 형식 부분집합 밖이라 남겨둠. 회귀 시험 추가. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc --- A00_Common/spreadsheet/spreadsheet_fill.ts | 19 +--- A00_Common/spreadsheet/spreadsheet_numfmt.ts | 40 +++++-- .../spreadsheet/spreadsheet_b_test_entry.ts | 37 ++++++ .../spreadsheet_numfmt_survey_entry.ts | 43 +++++++ .../tester/test_spreadsheet_numfmt_survey.py | 105 ++++++++++++++++++ 5 files changed, 215 insertions(+), 29 deletions(-) create mode 100644 resources/tester/spreadsheet/spreadsheet_numfmt_survey_entry.ts create mode 100644 resources/tester/test_spreadsheet_numfmt_survey.py diff --git a/A00_Common/spreadsheet/spreadsheet_fill.ts b/A00_Common/spreadsheet/spreadsheet_fill.ts index 556d8033..2c33275c 100644 --- a/A00_Common/spreadsheet/spreadsheet_fill.ts +++ b/A00_Common/spreadsheet/spreadsheet_fill.ts @@ -7,22 +7,7 @@ import type { Cell, CellRange, FillDirection, Workbook } from "./spreadsheet_types"; import { moveFormula } from "./spreadsheet_refshift"; - -/** 0 → `A` · 27 → `AB`(엔진 A 의 `spreadsheet_address.ts` 와 같은 규칙 · 채우기는 자기 것을 씀) */ -function colLetters(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; -} - -function a1(r: number, c: number): string { - return `${colLetters(c)}${r + 1}`; -} +import { toA1 } from "./spreadsheet_address"; function detectDirection(source: CellRange, target: CellRange): FillDirection { if (target.r1 > source.r1 && target.c0 === source.c0 && target.c1 === source.c1) return "down"; @@ -64,7 +49,7 @@ export function fillCells( const targetEnd = vertical ? target.r1 : target.c1; const lineLen = sourceEnd - sourceStart + 1; - const key = (pos: number): string => (vertical ? a1(pos, fixed) : a1(fixed, pos)); + const key = (pos: number): string => (vertical ? toA1(pos, fixed) : toA1(fixed, pos)); const sourceCells: (Cell | undefined)[] = []; for (let p = sourceStart; p <= sourceEnd; p++) sourceCells.push(sheetObj.칸[key(p)]); diff --git a/A00_Common/spreadsheet/spreadsheet_numfmt.ts b/A00_Common/spreadsheet/spreadsheet_numfmt.ts index adde4c58..1351fcab 100644 --- a/A00_Common/spreadsheet/spreadsheet_numfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_numfmt.ts @@ -64,7 +64,12 @@ function extractColor(section: string): { rest: string; color?: string } { let color: string | undefined; const rest = section.replace(/\[([^\]]+)\]/g, (_m, inner: string) => { const lower = inner.toLowerCase(); - if (COLOR_NAMES.includes(lower)) color = lower; + if (COLOR_NAMES.includes(lower)) { + color = lower; + return ""; + } + const currency = /^\$([^-\]]*)(-[0-9A-Fa-f]+)?$/.exec(inner); + if (currency) return currency[1]; // `[$₩-412]` 등 통화 태그 → 글자만 남김 return ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시 }); return { rest, color }; @@ -78,6 +83,7 @@ type Tok = | { k: "point" } | { k: "percent" } | { k: "text" } + | { k: "general" } | { k: "lit"; ch: string }; function tokenize(s: string): { toks: Tok[]; fillChar?: string } { @@ -85,6 +91,12 @@ function tokenize(s: string): { toks: Tok[]; fillChar?: string } { let fillChar: string | undefined; for (let i = 0; i < s.length; i++) { const ch = s[i]; + // 코드 속에 섞인 맨 `General`(따옴표 없이) — 실무 흔한 `General"개"` 같은 단위 접미 버릇. + if (s.slice(i, i + 7).toLowerCase() === "general") { + toks.push({ k: "general" }); + i += 6; + continue; + } if (ch === '"') { let j = i + 1; while (j < s.length && s[j] !== '"') { @@ -214,6 +226,10 @@ function renderNumericSection(toks: Tok[], magnitude: Frac): string { continue; } if (t.k === "text") continue; // 수 구역엔 의미 없음(방어) + if (t.k === "general") { + out += generalFormat(magnitude); + continue; + } out += t.ch; } return out; @@ -238,34 +254,34 @@ function chooseSection(sections: string[], value: Frac): { code: string; forceMi } function formatNumber(value: Frac, code: string | undefined): FormattedText { - const trimmed = code?.trim(); - if (!trimmed || trimmed.toLowerCase() === "general") { + if (code === undefined || code === "" || code.trim().toLowerCase() === "general") { return { 글: generalFormat(value), 정렬: "right" }; } - const sections = splitSections(trimmed); + const sections = splitSections(code); const { code: chosen, forceMinus } = chooseSection(sections, value); const { rest, color } = extractColor(chosen); const { toks, fillChar } = tokenize(rest); const extra: Partial = {}; if (color) extra.색 = color; if (fillChar) extra.채움글 = fillChar; - if (!toks.some((t) => t.k === "digit")) { + const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value; + if (!toks.some((t) => t.k === "digit" || t.k === "general")) { let out = ""; for (const t of toks) if (t.k === "lit") out += t.ch; return { 글: out, 정렬: "right", ...extra }; } - const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value; const body = renderNumericSection(toks, magnitude); return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra }; } function formatText(value: string, code: string | undefined): FormattedText { - const trimmed = code?.trim(); - if (!trimmed) return { 글: value, 정렬: "left" }; - const sections = splitSections(trimmed); - // 구역 하나뿐(`;` 없음) = 그 구역이 수 · 글 모두에 적용(`General` 과 같은 이치) · 2~3 구역엔 글 자리가 없어 그대로. - const textCode = - sections.length >= 4 ? sections[3] : sections.length === 1 ? sections[0] : undefined; + if (code === undefined || code === "") return { 글: value, 정렬: "left" }; + const sections = splitSections(code); + // 구역 하나뿐(`;` 없음)에 `@` 가 있을 때만 그 구역을 씀(순수 수 형식 하나뿐이면 글엔 안 씀) · + // 넷째 구역은 명시적 글 자리라 `@` 없어도 그대로(고정 라벨 버릇) · 2~3 구역엔 글 자리가 없어 그대로. + let textCode: string | undefined; + if (sections.length >= 4) textCode = sections[3]; + else if (sections.length === 1 && sections[0].includes("@")) textCode = sections[0]; if (textCode === undefined) return { 글: value, 정렬: "left" }; const { rest, color } = extractColor(textCode); const { toks, fillChar } = tokenize(rest); diff --git a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts index 7f78fccb..92972b24 100644 --- a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts @@ -216,6 +216,34 @@ ok("0.00", fmt(frac(BigInt(150), 100n), "0.00") === "1.50", fmt(frac(150n, 100n) ok("#,##0", fmt(frac(1234567n), "#,##0") === "1,234,567", fmt(frac(1234567n), "#,##0")); ok("천 나눔 끝쉼표", fmt(frac(1234000n), "#,##0,") === "1,234", fmt(frac(1234000n), "#,##0,")); ok("퍼센트", fmt(frac(50n, 100n), "0%") === "50%", fmt(frac(50n, 100n), "0%")); + +// 실무 구조도 xlsx 세 벌 실측 코드(2026-09-27 대조) — 회귀 방지. +ok( + "_x 폭띄움 끝쉼표", + fmt(frac(3n), "0.00_ ") === "3.00 ", + JSON.stringify(fmt(frac(3n), "0.00_ ")), +); +ok( + "_x 글에는 안 먹음(순수 수 형식)", + fmt("외부벽체", "0.00_ ") === "외부벽체", + fmt("외부벽체", "0.00_ "), +); +ok( + "General 단위접미(실무 버릇)", + fmt(frac(4n), 'General"열"') === "4열", + fmt(frac(4n), 'General"열"'), +); +ok( + "회계형(끝 구역 글)", + fmt(frac(1044n, 1000n), '_-* #,##0.00_-;\\-* #,##0.00_-;_-* "-"_-;_-@_-') === " 1.04 ", + fmt(frac(1044n, 1000n), '_-* #,##0.00_-;\\-* #,##0.00_-;_-* "-"_-;_-@_-'), +); +ok( + "통화 태그([$기호-로캘])", + fmt(frac(2n), '_-[$€-2]* #,##0.00_-;-[$€-2]* #,##0.00_-;_-[$€-2]* "-"??_-') === " €2.00 ", + fmt(frac(2n), '_-[$€-2]* #,##0.00_-;-[$€-2]* #,##0.00_-;_-[$€-2]* "-"??_-'), +); +ok("따옴표 접두·접미", fmt(frac(800n), '"φ"###') === "φ800", fmt(frac(800n), '"φ"###')); ok( "음수 회계형", fmt(frac(-150n, 100n), "0.00_);[Red](0.00)") === "(1.50)", @@ -278,6 +306,15 @@ function bookWith(cells: Record): Workbook { JSON.stringify(out), ); } +{ + const book = bookWith({ B1: { 식: "A1*2" } }); + const out = fillCells(book, "s1", { r0: 0, c0: 1, r1: 0, c1: 1 }, { r0: 0, c0: 1, r1: 2, c1: 1 }); + ok( + "채우기 식 상대 이동(A moveFormula)", + out.B2?.식 === "A2*2" && out.B3?.식 === "A3*2", + JSON.stringify(out), + ); +} // ── 요약 ──────────────────────────────────────────────────────────────── diff --git a/resources/tester/spreadsheet/spreadsheet_numfmt_survey_entry.ts b/resources/tester/spreadsheet/spreadsheet_numfmt_survey_entry.ts new file mode 100644 index 00000000..3ade8659 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_numfmt_survey_entry.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * spreadsheet_numfmt_survey_entry.ts (B 자기 시험) + * 실무 구조도 xlsx 세 벌에서 뽑은 실제 숫자 형식 코드 전부를 numfmt 로 돌려 사람이 대조. + * 입력 = 시험이 openpyxl 로 미리 뽑은 JSON(코드 · 실측 수 · 실측 글 견본). + * ========================================================================== */ + +import { readFileSync } from "node:fs"; +import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; +import { formatValue } from "../../../A00_Common/spreadsheet/spreadsheet_numfmt"; + +interface CodeSample { + code: string; + count: number; + num: number | null; + str: string | null; +} + +const inputPath = process.argv[2]; +const samples: CodeSample[] = JSON.parse(readFileSync(inputPath, "utf-8")); + +const rows = samples.map((s) => { + let numOut: string | null = null; + let numErr: string | null = null; + if (s.num !== null) { + try { + numOut = formatValue(toFrac(s.num), s.code).글; + } catch (e) { + numErr = String(e); + } + } + let strOut: string | null = null; + let strErr: string | null = null; + if (s.str !== null) { + try { + strOut = formatValue(s.str, s.code).글; + } catch (e) { + strErr = String(e); + } + } + return { ...s, numOut, numErr, strOut, strErr }; +}); + +console.log(JSON.stringify(rows, null, 1)); diff --git a/resources/tester/test_spreadsheet_numfmt_survey.py b/resources/tester/test_spreadsheet_numfmt_survey.py new file mode 100644 index 00000000..62e33771 --- /dev/null +++ b/resources/tester/test_spreadsheet_numfmt_survey.py @@ -0,0 +1,105 @@ +"""numfmt — 실무 구조도 xlsx 세 벌 실측 숫자 형식 코드 전부를 돌려 안 죽는지 대조(2026-09-27). + +`resources/knowledge/original/실무문서/` 세 벌(openpyxl 로 읽기만 · 안 고침)에서 쓰인 서로 다른 +숫자 형식 코드를 모아 `spreadsheet_numfmt_survey_entry.ts`(vite ssr → node)로 돌린다. +값별 정답은 실제 엑셀이 있어야 재는데(이 환경엔 없음) `resources/tester/spreadsheet/ +spreadsheet_b_test_entry.ts` 에 실측 코드 몇 개를 손으로 대조한 회귀 시험을 이미 넣었다 — +여기는 "실무에 쓰인 코드 전부가 죽지 않고 뭔가 보이는 글을 냄"만 잰다(폭넓은 그물). + +알려진 빈 자리(1단계 밖 · `9_엑셀검토.md` 형식 부분집합에 없음): +- 분수 형식(`0/0`, `# ?/?` 류) — 미지원 · 자리표시만 있고 값은 틀리게 나옴. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import openpyxl +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_numfmt_survey_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + +FILES = [ + ROOT + / "resources/knowledge/original/실무문서/25년 산불진화임도(기번8)(울진소광)/1. 설계원본/02-수량/07-구조도-소광리.xlsx", + ROOT + / "resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/02 산출자료/5. 구조도(기번3).xlsx", + ROOT + / "resources/knowledge/original/실무문서/20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/02.엑셀자료/구조도/구조도(변경).xlsx", +] + +# 미지원으로 이미 아는 코드(1단계 밖 · 분수 형식) — 죽지 않아도 값이 틀리므로 미리 뺌. +KNOWN_UNSUPPORTED = {"0/0"} + +pytestmark = pytest.mark.skipif( + not all(f.is_file() for f in FILES) or shutil.which("node") is None, + reason="실무 xlsx 세 벌 또는 node 가 없음", +) + + +def _collect_codes() -> list[dict]: + codes: dict[str, dict] = {} + for f in FILES: + wb = openpyxl.load_workbook(f, data_only=True, read_only=True) + for ws in wb.worksheets: + for row in ws.iter_rows(): + for cell in row: + if cell.value is None or isinstance(cell.value, bool): + continue + fmt = cell.number_format + if fmt == "General": + continue + info = codes.setdefault(fmt, {"count": 0, "num": None, "str": None}) + info["count"] += 1 + if isinstance(cell.value, (int, float)) and info["num"] is None: + info["num"] = cell.value + if isinstance(cell.value, str) and info["str"] is None: + info["str"] = cell.value + wb.close() + return [{"code": k, **v} for k, v in codes.items()] + + +def _run_survey(samples: list[dict]) -> list[dict]: + with tempfile.TemporaryDirectory(prefix="numfmt_survey_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + ["node", str(VITE), "build", "--configLoader", "runner", "--ssr", entry_rel, "--outDir", outdir], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + bundle = Path(outdir) / "spreadsheet_numfmt_survey_entry.js" + input_path = Path(outdir) / "codes.json" + input_path.write_text(json.dumps(samples, ensure_ascii=False), encoding="utf-8") + run = subprocess.run( + ["node", str(bundle), str(input_path)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), f"시험 진입이 아무 결과도 안 냄:\n{run.stderr}" + return json.loads(run.stdout) + + +def test_real_world_format_codes_do_not_error(): + samples = _collect_codes() + assert len(samples) > 30, f"실무 코드가 너무 적게 걸림: {len(samples)}" + results = _run_survey(samples) + broken = [ + r + for r in results + if r["code"] not in KNOWN_UNSUPPORTED and (r["numErr"] or r["strErr"]) + ] + assert broken == [], json.dumps(broken, ensure_ascii=False, indent=1) From 85fa62ae4d0926be49594b5d3a342758770d004f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:39:35 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(M02):=20=EC=9E=84=EC=8B=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=94=84=EB=A0=88=EB=93=9C=EC=8B=9C=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=EB=8B=A8=EC=B6=94=EA=B0=80=20inspect=20=EB=AC=B8?= =?UTF-8?q?=EB=A7=A5=EC=9D=84=20=EC=BC=AC(=EB=B8=8C=EB=A0=88=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp --- M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.ts b/M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.ts index 7d655959..bbe3ffc2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Spreadsheet_Trial.ts @@ -56,7 +56,10 @@ export function mountSpreadsheetTrial( readSpreadsheetTrial(), ]); if (!alive) return; - sheet = createSpreadsheet(area, got.문서 as Workbook, { onChange: () => onDirty(true) }); + sheet = createSpreadsheet(area, got.문서 as Workbook, { + onChange: () => onDirty(true), + inspect: true, // ⚠ 임시 — 브레인 검증용 문맥(window.__aisloSheet) · 산출근거 카드는 안 넘김 + }); } catch (error) { if (!alive) return; area.replaceChildren(el("p", { className: "m02-trial__empty", text: why(error) })); From 7d9fc6eea5a129a6cf07999f34f37688d4be0b02 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:45:30 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(spreadsheet):=20C=20=EA=B2=A9=EC=9E=90?= =?UTF-8?q?=20=E2=80=94=20=EC=8B=9C=ED=8A=B8=20=EA=B8=B0=EB=B3=B8=EA=B0=92?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=C2=B7=20data-addr=20=C2=B7=20=ED=8C=90?= =?UTF-8?q?=EB=B3=84=20editorSlot=20=C2=B7=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=84=B8=EB=AA=A8=20=C2=B7=20=EC=8B=9C=ED=97=98=20=ED=8B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg --- .../spreadsheet/spreadsheet_cellstyle.ts | 3 +- A00_Common/spreadsheet/spreadsheet_grid.css | 13 ++ A00_Common/spreadsheet/spreadsheet_grid.ts | 31 +++- A00_Common/spreadsheet/spreadsheet_headers.ts | 4 +- .../spreadsheet/spreadsheet_grid_test.html | 22 +++ .../spreadsheet/spreadsheet_grid_test.ts | 161 ++++++++++++++++++ 6 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 resources/tester/spreadsheet/spreadsheet_grid_test.html create mode 100644 resources/tester/spreadsheet/spreadsheet_grid_test.ts diff --git a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts index 28b2db92..375998a7 100644 --- a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts +++ b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts @@ -44,7 +44,7 @@ export interface ResolvedBorders { 오른?: BorderSide; } -/** `서식` 표 번호 — 칸 → 행 → 열 → 0(기본) 차례(칸 안의 계약 주석과 같음). */ +/** `서식` 표 번호 — 칸 → 행 → 열 → 시트 `기본.서식` → 0(기본) 차례(칸 안의 계약 주석과 같음). */ export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): number { const cell = sheet.칸[key]; if (cell?.서식 !== undefined) return cell.서식; @@ -52,6 +52,7 @@ export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): n if (rowFmt !== undefined) return rowFmt; const colFmt = sheet.열?.[colName(c)]?.서식; if (colFmt !== undefined) return colFmt; + if (sheet.기본?.서식 !== undefined) return sheet.기본.서식; return 0; } diff --git a/A00_Common/spreadsheet/spreadsheet_grid.css b/A00_Common/spreadsheet/spreadsheet_grid.css index 6ec0e9dd..024233ba 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.css +++ b/A00_Common/spreadsheet/spreadsheet_grid.css @@ -172,6 +172,19 @@ z-index: 1; } +/* 메모 칸 — 엑셀처럼 오른 위 모서리 빨간 세모. */ +.aislo-cell.has-comment::after { + content: ""; + position: absolute; + top: 0; + right: 0; + width: 0; + height: 0; + border-style: solid; + border-width: 0 6px 6px 0; + border-color: transparent #c00 transparent transparent; +} + .aislo-grid__ref-highlight { position: absolute; box-sizing: border-box; diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index de32e299..2a7d5fbd 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -130,6 +130,7 @@ interface CellDisplay { hAlign: HAlign; title: string; isError: boolean; + color?: string; } function cellDisplay( @@ -151,7 +152,7 @@ function cellDisplay( const isError = typeof scalar === "object" && scalar !== null && "error" in scalar; const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬; const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : ""; - return { text: formatted.글, hAlign, title, isError }; + return { text: formatted.글, hAlign, title, isError, color: formatted.색 }; } export function createGrid(ctx: SpreadsheetContext): GridHandle { @@ -214,6 +215,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { let frozenCols = 0; let frozenW = 0; let frozenH = 0; + let lastSheetId = ""; const resize = new ResizeObserver(() => layoutPanes()); resize.observe(root); @@ -313,6 +315,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { cellEl.style.height = `${h}px`; cellEl.dataset.r = String(r0); cellEl.dataset.c = String(c0); + const addr = toA1(r0, c0); + cellEl.dataset.addr = addr; const style = cellStyleAt(book, sheet, r0, c0); const borders: ResolvedBorders = resolveBoxBorders( book, @@ -326,8 +330,11 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { ); const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식); applyCellStyle(cellEl, style, borders, display.hAlign); - setCellText(cellEl, display.text, display.title || undefined); + if (display.color) cellEl.style.color = display.color; + const comment = sheet.comments?.[addr]; + setCellText(cellEl, display.text, display.title || comment || undefined); cellEl.classList.toggle("is-error", display.isError); + cellEl.classList.toggle("has-comment", !!comment); cellEl.classList.toggle( "is-selected", ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0, @@ -478,6 +485,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { function render(): void { const sheet = ctx.sheet(); + root.style.fontFamily = ctx.book.기본?.글꼴 ?? ""; + root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기}pt` : ""; colGeo = buildColGeometry(ctx.book, sheet); rowGeo = buildRowGeometry(ctx.book, sheet); extent = computeExtent(sheet); @@ -486,8 +495,11 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { frozenCols = Math.min(sheet.틀고정?.열 ?? 0, extent.cols); spacer.style.width = "1px"; spacer.style.height = "1px"; - paneBR.scrollLeft = 0; - paneBR.scrollTop = 0; + if (sheet.id !== lastSheetId) { + lastSheetId = sheet.id; + paneBR.scrollLeft = 0; + paneBR.scrollTop = 0; + } layoutPanes(); } @@ -667,8 +679,17 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { }; } + /** 칸이 든 판(틀고정 4 판 중 하나)의 안쪽 층 — 어느 판인지는 이 반환 요소로 앎. */ + function paneLayerOf(r: number, c: number): HTMLElement { + const inFrozenRow = r < frozenRows; + const inFrozenCol = c < frozenCols; + if (inFrozenRow) return inFrozenCol ? paneTLInner : paneTRInner; + return inFrozenCol ? paneBLInner : paneBRInner; + } + + /** 편집기 자리 — `layer` = 칸이 든 판의 안쪽 층 · `box` = 격자 뿌리 기준 화면 좌표(D 가 층 차이를 빼서 맞춤). */ function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } { - return { layer: overlay, box: screenBoxOf(r, c) }; + return { layer: paneLayerOf(r, c), box: screenBoxOf(r, c) }; } function destroy(): void { diff --git a/A00_Common/spreadsheet/spreadsheet_headers.ts b/A00_Common/spreadsheet/spreadsheet_headers.ts index ac634b4f..3d8be157 100644 --- a/A00_Common/spreadsheet/spreadsheet_headers.ts +++ b/A00_Common/spreadsheet/spreadsheet_headers.ts @@ -100,7 +100,7 @@ class SparseGeometry implements LineGeometry { } export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry { - const base = colCharsToPx(book.기본?.열폭 ?? DEFAULT_COL_CHARS); + const base = colCharsToPx(sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS); const overrides = new Map(); for (const [key, info] of Object.entries(sheet.열 ?? {})) { const c = colIndex(key); @@ -112,7 +112,7 @@ export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry { } export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry { - const base = ptToPx(book.기본?.행높이 ?? DEFAULT_ROW_PT); + const base = ptToPx(sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT); const overrides = new Map(); for (const [key, info] of Object.entries(sheet.행 ?? {}) as [string, RowInfo][]) { const r = Number(key) - 1; diff --git a/resources/tester/spreadsheet/spreadsheet_grid_test.html b/resources/tester/spreadsheet/spreadsheet_grid_test.html new file mode 100644 index 00000000..f393d1d4 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_grid_test.html @@ -0,0 +1,22 @@ + + + + + C 격자 시험 틀 + + + +
+ + + diff --git a/resources/tester/spreadsheet/spreadsheet_grid_test.ts b/resources/tester/spreadsheet/spreadsheet_grid_test.ts new file mode 100644 index 00000000..ed13e30c --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_grid_test.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * spreadsheet_grid_test.ts + * C(격자) 화면 시험 틀 — 병합 · 틀고정 · 숨김 · 폭 · hair 선 · 균등 분할 · 선택 영역 가운데 · + * [Red] 음수 서식 · 1만 행 스크롤 때 DOM 칸 수를 ORCA 로 확인. D · 엔진이 없으니 이 파일이 + * `SpreadsheetContext` 를 최소로 흉내 낸다(엔진 값은 안 씀 — 식 칸이 없음). + * ========================================================================== */ + +import { createGrid } from "../../../A00_Common/spreadsheet/spreadsheet_grid"; +import type { + CalcEngine, + Command, + History, + Sheet, + Workbook, +} from "../../../A00_Common/spreadsheet/spreadsheet_types"; +import { colIndex } from "../../../A00_Common/spreadsheet/spreadsheet_address"; +import type { + GridHandle, + SpreadsheetContext, +} from "../../../A00_Common/spreadsheet/spreadsheet_view_types"; + +const sheet: Sheet = { + id: "s1", + 이름: "시험", + 칸: { + A1: { 값: "□격자 시험 — 병합 · 틀고정 · 숨김 · hair 선", 서식: 1 }, + A3: { 값: "공종", 서식: 2 }, + B3: { 값: "규격", 서식: 2 }, + C3: { 값: "산출 내역", 서식: 2 }, + G3: { 값: "단위", 서식: 2 }, + H3: { 값: "수량", 서식: 2 }, + A4: { 값: "터파기", 서식: 3 }, + C4: { 값: "균등 분할 정렬 보기 글", 서식: 4 }, + H4: { 값: -12.5, 서식: 6 }, + A6: { 값: "선택 영역 가운데(centerContinuous) 보기", 서식: 5 }, + A8: { 값: "hair 테두리 칸", 서식: 3 }, + B8: { 값: 3.14159, 서식: 7 }, + J10000: { 값: "맨 끝 칸(1만 행 스크롤 확인용)" }, + }, + comments: { H4: "메모 — ROUNDDOWN 끝수 확인" }, + 병합: ["A1:H1", "B8:C9"], + 열: { A: { 폭: 14 }, D: { 숨김: true } }, + 행: { "1": { 높이: 22 }, "5": { 숨김: true } }, + 틀고정: { 행: 3, 열: 1 }, + 보기: { 눈금선: true }, +}; + +const book: Workbook = { + 종류: "통합문서", + 판: 1, + 열: "test", + 기본: { 글꼴: "굴림", 크기: 10, 열폭: 8.43, 행높이: 15 }, + 서식: [ + {}, + { 크기: 14, 굵게: true }, + { + 가로: "center", + 세로: "center", + 채움: "#F2F2F2", + 테두리: { + 위: { 선: "thin" }, + 아래: { 선: "thin" }, + 왼: { 선: "thin" }, + 오른: { 선: "thin" }, + }, + }, + { 세로: "center", 테두리: { 위: { 선: "hair" }, 아래: { 선: "hair" } } }, + { 가로: "distributed", 세로: "center" }, + { 가로: "centerContinuous", 세로: "center" }, + { 형식: "0.00;[Red]-0.00" }, + { + 형식: "0.0000", + 테두리: { + 위: { 선: "hair" }, + 아래: { 선: "hair" }, + 왼: { 선: "hair" }, + 오른: { 선: "hair" }, + }, + }, + ], + 시트: [sheet], + 활성: "s1", +}; + +function noopEngine(): CalcEngine { + return { + update: () => [], + rebuild: () => {}, + value: () => null, + precedents: () => [], + snapshot: () => ({}), + }; +} + +function noopHistory(): History { + return { + push: () => {}, + undo: () => null, + redo: () => null, + canUndo: () => false, + canRedo: () => false, + clear: () => {}, + }; +} + +let grid!: GridHandle; + +const ctx: SpreadsheetContext = { + root: document.getElementById("app")!, + book, + readOnly: false, + engine: noopEngine(), + history: noopHistory(), + selection: { + 시트: "s1", + 범위: [{ r0: 2, c0: 2, r1: 2, c1: 2 }], + 활성: { r: 2, c: 2 }, + 기준: { r: 2, c: 2 }, + }, + sheet: () => sheet, + dispatch: (command: Command) => { + if (command.종류 === "열폭") { + const c0 = command.열[0]; + const key = c0 >= 0 ? String.fromCharCode(65 + c0) : "A"; // 시험 데이터가 A~J 안쪽이라 단순 변환으로 충분 + sheet.열 = { ...sheet.열, [key]: { ...sheet.열?.[key], 폭: command.폭 ?? undefined } }; + } else if (command.종류 === "행높이") { + const r0 = command.행[0]; + sheet.행 = { + ...sheet.행, + [String(r0 + 1)]: { ...sheet.행?.[String(r0 + 1)], 높이: command.높이 ?? undefined }, + }; + } + grid.render(); + }, + undo: () => {}, + redo: () => {}, + select: () => grid.renderSelection(), + showSheet: () => {}, + get grid() { + return grid; + }, + editor: { + editing: () => false, + begin: () => {}, + commit: () => {}, + cancel: () => {}, + sync: () => {}, + }, +} as SpreadsheetContext; + +grid = createGrid(ctx); +ctx.root.append(grid.root); + +// ORCA 수치 확인용 — 눈에 안 보이는 전역에 걸어 둠(콘솔 · eval 로 셈). +(window as unknown as { __gridTest: unknown }).__gridTest = { + grid, + sheet, + book, + colIndex, + countCells: () => grid.root.querySelectorAll(".aislo-cell").length, +};