feat(B07): DXF 내보내기 표 = 선 + 글 — 칸 정렬 · 병합 자리 끊김 · B07 수량표도 나옴 (41-2)
- Frame_Export: Table 갈래 table_parts(경계선 · 글 자리) · 정렬 MIDDLE_LEFT · CENTER · RIGHT · 높이 = 칸 → 표 글자 크기 · 전에는 Table 이 조용히 빠짐 - 짝 = openwebcad table-geometry.ts(두 파일 머리) · 거울 시험 test_b07_frame_export_table.py + helper_b07_table_geometry.cjs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
@@ -6,6 +6,10 @@ DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불
|
||||
|
||||
그림(Image)은 DXF 에 그대로 담을 수 없어(외부 파일 참조 방식) 내보내지 않는다. 대신 몇 개를
|
||||
건너뛰었는지 세어 화면이 알릴 수 있게 돌려준다.
|
||||
|
||||
표(Table · B07 수량표 · M02 41장 면적산출 표)는 선 + 글로 풀어 쓴다. 짝(CLAUDE.md 5장 ①):
|
||||
`table_parts` ↔ openwebcad `src/helpers/table-geometry.ts`(`tableBorders` · `cellRect` ·
|
||||
`cellTextAnchor`) — 거울 시험 `resources/tester/test_b07_frame_export_table.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,6 +35,14 @@ logger = logging.getLogger(__name__)
|
||||
_DXF_VERSION = "R2018"
|
||||
_DWG_SOURCE_VERSION = "R2004"
|
||||
_DEFAULT_TEXT_MM = 3.0
|
||||
#: 표 기본 글자 크기 · 여백 — openwebcad `DEFAULT_TABLE_STYLE` 과 같게
|
||||
_TABLE_FONT = 2.2
|
||||
_TABLE_PADDING = 1.0
|
||||
_ALIGN = {
|
||||
"left": TextEntityAlignment.MIDDLE_LEFT,
|
||||
"right": TextEntityAlignment.MIDDLE_RIGHT,
|
||||
"center": TextEntityAlignment.MIDDLE_CENTER,
|
||||
}
|
||||
|
||||
|
||||
def _xy(point: Any) -> tuple[float, float] | None:
|
||||
@@ -47,6 +59,96 @@ def _layer_name(raw: Any) -> str:
|
||||
return name[:255] or "0"
|
||||
|
||||
|
||||
def _span(value: Any) -> int:
|
||||
"""병합 칸 수 — TS `Math.max(1, Math.round(v ?? 1))` 와 같게(반올림은 .5 올림)."""
|
||||
number = float(value) if isinstance(value, (int, float)) else 1.0
|
||||
return max(1, math.floor(number + 0.5))
|
||||
|
||||
|
||||
def _owners(cells: list, rows: int, columns: int) -> list[list[str | None]]:
|
||||
owners: list[list[str | None]] = [[None] * columns for _ in range(rows)]
|
||||
for row in range(rows):
|
||||
for column in range(columns):
|
||||
cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
for r in range(row, min(rows, row + _span(cell.get("rowSpan")))):
|
||||
for c in range(column, min(columns, column + _span(cell.get("colSpan")))):
|
||||
owners[r][c] = f"{row}:{column}"
|
||||
return owners
|
||||
|
||||
|
||||
def table_parts(shape: dict[str, Any]) -> tuple[list[tuple], list[dict[str, Any]]]:
|
||||
"""표 → (경계선 [(x1, y1, x2, y2)], 글 [{text, x, y, align, height}]) — 원점 = 왼쪽 위 · 아래로.
|
||||
|
||||
바깥 테두리는 늘 · 안쪽 선은 양옆(위아래)이 같은 병합 칸이면 끊음 · 글은 칸 가운데 높이 ·
|
||||
정렬에 따라 여백만큼 안쪽."""
|
||||
origin = _xy(shape.get("origin")) or (0.0, 0.0)
|
||||
widths = [float(w) for w in shape.get("columnWidths") or []]
|
||||
heights = [float(h) for h in shape.get("rowHeights") or []]
|
||||
cells = shape.get("cells") or []
|
||||
style = shape.get("style") or {}
|
||||
rows, columns = len(heights), len(widths)
|
||||
if not rows or not columns:
|
||||
return [], []
|
||||
xs, ys = [origin[0]], [origin[1]]
|
||||
for width in widths:
|
||||
xs.append(xs[-1] + width)
|
||||
for height in heights:
|
||||
ys.append(ys[-1] - height)
|
||||
owners = _owners(cells, rows, columns)
|
||||
lines = [
|
||||
(xs[0], ys[0], xs[columns], ys[0]),
|
||||
(xs[0], ys[rows], xs[columns], ys[rows]),
|
||||
(xs[0], ys[0], xs[0], ys[rows]),
|
||||
(xs[columns], ys[0], xs[columns], ys[rows]),
|
||||
]
|
||||
for column in range(1, columns):
|
||||
for row in range(rows):
|
||||
left, right = owners[row][column - 1], owners[row][column]
|
||||
if left is None or left != right:
|
||||
lines.append((xs[column], ys[row], xs[column], ys[row + 1]))
|
||||
for row in range(1, rows):
|
||||
for column in range(columns):
|
||||
above, below = owners[row - 1][column], owners[row][column]
|
||||
if above is None or above != below:
|
||||
lines.append((xs[column], ys[row], xs[column + 1], ys[row]))
|
||||
raw = style.get("padding")
|
||||
padding = float(raw) if isinstance(raw, (int, float)) else _TABLE_PADDING
|
||||
texts = []
|
||||
for row in range(rows):
|
||||
for column in range(columns):
|
||||
cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None
|
||||
if not isinstance(cell, dict) or not cell.get("text"):
|
||||
continue
|
||||
right = xs[min(columns, column + _span(cell.get("colSpan")))]
|
||||
bottom = ys[min(rows, row + _span(cell.get("rowSpan")))]
|
||||
align = cell.get("align") if cell.get("align") in _ALIGN else "center"
|
||||
x = {"left": xs[column] + padding, "right": right - padding}.get(
|
||||
align, (xs[column] + right) / 2
|
||||
)
|
||||
height = cell.get("fontSize") or style.get("fontSize") or _TABLE_FONT
|
||||
texts.append(
|
||||
{
|
||||
"text": str(cell["text"]),
|
||||
"x": x,
|
||||
"y": (ys[row] + bottom) / 2,
|
||||
"align": align,
|
||||
"height": float(height),
|
||||
}
|
||||
)
|
||||
return lines, texts
|
||||
|
||||
|
||||
def _add_table(space: Any, shape: dict[str, Any], attribs: dict[str, Any]) -> None:
|
||||
lines, texts = table_parts(shape)
|
||||
for x1, y1, x2, y2 in lines:
|
||||
space.add_line((x1, y1), (x2, y2), dxfattribs=attribs)
|
||||
for one in texts:
|
||||
text = space.add_text(one["text"], dxfattribs={**attribs, "height": one["height"]})
|
||||
text.set_placement((one["x"], one["y"]), align=_ALIGN[one["align"]])
|
||||
|
||||
|
||||
def _add_entity(
|
||||
space: Any, entity: dict[str, Any], layers: set[str], skipped: dict[str, int]
|
||||
) -> None:
|
||||
@@ -65,6 +167,9 @@ def _add_entity(
|
||||
# 그림은 DXF 가 외부 파일을 가리키는 방식이라 그대로 옮기지 못한다.
|
||||
skipped["Image"] = skipped.get("Image", 0) + 1
|
||||
return
|
||||
if kind == "Table": # 표 = 선 + 글(칸 정렬 반영)
|
||||
_add_table(space, shape, attribs)
|
||||
return
|
||||
|
||||
start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint"))
|
||||
if start and end:
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
*
|
||||
* 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은
|
||||
* 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다.
|
||||
*
|
||||
* 짝(CLAUDE.md 5장 ①): 서버 DXF 내보내기 `B07_DesignDetail_Engine_Frame_Export.table_parts`
|
||||
* (`tableBorders` · `cellRect` · `cellTextAnchor`) — 거울 시험 `test_b07_frame_export_table.py`.
|
||||
*/
|
||||
|
||||
export interface TableCell {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/* 표 기하(`openwebcad/src/helpers/table-geometry.ts`) 거울 헬퍼 — 서버 DXF `table_parts` 와 대조.
|
||||
* stdin JSON [표 shapeData] → stdout JSON [{lines, texts}] (TS 규칙 그대로). */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"B07_DesignDetail",
|
||||
"openwebcad",
|
||||
"src",
|
||||
"helpers",
|
||||
"table-geometry.ts",
|
||||
);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const g = box.exports;
|
||||
|
||||
const shapes = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = shapes.map((s) => {
|
||||
const lines = g
|
||||
.tableBorders(s.origin, s.columnWidths, s.rowHeights, s.cells)
|
||||
.map((b) => [b.x1, b.y1, b.x2, b.y2]);
|
||||
const padding = s.style?.padding ?? 1;
|
||||
const texts = [];
|
||||
for (let row = 0; row < s.rowHeights.length; row += 1)
|
||||
for (let column = 0; column < s.columnWidths.length; column += 1) {
|
||||
const cell = s.cells[row]?.[column];
|
||||
if (!cell?.text) continue;
|
||||
const rect = g.cellRect(s.origin, s.columnWidths, s.rowHeights, row, column, cell);
|
||||
const at = g.cellTextAnchor(rect, cell.align, padding);
|
||||
texts.push({ text: cell.text, x: at.x, y: at.y, align: cell.align ?? "center" });
|
||||
}
|
||||
return { lines, texts };
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,119 @@
|
||||
"""DXF 내보내기 표 = 선 + 글(PLAN 41-2) — `B07_DesignDetail_Engine_Frame_Export`.
|
||||
|
||||
- 거울: `table_parts` 경계선 · 글 자리 = openwebcad `table-geometry.ts`(Node 헬퍼) —
|
||||
병합 · 정렬 · 여백
|
||||
- DXF: M02 면적산출 표 · B07 수량표(`table_entity`) 모두 LINE · TEXT 로 나옴(전에는 조용히 빠짐) ·
|
||||
글 정렬(MIDDLE_LEFT · CENTER · RIGHT) · 높이 = 칸 글자 크기 → 표 글자 크기
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
|
||||
from B07_DesignDetail import B07_DesignDetail_Engine_Frame_Export as export
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import table_entity
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
AREA_TABLE = {
|
||||
"origin": {"x": 10, "y": 50},
|
||||
"columnWidths": [30, 15, 15],
|
||||
"rowHeights": [5, 5, 5],
|
||||
"cells": [
|
||||
[{"text": "항목", "colSpan": 3}, None, None],
|
||||
[
|
||||
{"text": "면적", "rowSpan": 2, "align": "left"},
|
||||
{"text": "12.5", "align": "right"},
|
||||
{"text": "㎡"},
|
||||
],
|
||||
[None, {"text": "1,250", "align": "right", "fontSize": 3}, {"text": ""}],
|
||||
],
|
||||
"style": {"fontSize": 2.5, "padding": 1, "연결": "T1"},
|
||||
}
|
||||
PLAIN = {
|
||||
"origin": {"x": 0, "y": 0},
|
||||
"columnWidths": [10, 10],
|
||||
"rowHeights": [4],
|
||||
"cells": [[{"text": "a"}, {"text": "b", "align": "left"}]],
|
||||
"style": {"padding": 0},
|
||||
}
|
||||
|
||||
|
||||
def _mirror(shapes: list[dict]) -> list[dict]:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b07_table_geometry.cjs")],
|
||||
input=json.dumps(shapes, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_거울_경계선_글_자리가_TS_와_같음():
|
||||
shapes = [AREA_TABLE, PLAIN]
|
||||
for shape, ts in zip(shapes, _mirror(shapes)):
|
||||
lines, texts = export.table_parts(shape)
|
||||
flat = [value for line in lines for value in line]
|
||||
assert flat == pytest.approx([value for line in ts["lines"] for value in line])
|
||||
assert len(lines) == len(ts["lines"])
|
||||
assert [(t["text"], t["x"], t["y"], t["align"]) for t in texts] == [
|
||||
(t["text"], pytest.approx(t["x"]), pytest.approx(t["y"]), t["align"])
|
||||
for t in ts["texts"]
|
||||
]
|
||||
|
||||
|
||||
def test_병합_자리는_선을_끊음():
|
||||
lines, _ = export.table_parts(AREA_TABLE)
|
||||
# 바깥 4 · 안쪽 세로 4(1행 병합 자리 끊김) · 가로 5(「면적」 세로 병합 자리 끊김)
|
||||
vertical = [line for line in lines[4:] if line[0] == line[2]]
|
||||
horizontal = [line for line in lines[4:] if line[1] == line[3]]
|
||||
assert len(vertical) == 4 and len(horizontal) == 5
|
||||
|
||||
|
||||
def _dxf(entities: list[dict], tmp_path) -> ezdxf.document.Drawing:
|
||||
raw, skipped = export.drawing_to_dxf({"entities": entities, "layers": []})
|
||||
assert skipped == {}
|
||||
path = tmp_path / "out.dxf"
|
||||
path.write_bytes(raw)
|
||||
return ezdxf.readfile(path)
|
||||
|
||||
|
||||
def test_면적산출_표가_LINE_TEXT_로_나옴(tmp_path):
|
||||
doc = _dxf([{"type": "Table", "layerId": "기본", "shapeData": AREA_TABLE}], tmp_path)
|
||||
lines, texts = export.table_parts(AREA_TABLE)
|
||||
space = doc.modelspace()
|
||||
assert len(space.query("LINE")) == len(lines) == 13
|
||||
found = {t.dxf.text: t for t in space.query("TEXT")}
|
||||
assert set(found) == {"항목", "면적", "12.5", "㎡", "1,250"} and len(texts) == 5
|
||||
assert found["면적"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_LEFT
|
||||
assert found["12.5"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_RIGHT
|
||||
assert found["항목"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_CENTER
|
||||
assert found["1,250"].dxf.height == 3 and found["항목"].dxf.height == 2.5
|
||||
|
||||
|
||||
def test_B07_수량표도_같이_나옴(tmp_path):
|
||||
qtable = table_entity(
|
||||
"t:qtable",
|
||||
(0, 0),
|
||||
[20, 20],
|
||||
[5, 5],
|
||||
[
|
||||
[{"text": "토공", "colSpan": 2}, None],
|
||||
[{"text": "절토"}, {"text": "12.30", "align": "right"}],
|
||||
],
|
||||
"b08-cross-table",
|
||||
"#ffffff",
|
||||
2.0,
|
||||
"#ffffff",
|
||||
)
|
||||
space = _dxf([qtable], tmp_path).modelspace()
|
||||
assert len(space.query("LINE")) == 7 # 바깥 4 · 세로 1(1행 병합 자리 끊김) · 가로 2
|
||||
assert sorted(t.dxf.text for t in space.query("TEXT")) == ["12.30", "절토", "토공"]
|
||||
Reference in New Issue
Block a user