feat(b09-print): 인쇄 미리보기 도면 PDF — B07 도면 + 구조물 조합 도면 · 도각 크기 쪽 · 숨긴 층 뺌 · 벡터 · 한글 (302-2)
- 길 = GET /api/projects/{id}/print/drawings · POST /api/projects/{id}/print/drawings-pdf {ids?} (draft 라우터 · 로그인 · 회사 · 프로젝트 권한 스스로)
- 차례 = B07 도면번호 → 구조물 조합 도면(도번 차례 · 도번마다 한 번 · 더한장)
- B07 = B07 단건 조회 그대로(확정본 · 미확정 조립) · 미확정 횡단 장 구조물 = 화면 TS 를 서버 Node 로(번들 tmp · package.json 안 건드림)
- 쪽 = 도각 양식 실치수(A1 840×594) · 색 = openwebcad 밝은 바탕 규칙 · 글 = 맑은 고딕 심음(subset) · 그림 = data URL 넣음 · 못 넣은 수 = 응답 머리
- 화면 쪽 = fetchDrawingList · fetchDrawingsPdf(약속 꼴) · skippedImagesOf
- requirements = PyMuPDF · ezdxf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
"""인쇄 미리보기 도면(PLAN 302-2) — 목록 · 도면 모으기.
|
||||
|
||||
차례 = B07 도면번호(B07 목록 그대로) →
|
||||
구조물 조합 도면(도번 차례 · 도번마다 한 번 · `도면` 다음 `더한장`).
|
||||
B07 도면 = B07 단건 조회 그대로(확정본 = 저장 JSON · 미확정 = 서버 조립) + 미확정 횡단 장 구조물 =
|
||||
B07 화면과 같은 TS 를 Node 로(`B09_Draft_Print_Drawings_Node.ts` · CLAUDE.md 5장 ②).
|
||||
구조물 도면 = 프로젝트 사본 `도면.json` + 얹는 도각(M02 `structure_frame` · 장 도번).
|
||||
저장 · 확정 상태는 손대지 않음 — 매번 읽어 만듦.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
entities_bbox,
|
||||
template_entities,
|
||||
use_project_templates,
|
||||
use_title_fields,
|
||||
)
|
||||
from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import paper_name
|
||||
from common_util.common_util_node_bundle import ROOT, bundle_stale, node_env, run_bundle_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
B07_PREFIX = "b07:"
|
||||
STRUCTURE_PREFIX = "structure:"
|
||||
_ENTRY = Path(__file__).with_name("B09_Draft_Print_Drawings_Node.ts")
|
||||
# 번들은 git 밖(tmp) — package.json 을 안 건드리려고 vite 를 바로 부름
|
||||
BUNDLE = ROOT / "tmp" / "print_drawings_node" / "B09_Draft_Print_Drawings_Node.js"
|
||||
_BUILD = [
|
||||
"node",
|
||||
"./config/node_modules/vite/bin/vite.js",
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts",
|
||||
"--outDir",
|
||||
"../tmp/print_drawings_node",
|
||||
]
|
||||
|
||||
|
||||
async def project_root(project_id: UUID) -> Path:
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
async with get_db_pool().acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
return Path(resolve_stored_project_path(stored)).resolve()
|
||||
|
||||
|
||||
async def title_fields(project_id: UUID) -> dict[str, str]:
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
|
||||
|
||||
return await title_block_fields(project_id)
|
||||
|
||||
|
||||
async def b07_list(project_id: UUID) -> tuple[int | None, list[dict[str, Any]]]:
|
||||
"""(노선 id, B07 목록) — 노선 미확정 · 자료 없음이면 (None, [])."""
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import get_design_drawing_list
|
||||
|
||||
response = await get_design_drawing_list(project_id)
|
||||
if not hasattr(response, "drawings"): # JSONResponse = 404 · 409 · 500
|
||||
return None, []
|
||||
return response.route_id, [item.model_dump() for item in response.drawings]
|
||||
|
||||
|
||||
async def b07_drawing(project_id: UUID, drawing_id: str) -> dict[str, Any]:
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import get_design_drawing
|
||||
|
||||
response = await get_design_drawing(project_id, drawing_id)
|
||||
if not hasattr(response, "drawing"):
|
||||
raise ValueError(f"B07 도면 「{drawing_id}」 을 읽지 못했습니다.")
|
||||
return response.model_dump()
|
||||
|
||||
|
||||
async def section_detail(project_id: UUID, route_id: int) -> dict[str, Any] | None:
|
||||
from B06_Section.B06_Section_Router import get_section_detail
|
||||
|
||||
response = await get_section_detail(project_id, route_id)
|
||||
dump = getattr(response, "model_dump", None)
|
||||
return dump(mode="json") if dump else None
|
||||
|
||||
|
||||
def _bundle_ready() -> bool:
|
||||
stale = bundle_stale(BUNDLE) or _ENTRY.stat().st_mtime > BUNDLE.stat().st_mtime
|
||||
if not stale:
|
||||
return True
|
||||
result = subprocess.run( # noqa: S603 — 고정 명령
|
||||
_BUILD, cwd=str(ROOT), env=node_env(), capture_output=True, text=True, timeout=300
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("인쇄 도면 Node 번들 빌드 실패:\n%s", result.stderr)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def place_structures(detail: dict[str, Any], drawings: dict[str, dict[str, Any]]) -> bool:
|
||||
"""미확정 횡단 장에 구조물을 얹음(제자리) — 실패면 거짓(서버 그림만 나감)."""
|
||||
if not drawings or not _bundle_ready():
|
||||
return False
|
||||
output = run_bundle_json(BUNDLE, "", {"detail": detail, "drawings": drawings})
|
||||
placed = (output or {}).get("drawings") if isinstance(output, dict) else None
|
||||
if not isinstance(placed, dict):
|
||||
return False
|
||||
for key, drawing in placed.items():
|
||||
if key in drawings and isinstance(drawing, dict):
|
||||
drawings[key].clear()
|
||||
drawings[key].update(drawing)
|
||||
return True
|
||||
|
||||
|
||||
def sheet_number(number: str, index: int) -> str:
|
||||
"""장 도번 — 1장 = 도번 · n 째 장 = 도번-n(M02 `sheetNumber` 와 같음)."""
|
||||
return "" if not number else number if index == 0 else f"{number}-{index + 1}"
|
||||
|
||||
|
||||
def structure_sheets(root: Path) -> list[dict[str, Any]]:
|
||||
"""구조물 조합 도면 장 — 도번 차례 · 같은 도번은 한 번 · {id, label, number, drawing}."""
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots, structures_dir
|
||||
from M02_MasterTemplete.M02_Item_Source import read_json
|
||||
|
||||
docs: dict[str, tuple[str, dict[str, Any]]] = {}
|
||||
for owner, copy in linked_snapshots(root).items():
|
||||
if "도면" not in copy["부분"]:
|
||||
continue
|
||||
doc = read_json(structures_dir(root) / copy["폴더"] / "도면.json")
|
||||
if not isinstance(doc, dict) or not isinstance(doc.get("도면"), dict):
|
||||
continue
|
||||
number = str(doc.get("도번") or "")
|
||||
name = str((copy.get("항목") or {}).get("name") or copy["폴더"])
|
||||
docs.setdefault(number or f"~{owner}", (name, doc))
|
||||
sheets = []
|
||||
for key in sorted(docs):
|
||||
name, doc = docs[key]
|
||||
number = "" if key.startswith("~") else key
|
||||
pages = [{"이름": "", "도면": doc["도면"]}, *(doc.get("더한장") or [])]
|
||||
for index, page in enumerate(pages):
|
||||
if not isinstance(page, dict) or not isinstance(page.get("도면"), dict):
|
||||
continue
|
||||
label = f"{name} · {page['이름']}" if page.get("이름") else name
|
||||
numbered = sheet_number(number, index) or f"{key.lstrip('~')}-{index + 1}"
|
||||
sheets.append(
|
||||
{
|
||||
"id": f"{STRUCTURE_PREFIX}{numbered}",
|
||||
"label": label,
|
||||
"number": sheet_number(number, index),
|
||||
"drawing": page["도면"],
|
||||
}
|
||||
)
|
||||
return sheets
|
||||
|
||||
|
||||
def with_frame(drawing: dict[str, Any], number: str) -> dict[str, Any]:
|
||||
"""구조물 도면 + 얹는 도각(M02 화면과 같은 `structure_frame` · 표제란 = 프로젝트 값)."""
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import structure_frame
|
||||
|
||||
frame = structure_frame(number)
|
||||
layers = [*(drawing.get("layers") or []), *([frame["layer"]] if frame["layer"] else [])]
|
||||
return {
|
||||
**drawing,
|
||||
"entities": [*frame["entities"], *drawing.get("entities", [])],
|
||||
"layers": layers,
|
||||
}
|
||||
|
||||
|
||||
async def drawing_items(project_id: UUID) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
"""(목록, 만들 때 쓸 문맥).
|
||||
|
||||
목록 칸 = {id, source, kind, label, number, confirmed, paper, images_skipped}.
|
||||
"""
|
||||
root = await project_root(project_id)
|
||||
use_project_templates(root)
|
||||
template = entities_bbox(template_entities())
|
||||
paper = paper_name(template[2] - template[0], template[3] - template[1]) if template else None
|
||||
route_id, b07 = await b07_list(project_id)
|
||||
items = [
|
||||
{
|
||||
"id": f"{B07_PREFIX}{item['id']}",
|
||||
"source": "b07",
|
||||
"kind": item["kind"],
|
||||
"label": item["label"],
|
||||
"number": str(number),
|
||||
"confirmed": bool(item.get("confirmed")),
|
||||
"paper": paper,
|
||||
"images_skipped": None, # PDF 만들 때 셈 — 응답 머리 X-Aislo-Skipped-Images
|
||||
}
|
||||
for number, item in enumerate(b07, start=1)
|
||||
]
|
||||
sheets = await asyncio.to_thread(structure_sheets, root)
|
||||
items += [
|
||||
{
|
||||
"id": sheet["id"],
|
||||
"source": "structure",
|
||||
"kind": "structure",
|
||||
"label": sheet["label"],
|
||||
"number": sheet["number"],
|
||||
"confirmed": True,
|
||||
"paper": paper,
|
||||
"images_skipped": None,
|
||||
}
|
||||
for sheet in sheets
|
||||
]
|
||||
return items, {"root": root, "route_id": route_id, "sheets": {s["id"]: s for s in sheets}}
|
||||
|
||||
|
||||
async def drawings_in_order(
|
||||
project_id: UUID, items: list[dict[str, Any]], context: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""고른 목록 차례대로 CAD 도면 — 미확정 횡단 장은 구조물을 얹어서."""
|
||||
base_fields = await title_fields(project_id)
|
||||
drawings: list[dict[str, Any]] = []
|
||||
unconfirmed_cross: dict[str, dict[str, Any]] = {}
|
||||
for item in items:
|
||||
if item["source"] == "b07":
|
||||
response = await b07_drawing(project_id, item["id"][len(B07_PREFIX) :])
|
||||
drawing = response["drawing"]
|
||||
if item["kind"] == "cross" and not response.get("confirmed"):
|
||||
unconfirmed_cross[item["id"]] = drawing
|
||||
else:
|
||||
sheet = context["sheets"][item["id"]]
|
||||
use_project_templates(context["root"])
|
||||
use_title_fields(base_fields)
|
||||
drawing = with_frame(sheet["drawing"], sheet["number"])
|
||||
drawings.append(drawing)
|
||||
if unconfirmed_cross and context.get("route_id") is not None:
|
||||
detail = await section_detail(project_id, int(context["route_id"]))
|
||||
if detail is None or not await asyncio.to_thread(
|
||||
place_structures, detail, unconfirmed_cross
|
||||
):
|
||||
logger.warning(
|
||||
"인쇄 도면 — 미확정 횡단 장 구조물을 못 얹음(서버 그림만): %s", project_id
|
||||
)
|
||||
return drawings
|
||||
Reference in New Issue
Block a user