- 점선 = 미리 자른 실선 조각(Chromium 이 점선을 채운 윤곽으로 풀어 PDF 3 배 커지는 것 막음) - 글 안 빈칸 그대로(white-space: pre) · 세운 글 방향 = 화면(openwebcad canvas)과 같게 - 못 넣은 그림 = data: 아님 · base64 깨짐 - test_302_2: PDF 읽기 pymupdf → pypdf · add_drawing_page(pymupdf 문서) 시험 → drawing_page(SVG) 로 고침 · 점선 조각 · 빈칸 시험 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""PLAN 302-2 · 535-2 인쇄 미리보기 도면 PDF — 목록 차례 · 쪽 수 · 쪽 크기 · 한글 · 숨긴 층 · 권한 ·
|
||
점선 조각 · 그림 · 빈칸 그대로(PDF 읽기 = pypdf).
|
||
|
||
임시 폴더 견본만 씀(공용 storage 안 씀) — B07 목록 · 단건 · 표제란 값은 바꿔 끼움.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import json
|
||
from pathlib import Path
|
||
from uuid import uuid4
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
from pypdf import PdfReader
|
||
|
||
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,
|
||
dash_pieces,
|
||
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"]) == {}
|
||
if response.status_code == 503:
|
||
pytest.skip(response.json()["message"]) # 서버에 Edge · Chrome 없음
|
||
pages = PdfReader(io.BytesIO(response.content)).pages
|
||
assert len(pages) == 5
|
||
for page in pages:
|
||
assert abs(float(page.mediabox.width) / PT_PER_MM - 840) < 0.5
|
||
assert abs(float(page.mediabox.height) / PT_PER_MM - 594) < 0.5
|
||
texts = [page.extract_text() for page in pages]
|
||
first = texts[0]
|
||
assert "가나다임도개설공사" in first and "라마바설계" in first # 표제란 한글 그대로
|
||
assert "보이는글" in first
|
||
assert "숨은글" not in first and "얼린글" not in first
|
||
# 구조물 장 = 얹은 도각 + 장 도번 · 표제란 = 프로젝트 값
|
||
assert "구조물그림" in texts[2] and "SD-000001" in texts[2]
|
||
assert "SD-000002-2" in texts[4] and "가나다임도개설공사" in texts[4]
|
||
|
||
|
||
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})
|
||
if response.status_code == 503:
|
||
pytest.skip(response.json()["message"])
|
||
pages = PdfReader(io.BytesIO(response.content)).pages
|
||
assert len(pages) == 2 and "구조물그림" in pages[0].extract_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
|
||
|
||
|
||
#: 1×1 PNG
|
||
PNG = (
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAE"
|
||
"hQGAhKmMIQAAAABJRU5ErkJggg=="
|
||
)
|
||
|
||
|
||
def test_그림_넣기_못_넣은_수():
|
||
|
||
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," + PNG),
|
||
image("/api/assets/없는그림.png"),
|
||
image("data:image/png;base64,@@깨진@@"),
|
||
],
|
||
"layers": [],
|
||
}
|
||
page, size, paper, skipped = drawing_page(drawing)
|
||
assert skipped == 2 and page.count("<image ") == 1
|
||
|
||
|
||
def test_점선_조각_빈칸_그대로():
|
||
"""점선 = 미리 자른 실선 조각(Chromium 이 윤곽으로 안 풂) · 무늬 = 꺾은선 처음부터 이어 셈."""
|
||
pieces = dash_pieces([(0, 0), (10, 0), (10, 10)], (6, 3))
|
||
assert pieces == [
|
||
[(0, 0), (6.0, 0.0)],
|
||
[(9.0, 0.0), (10, 0), (10.0, 5.0)],
|
||
[(10.0, 8.0), (10, 10)],
|
||
]
|
||
assert dash_pieces([(0, 0), (1, 0)], (0, 0)) == [[(0, 0), (1, 0)]] # 무늬 없음 = 실선
|
||
line = {"type": "Line", "layerId": "a", "lineDash": [6, 3]}
|
||
line["shapeData"] = {"startPoint": {"x": 0, "y": 0}, "endPoint": {"x": 100, "y": 0}}
|
||
text = _text("축 척", 50, 50, "a")
|
||
page, size, paper, _ = drawing_page({"entities": [line, text], "layers": []})
|
||
assert "stroke-dasharray" not in page and page.count("M") > 5
|
||
assert "축 척" in page and "white-space: pre" in page
|
||
|
||
|
||
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()
|