From 5e6037270ebcfd2a2f02d878d094725efa433d5d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 10:06:06 +0900 Subject: [PATCH] =?UTF-8?q?fix(833-1):=20=EC=9B=90=EB=AC=B8=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=ED=91=9C=20=EA=B9=A8=EC=A7=90=20=E2=80=94=20?= =?UTF-8?q?=ED=91=9C=20=EB=A7=A8=20=EC=95=84=EB=9E=98=20=EB=B9=84=EA=B3=A0?= =?UTF-8?q?=20=C2=B7=20[=EC=A3=BC]=20=C2=B7=20=E2=80=BB=20=EC=84=9C?= =?UTF-8?q?=EC=88=A0=20=EC=A4=84=EC=9D=84=20=ED=91=9C=20=EB=B0=96=20?= =?UTF-8?q?=EA=B8=80=EB=A1=9C=20=C2=B7=20=EC=84=9C=EC=88=A0=20=EC=B9=B8=20?= =?UTF-8?q?=EC=A4=84=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 순수 계산 logic/M01_MasterData_logic_source_notes.ts (서술 줄 가르기 · 줄바꿈 칸 판정) · 원문 글 · 차례 그대로 - 원문 표 2,573 · 폭 420 기준 서술 줄 때문에 넘친 표 138 → 0 · 넘침 1,228 → 1,020 - 시험 test_833_1_source_notes (보기 + 전수 글 보존) Co-Authored-By: Claude Sonnet 5.5 --- .../M01_MasterData_UI_LogicLab_Side.css | 16 +++ .../M01_MasterData_UI_LogicLab_Source.ts | 32 +++++- .../M01_MasterData_logic_source_notes.ts | 43 ++++++++ resources/tester/test_833_1_source_notes.py | 97 +++++++++++++++++++ 4 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 M01_MasterData/logic/M01_MasterData_logic_source_notes.ts create mode 100644 resources/tester/test_833_1_source_notes.py diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css index db0b69c32..383d174dc 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css @@ -79,3 +79,19 @@ html:not([data-size="compact"], [data-size="medium"]) .m01-master__panel .m01-lo .m01lab-src__find .ui-find__list { max-height: 40vh; } + +/* 표 + 표 아래 비고 · [주] 글(833-1) — 글은 패널 폭 그대로 접힘 */ +.m01lab-src__tblwrap { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +/* 서술 칸(한 줄 30자 이상) · 한 칸짜리 표 — 접고, 너무 좁게 찌그러지지 않게 */ +.m01lab-src .m01lab-src__table td.m01lab-src__wrapcell { + white-space: pre-wrap; + overflow-wrap: anywhere; + min-width: 14em; + max-width: 28em; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts index 8b5153334..b6c5336aa 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts @@ -8,6 +8,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; import { API_BASE_URL } from "@config/config_frontend"; +import { splitTableNotes, wrapCell } from "./logic/M01_MasterData_logic_source_notes"; import "./M01_MasterData_UI_LogicLab_Side.css"; const TEXT = { @@ -48,22 +49,45 @@ function blockNode(b: Block): HTMLElement { } function rawNode(b: Block): HTMLElement { - if (b.table) - return el("div", { + if (b.table) { + // 표 맨 아래 비고 · [주] · ※ 줄 = 표 밖 글로(833-1) — 긴 글이 열 폭을 밀지 않게 + const { rows, notes } = splitTableNotes(b.table); + const grid = el("div", { className: "m01-logic__scroll", children: [ el("table", { className: "ui-table ui-table--data m01-logic__grid m01lab-src__table", children: [ el("tbody", { - children: b.table.map((r) => - el("tr", { children: r.map((c) => el("td", { text: c })) }), + children: rows.map((r) => + el("tr", { + children: r.map((c) => + el("td", { + text: c, + className: wrapCell(c, r.length) ? "m01lab-src__wrapcell" : "", + }), + ), + }), ), }), ], }), ], }); + if (!notes.length) return grid; + return el("div", { + className: "m01lab-src__tblwrap", + children: [ + grid, + ...notes.map((n) => + el("p", { + className: "m01lab-src__p m01lab-src__tnote", + children: [...(n.label ? [el("strong", { text: n.label }), " "] : []), n.text], + }), + ), + ], + }); + } if (b.h) return el("h4", { className: "m01lab-src__head", text: b.text ?? "" }); return el("p", { className: "m01lab-src__p", text: b.p ?? "" }); } diff --git a/M01_MasterData/logic/M01_MasterData_logic_source_notes.ts b/M01_MasterData/logic/M01_MasterData_logic_source_notes.ts new file mode 100644 index 000000000..8103ffc34 --- /dev/null +++ b/M01_MasterData/logic/M01_MasterData_logic_source_notes.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * M01_MasterData_logic_source_notes.ts + * 원문 표 맨 아래 서술 줄(비고 · [주] · ※ …) 가르기 — 순수 계산(화면 · fetch 없음 · PLAN 833-1) + * 표 끝에 이어 붙은 서술 줄 = 표 밖 글로 · 글 순서 · 내용은 그대로 · 위쪽 줄(표 몸 · 묶음 머리)은 안 건드림 + * ========================================================================== */ + +/** 표 밖으로 나가는 서술 줄 하나 — `label` = 「비고」 · 「[주]」 머리(없으면 빈 글) */ +export interface NoteLine { + label: string; + text: string; +} + +const LABEL = /^(비고|\[?주\]?\)?|※|참고)\s*\d*$/; +const MARK = /^(※|-|∙|·|\[주\]|주\)|비고|참고)/; +const LONG = 30; + +/** 한 줄이 서술 줄이면 글 — 아니면 null (머리 줄 · 숫자 줄 · 묶음 머리(짧은 한 칸)는 아님) */ +export function noteLine(row: string[]): NoteLine | null { + if (row.length < 2) return null; + const full = row.map((c, k) => [k, c.trim()] as const).filter(([, c]) => c); + if (full.length === 1) { + const [[, text]] = full; + return MARK.test(text) || text.length >= LONG ? { label: "", text } : null; + } + if (full.length === 2 && full[0][0] === 0 && LABEL.test(full[0][1])) + return { label: full[0][1], text: full[1][1] }; + return null; +} + +/** 표 → 몸 줄 + 맨 아래 이어 붙은 서술 줄 — 머리(첫 줄)는 늘 몸 */ +export function splitTableNotes(table: string[][]): { rows: string[][]; notes: NoteLine[] } { + let end = table.length; + while (end > 1 && noteLine(table[end - 1])) end--; + return { + rows: table.slice(0, end), + notes: table.slice(end).map((r) => noteLine(r) as NoteLine), + }; +} + +/** 칸 글이 줄바꿈 대상 — 한 줄이 30자 이상(서술 칸) · 한 칸짜리 표(글 상자) · 숫자 칸은 안 접음 */ +export function wrapCell(text: string, cols: number): boolean { + return cols === 1 || text.split("\n").some((l) => l.length >= LONG); +} diff --git a/resources/tester/test_833_1_source_notes.py b/resources/tester/test_833_1_source_notes.py new file mode 100644 index 000000000..2c730a913 --- /dev/null +++ b/resources/tester/test_833_1_source_notes.py @@ -0,0 +1,97 @@ +"""833-1 지킴 — 원문 표 맨 아래 서술 줄(비고 · [주] · ※) 가르기 · 서술 칸 줄바꿈 판정. + +순수 TS(`M01_MasterData/logic/M01_MasterData_logic_source_notes.ts`)를 node 로 돌림 · 서버 · 화면 없음. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +TS = (ROOT / "M01_MasterData/logic/M01_MasterData_logic_source_notes.ts").as_uri() + + +def _node(body: str, data: object = None) -> object: + src = f'import {{ noteLine, splitTableNotes, wrapCell }} from "{TS}";\n' + body + done = subprocess.run( + ["node", "--experimental-strip-types", "--input-type=module", "-e", src], + input=json.dumps(data, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + cwd=ROOT, + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_보기_서술_줄_가르기() -> None: + got = _node( + r""" +const t = [ + ["구분", "단위", "수량"], + ["보통인부", "인", "1"], + ["비고", "- 비계주위 보호망을 시공하지 않는 경우 본 시공량의 8%를 증하여 적용한다.", ""], + ["※ 위 표에서 ‘노’는 ‘노지묘’이다.", "", ""], +]; +console.log(JSON.stringify([splitTableNotes(t), noteLine(["공종", "Belt폭", ""]), + noteLine(["뒷채움 자재비 및 운반비 별산", "", ""]), noteLine(["1", "2", "3"]), + noteLine(["단독"]), splitTableNotes([["비고", "긴 글이 머리 줄에 있어도 머리는 몸"]]).notes.length, + [wrapCell("7", 3), wrapCell("가".repeat(30), 3), wrapCell("글", 1), wrapCell("짧음\n" + "나".repeat(31), 2)]])); +""" + ) + split = got[0] + assert split["rows"] == [["구분", "단위", "수량"], ["보통인부", "인", "1"]] + assert [n["label"] for n in split["notes"]] == ["비고", ""] + assert split["notes"][1]["text"].startswith("※ 위 표에서") + assert got[1:6] == [ + None, + None, + None, + None, + 0, + ] # 짧은 묶음 머리 · 숫자 줄 · 한 칸 · 머리 줄은 서술 아님 + assert got[6] == [False, True, True, True] + + +def test_원문_전수_글_그대로_표_밖으로() -> None: + """품셈 본문 표 전부 — 줄 수 · 글 합계가 가르기 앞뒤 같음 · 머리 줄은 몸에 남음 · 표 밖 글 표는 200 넘음.""" + from M01_MasterData import M01_MasterData_Store as _s # noqa: F401 + from M01_MasterData import M01_MasterData_Store_Make as mk + + tables = [] + for book in mk.BOOKS: + for _d, _n, _t, folder in mk._chapters(book): + for md in sorted(folder.glob("*.md")): + for b in mk._blocks(md.read_text(encoding="utf-8").split("\n")): + if "table" in b: + tables.append(b["table"]) + got = _node( + r""" +const tables = JSON.parse(await new Promise((ok) => { let s = ""; process.stdin.on("data", (d) => (s += d)); process.stdin.on("end", () => ok(s)); })); +let lost = 0, moved = 0, headBad = 0; +for (const t of tables) { + const { rows, notes } = splitTableNotes(t); + const had = t.flat().join("").replace(/\s/g, ""); + const now = [...rows.flat(), ...notes.flatMap((n) => [n.label, n.text])].join("").replace(/\s/g, ""); + if (had !== now || rows.length + notes.length !== t.length) lost++; + if (rows.length < 1) headBad++; + if (notes.length) moved++; +} +console.log(JSON.stringify({ n: tables.length, lost, moved, headBad })); +""", + tables, + ) + assert got["n"] > 2500 and got["lost"] == 0 and got["headBad"] == 0 + assert got["moved"] > 200