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:
2026-10-04 22:48:34 +09:00
co-authored by Claude Opus 5.5
parent 0bc5606638
commit cc63e49ab3
4 changed files with 268 additions and 0 deletions
@@ -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: