Merge branch 'sub_laptop_4' of C:/Aislo_wt/sub_laptop_4 into main_laptop_1

This commit is contained in:
2026-11-15 10:06:43 +09:00
4 changed files with 184 additions and 4 deletions
@@ -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;
}
@@ -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 ?? "" });
}
@@ -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);
}
@@ -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