- 길 = 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
274 lines
10 KiB
Python
274 lines
10 KiB
Python
"""PLAN 302-2 인쇄 미리보기 도면 PDF — 목록 차례 · 쪽 수 · 쪽 크기 · 한글 · 숨긴 층 · 권한.
|
|
|
|
임시 폴더 견본만 씀(공용 storage 안 씀) — B07 목록 · 단건 · 표제란 값은 바꿔 끼움.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import pymupdf
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
|
frame_entities,
|
|
usable_bbox,
|
|
use_project_templates,
|
|
use_title_fields,
|
|
)
|
|
from B09_Estimation.draft import B09_Draft_Print_Drawings_Router as router_module
|
|
from B09_Estimation.draft import B09_Draft_Print_Drawings_Source as source
|
|
from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import (
|
|
PT_PER_MM,
|
|
add_drawing_page,
|
|
paint_color,
|
|
)
|
|
from common_util import common_util_auth as auth
|
|
|
|
TITLE = {"공사명": "가나다임도개설공사", "용역회사": "라마바설계"}
|
|
|
|
|
|
def _text(label: str, x: float, y: float, layer: str) -> dict:
|
|
return {
|
|
"id": str(uuid4()),
|
|
"type": "Text",
|
|
"lineColor": "#ffffff",
|
|
"lineWidth": 1,
|
|
"layerId": layer,
|
|
"shapeData": {
|
|
"basePoint": {"x": x, "y": y},
|
|
"label": label,
|
|
"options": {"fontSize": 10, "textAlign": "left", "textColor": "#ffffff"},
|
|
},
|
|
}
|
|
|
|
|
|
def _b07_drawing(drawing_id: str) -> dict:
|
|
"""도각(시스템 A1 · 표제란 = TITLE) + 보이는 글 + 숨긴 층 · 동결 층 글."""
|
|
use_project_templates(None)
|
|
use_title_fields(TITLE)
|
|
entities = frame_entities(drawing_id, usable_bbox(), fit=False, fields={"도면명": "종단면도"})
|
|
entities += [
|
|
_text("보이는글", 200, 300, "draw"),
|
|
_text("숨은글", 200, 320, "hidden"),
|
|
_text("얼린글", 200, 340, "frozen"),
|
|
]
|
|
layers = [
|
|
{"id": "draw", "name": "그림", "isVisible": True, "isLocked": False},
|
|
{"id": "hidden", "name": "숨김", "isVisible": False, "isLocked": False},
|
|
{"id": "frozen", "name": "동결", "isVisible": True, "isLocked": False, "isFrozen": True},
|
|
]
|
|
return {"format": "openwebcad", "entities": entities, "layers": layers}
|
|
|
|
|
|
def _structure_doc(number: str, more: list[str] = ()) -> dict:
|
|
sheet = {
|
|
"format": "openwebcad",
|
|
"entities": [_text("구조물그림", 300, 300, "기본")],
|
|
"layers": [],
|
|
}
|
|
return {
|
|
"도번": number,
|
|
"도면": sheet,
|
|
"더한장": [{"장id": f"s{i}", "이름": name, "도면": sheet} for i, name in enumerate(more)],
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def sample(tmp_path, monkeypatch):
|
|
"""B07 둘(종단 · 표지) + 구조물 사본 셋(SD-2 둘 = 한 번 · 더한장 하나 · SD-1 하나)."""
|
|
from M02_MasterTemplete import M02_Item_Snapshot as snapshot
|
|
|
|
base = tmp_path / "structures"
|
|
copies = {
|
|
"pipe@10__a": ("A", _structure_doc("SD-000002", ["상세"])),
|
|
"pipe@20__b": ("B", _structure_doc("SD-000002", ["상세"])),
|
|
"pipe@30__c": ("C", _structure_doc("SD-000001")),
|
|
}
|
|
linked = {}
|
|
for owner, (folder, doc) in copies.items():
|
|
(base / folder).mkdir(parents=True)
|
|
(base / folder / "도면.json").write_text(json.dumps(doc, ensure_ascii=False), "utf-8")
|
|
linked[owner] = {"항목": {"name": f"구조물{folder}"}, "폴더": folder, "부분": ["도면"]}
|
|
monkeypatch.setattr(snapshot, "linked_snapshots", lambda root: linked)
|
|
monkeypatch.setattr(snapshot, "structures_dir", lambda root: base)
|
|
|
|
async def root(_pid):
|
|
return tmp_path
|
|
|
|
async def fields(_pid):
|
|
return dict(TITLE)
|
|
|
|
async def b07_list(_pid):
|
|
return 7, [
|
|
{
|
|
"id": "longitudinal_1",
|
|
"kind": "longitudinal",
|
|
"label": "종단도(1)",
|
|
"confirmed": True,
|
|
},
|
|
{"id": "cover", "kind": "cover", "label": "표지", "confirmed": False},
|
|
]
|
|
|
|
async def b07_drawing(_pid, drawing_id):
|
|
return {"drawing": _b07_drawing(drawing_id), "confirmed": True}
|
|
|
|
monkeypatch.setattr(source, "project_root", root)
|
|
monkeypatch.setattr(source, "title_fields", fields)
|
|
monkeypatch.setattr(source, "b07_list", b07_list)
|
|
monkeypatch.setattr(source, "b07_drawing", b07_drawing)
|
|
return tmp_path
|
|
|
|
|
|
def _client(session: dict | None = None, project_company: int | None = None) -> TestClient:
|
|
app = FastAPI()
|
|
app.include_router(router_module.router)
|
|
if session is not None:
|
|
app.dependency_overrides[auth.verify_session] = lambda: session
|
|
if project_company is None:
|
|
app.dependency_overrides[auth.require_project_access] = lambda: session
|
|
return TestClient(app)
|
|
|
|
|
|
SESSION = {"user_id": 1, "company_id": 1, "role": "USER", "is_master": False}
|
|
|
|
|
|
def test_paint_color_theme_짝():
|
|
"""openwebcad theme.ts paintColor(밝은 바탕) 손셈.
|
|
|
|
자동색 검정 · 밝은 노랑 휘도 0.3 으로 · 어두운 색 그대로.
|
|
"""
|
|
assert paint_color("#ffffff") == paint_color("#FFF") == paint_color("#f5f7fa") == (0, 0, 0)
|
|
assert paint_color("#f6d55c") == (89 / 255, 77 / 255, 33 / 255)
|
|
assert paint_color("#336699") == (0x33 / 255, 0x66 / 255, 0x99 / 255)
|
|
|
|
|
|
def test_목록_차례_B07_번호_다음_도번_한_번(sample):
|
|
pid = uuid4()
|
|
body = _client(SESSION).get(f"/api/projects/{pid}/print/drawings").json()
|
|
assert [item["id"] for item in body] == [
|
|
"b07:longitudinal_1",
|
|
"b07:cover",
|
|
"structure:SD-000001",
|
|
"structure:SD-000002",
|
|
"structure:SD-000002-2",
|
|
]
|
|
assert [item["number"] for item in body][:2] == ["1", "2"]
|
|
assert body[3]["label"] == "구조물A" and body[4]["label"] == "구조물A · 상세"
|
|
assert {item["paper"] for item in body} == {"A1"}
|
|
assert {item["source"] for item in body} == {"b07", "structure"}
|
|
|
|
|
|
def test_PDF_쪽수_크기_한글_숨긴층(sample):
|
|
pid = uuid4()
|
|
response = _client(SESSION).post(f"/api/projects/{pid}/print/drawings-pdf", json={})
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"] == "application/pdf"
|
|
assert response.headers["x-aislo-pages"] == "5"
|
|
assert json.loads(response.headers["x-aislo-skipped-images"]) == {}
|
|
doc = pymupdf.open(stream=response.content, filetype="pdf")
|
|
assert doc.page_count == 5
|
|
for page in doc:
|
|
assert abs(page.rect.width / PT_PER_MM - 840) < 0.5
|
|
assert abs(page.rect.height / PT_PER_MM - 594) < 0.5
|
|
first = doc[0].get_text()
|
|
assert "가나다임도개설공사" in first and "라마바설계" in first # 표제란 한글 그대로
|
|
assert "보이는글" in first
|
|
assert "숨은글" not in first and "얼린글" not in first
|
|
# 구조물 장 = 얹은 도각 + 장 도번 · 표제란 = 프로젝트 값
|
|
assert "구조물그림" in doc[2].get_text() and "SD-000001" in doc[2].get_text()
|
|
assert "SD-000002-2" in doc[4].get_text() and "가나다임도개설공사" in doc[4].get_text()
|
|
|
|
|
|
def test_PDF_고른_차례_모르는_id(sample):
|
|
pid = uuid4()
|
|
client = _client(SESSION)
|
|
picked = ["structure:SD-000001", "b07:cover"]
|
|
response = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": picked})
|
|
doc = pymupdf.open(stream=response.content, filetype="pdf")
|
|
assert doc.page_count == 2 and "구조물그림" in doc[0].get_text()
|
|
bad = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": ["b07:없음"]})
|
|
assert bad.status_code == 400
|
|
|
|
|
|
class _FakePool:
|
|
"""require_project_access 가 읽는 프로젝트 회사 한 줄."""
|
|
|
|
def __init__(self, company_id: int):
|
|
self.row = (company_id,)
|
|
|
|
def acquire(self):
|
|
pool = self
|
|
|
|
class _Cursor:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_):
|
|
return False
|
|
|
|
async def execute(self, *_):
|
|
return None
|
|
|
|
async def fetchone(self):
|
|
return pool.row
|
|
|
|
class _Connection:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_):
|
|
return False
|
|
|
|
def cursor(self):
|
|
return _Cursor()
|
|
|
|
return _Connection()
|
|
|
|
|
|
def test_로그인_회사_권한(sample, monkeypatch):
|
|
pid = uuid4()
|
|
path = f"/api/projects/{pid}/print/drawings"
|
|
assert _client().get(path).status_code == 401 # 쿠키 없음
|
|
no_company = {**SESSION, "company_id": None}
|
|
assert _client(no_company, project_company=0).get(path).status_code == 403
|
|
monkeypatch.setattr(auth, "get_db_pool", lambda: _FakePool(2))
|
|
other = _client(SESSION, project_company=2)
|
|
assert other.get(path).status_code == 403 # 남의 회사 프로젝트
|
|
assert other.post(f"/api/projects/{pid}/print/drawings-pdf", json={}).status_code == 403
|
|
monkeypatch.setattr(auth, "get_db_pool", lambda: _FakePool(1))
|
|
assert _client(SESSION, project_company=1).get(path).status_code == 200
|
|
|
|
|
|
def test_그림_넣기_못_넣은_수():
|
|
png = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 4, 4), 0).tobytes("png")
|
|
import base64
|
|
|
|
def image(data: str) -> dict:
|
|
points = [{"x": 10, "y": 10}, {"x": 50, "y": 10}, {"x": 50, "y": 50}, {"x": 10, "y": 50}]
|
|
return {"type": "Image", "layerId": "a", "shapeData": {"points": points, "imageData": data}}
|
|
|
|
drawing = {
|
|
"entities": [
|
|
image("data:image/png;base64," + base64.b64encode(png).decode()),
|
|
image("/api/assets/없는그림.png"),
|
|
],
|
|
"layers": [],
|
|
}
|
|
doc = pymupdf.open()
|
|
paper, skipped = add_drawing_page(doc, drawing)
|
|
assert skipped == 1 and len(doc[0].get_images()) == 1
|
|
|
|
|
|
def test_미확정_횡단_구조물_Node_번들이_돎():
|
|
"""서버 Node(화면 TS 그대로) — 번들 빌드 · 실행 · 배치 없는 장은 그대로."""
|
|
entity = _text("서버그림", 0, 0, "draw")
|
|
drawings = {"b07:cross_x": {"entities": [entity], "cross_placements": []}}
|
|
assert source.place_structures({"cross_sections": []}, drawings) is True
|
|
assert drawings["b07:cross_x"]["entities"] == [entity]
|
|
assert Path(source.BUNDLE).is_file()
|