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:
2026-10-13 08:01:47 +09:00
co-authored by Claude Opus 5.5
parent 8230788e96
commit f4889352f9
7 changed files with 1044 additions and 0 deletions
@@ -0,0 +1,59 @@
/* =============================================================================
* B09_Draft_Print_Drawings.ts
* 인쇄 미리보기 도면 영역(PLAN 302-2) — 서버가 만든 목록 · PDF 를 그대로 받음(보는 것 = 받는 것).
* 차례 = B07 도면번호 → 구조물 조합 도면(도번마다 한 번) · 서버 `B09_Draft_Print_Drawings_Router.py`
* PDF 머리 X-Aislo-Skipped-Images = 못 넣은 그림 {id: 수} → `skippedImagesOf(blob)`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { DrawingItem, FetchDrawingList, FetchDrawingsPdf } from "./B09_Draft_Print_Types";
/** 도면 한 장 — 약속 꼴(id · name = label) + 서버가 주는 칸 */
export interface PrintDrawingItem extends DrawingItem {
source: "b07" | "structure";
kind: string;
label: string;
number: string;
confirmed: boolean;
paper: string | null;
/** PDF 를 만들어야 셈 — 목록에서는 null */
images_skipped: number | null;
}
async function failure(response: Response): Promise<Error> {
const body = (await response.json().catch(() => null)) as { message?: string } | null;
return new Error(body?.message ?? `HTTP ${response.status}`);
}
export const fetchDrawingList: FetchDrawingList = async (projectId) => {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/print/drawings`, {
credentials: "include",
});
if (!response.ok) throw await failure(response);
const items = (await response.json()) as Omit<PrintDrawingItem, "name">[];
return items.map((item): PrintDrawingItem => ({ ...item, name: item.label }));
};
const skipped = new WeakMap<Blob, Record<string, number>>();
export const fetchDrawingsPdf: FetchDrawingsPdf = async (projectId, ids) => {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/print/drawings-pdf`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(ids ? { ids } : {}),
});
if (!response.ok) throw await failure(response);
const blob = await response.blob();
try {
skipped.set(blob, JSON.parse(response.headers.get("X-Aislo-Skipped-Images") ?? "{}"));
} catch {
/* 머리가 깨졌으면 빠진 그림 없음으로 */
}
return blob;
};
/** 그 PDF 에서 못 넣은 그림 {도면 id: 수} — 없으면 빈 꼴 */
export function skippedImagesOf(blob: Blob): Record<string, number> {
return skipped.get(blob) ?? {};
}
@@ -0,0 +1,34 @@
/* =============================================================================
* B09_Draft_Print_Drawings_Node.ts
* 인쇄 미리보기 도면(PLAN 302-2) — 미확정 횡단 장에 구조물을 얹는 Node 진입점(CLAUDE.md 5장 ②).
*
* B07 화면이 미확정 장을 열 때 얹는 것과 **같은 코드**(`placeStructureEntities`)를 서버에서 돌린다.
* 입력 상세 = 정본(`get_section_detail`) — 화면 캐시의 저장 안 한 편집은 안 실림(인쇄 = 저장본).
*
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { detail: 종횡단 상세, drawings: { [도면 id]: CAD 도면 } }
* 출력 { drawings: { [도면 id]: 구조물을 얹은 CAD 도면 } }
* 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */
import { readFileSync, writeFileSync } from "node:fs";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import "../../B07_DesignDetail/B07_DesignDetail_Node_Dom"; // 최소 DOM — 그리기 코드보다 먼저
import { placeStructureEntities } from "../../B07_DesignDetail/B07_DesignDetail_Cross_Overlay";
type Drawing = Parameters<typeof placeStructureEntities>[1];
const [inputPath, outputPath] = process.argv.slice(2);
if (!inputPath || !outputPath) {
console.error("사용법: node <번들> <입력.json> <출력.json>");
process.exit(2);
}
const input = JSON.parse(readFileSync(inputPath, "utf8")) as {
detail?: SectionDetailResponse;
drawings?: Record<string, Drawing>;
};
const sections = input.detail?.cross_sections ?? [];
const drawings = input.drawings ?? {};
for (const drawing of Object.values(drawings)) placeStructureEntities(sections, drawing);
writeFileSync(outputPath, JSON.stringify({ drawings }), "utf8");
@@ -0,0 +1,337 @@
"""인쇄 미리보기 도면(PLAN 302-2) — CAD 도면(openwebcad JSON) 한 장 → PDF 한 쪽(벡터).
쪽 = 도각 — 도각 층 범위를 그 양식 실치수(A1 840×594 · 회사 양식이면 그 크기) 종이에 맞춤 ·
도각이 없으면 그림 범위 1:1. 숨긴 층 · 동결 층은 뺌(화면에 안 보이는 것 = 안 나감).
그리기 규칙 = openwebcad 화면(밝은 바탕)과 같게:
색 = `theme.ts` `paintColor` 밝은 바탕 짝(자동색 → 검정 · 휘도 0.58 넘는 색 → 0.3 으로 누름)
글 = 기준점 세로 가운데(textBaseline middle) · 정렬 left/center/right(기본 center) ·
방향 벡터 회전
호 = 세계 좌표 반시계(counterClockwise 거짓이면 시계) · 점 = 반지름 5 원 ·
그림 = 칸 안 비율 지켜 가운데
표 = `B07 Frame_Export.table_parts`(openwebcad table-geometry 거울) 그대로
한글 = 시스템 TTF(맑은 고딕 → Noto Sans KR → 나눔고딕) 심음 · 끝에 문서가 글꼴을 줄임(subset).
"""
from __future__ import annotations
import base64
import math
import re
from functools import lru_cache
from pathlib import Path
from typing import Any
import pymupdf
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import table_parts
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, template_entities
PT_PER_MM = 72 / 25.4
#: 화면 선 굵기(px) → 종이(pt) — ponytail: 고정 배율, 출력물 보고 고칠 손잡이
LINE_PT_PER_PX = 0.5
DASH_PT_PER_PX = 1.0
POINT_RADIUS = 5.0
DEFAULT_FONT_MM = 3.0
#: 구조물 조합 도면에 얹는 도각 층(M02 `STRUCTURE_FRAME_LAYER`)
STRUCTURE_FRAME_LAYER = "m02-structure-frame"
#: 쪽을 잡는 도각 층 — 앞이 먼저
#: (구조물 도면은 불러온 DXF 도각 `b08-frame` 이 그림 안에 있어도 얹은 도각)
FRAME_LAYERS = (STRUCTURE_FRAME_LAYER, FRAME_LAYER_ID)
PAPERS = {"A1": (840.0, 594.0), "A3": (420.0, 297.0)}
FONT_FILES = (
"C:/Windows/Fonts/malgun.ttf",
"C:/Windows/Fonts/NotoSansKR-VF.ttf",
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
)
_AUTO_COLORS = {"#000000", "#ffffff", "#f5f7fa", "black", "white"}
@lru_cache(maxsize=1)
def _font() -> pymupdf.Font:
for path in FONT_FILES:
if Path(path).is_file():
return pymupdf.Font(fontfile=path)
raise RuntimeError("한글 글꼴(맑은 고딕 · Noto Sans KR · 나눔고딕)을 찾지 못했습니다.")
def _hex(color: str) -> str:
value = color.strip().lower()
short = re.fullmatch(r"#([0-9a-f])([0-9a-f])([0-9a-f])", value)
return "#" + "".join(c * 2 for c in short.groups()) if short else value
def paint_color(color: Any) -> tuple[float, float, float]:
"""`theme.ts` `paintColor`(밝은 바탕) 짝 — 거울 시험 `test_302_2_print_drawings`."""
value = _hex(str(color or "#ffffff"))
if value in _AUTO_COLORS or not re.fullmatch(r"#[0-9a-f]{6}", value):
return (0.0, 0.0, 0.0)
r, g, b = (int(value[i : i + 2], 16) for i in (1, 3, 5))
luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
if luminance > 0.58:
scale = 0.3 / luminance
r, g, b = (round(min(255, v * scale)) for v in (r, g, b))
return (r / 255, g / 255, b / 255)
def _xy(point: Any) -> tuple[float, float] | None:
if isinstance(point, dict) and isinstance(point.get("x"), (int, float)):
return float(point["x"]), float(point["y"])
return None
def hidden_layers(drawing: dict[str, Any]) -> set[str]:
return {
str(layer.get("id"))
for layer in drawing.get("layers") or []
if isinstance(layer, dict) and (layer.get("isVisible") is False or layer.get("isFrozen"))
}
def visible_entities(drawing: dict[str, Any]) -> list[dict[str, Any]]:
"""숨긴 · 동결 층 개체를 (자식까지) 뺀 목록."""
hidden = hidden_layers(drawing)
def keep(entity: Any) -> dict[str, Any] | None:
if not isinstance(entity, dict) or entity.get("layerId") in hidden:
return None
children = entity.get("children")
if not children:
return entity
return {**entity, "children": [c for c in map(keep, children) if c]}
return [e for e in map(keep, drawing.get("entities") or []) if e]
def paper_name(width: float, height: float) -> str:
for name, (w, h) in PAPERS.items():
if abs(width - w) < 5 and abs(height - h) < 5:
return name
return f"{width:.0f}×{height:.0f}mm"
def page_frame(entities: list[dict[str, Any]]) -> tuple[tuple[float, ...], tuple[float, float]]:
"""(세계 범위 x0, y0, x1, y1 · 종이 mm (가로, 세로)) — 도각 층 범위 → 양식 실치수 종이."""
for layer in FRAME_LAYERS:
frame = entities_bbox([e for e in entities if e.get("layerId") == layer])
if not frame:
continue
source = entities_bbox(template_entities())
if source:
width, height = source[2] - source[0], source[3] - source[1]
return frame, (width, height)
box = entities_bbox(entities) or (0.0, 0.0, 840.0, 594.0)
return box, (max(box[2] - box[0], 1.0), max(box[3] - box[1], 1.0))
_KAPPA = 0.5523 # 원 = 베지어 넷
def _g(value: float) -> str:
return f"{value:.3f}".rstrip("0").rstrip(".")
class _Painter:
"""세계 좌표(mm · y 위로) → PDF 그리기 명령(pt).
같은 선 모양 = 한 묶음 · 글 = 색마다 TextWriter.
선은 명령 글을 목록에 모아 한 번에 붙임 —
pymupdf Shape 를 개체마다 부르면 장 하나 수만 줄에 수십 초.
"""
def __init__(self, page: pymupdf.Page, world: tuple[float, ...], font: pymupdf.Font):
self.page = page
self.height = page.rect.height
self.x0, self.y1 = world[0], world[3]
width, height = max(world[2] - world[0], 1e-9), max(world[3] - world[1], 1e-9)
self.k = min(page.rect.width / width, page.rect.height / height)
# 도각 비율이 종이와 다르면 가운데
self.ox = (page.rect.width - width * self.k) / 2
self.oy = (page.rect.height - height * self.k) / 2
self.font = font
self.parts: list[str] = []
self.ops: list[str] = []
self.style: tuple | None = None
self.writers: dict[tuple, pymupdf.TextWriter] = {}
self.turned: list[tuple[pymupdf.TextWriter, pymupdf.Point, float]] = []
self.skipped = 0
def p(self, x: float, y: float) -> pymupdf.Point:
"""쪽 좌표(y 아래로) — 글 · 그림 자리."""
return pymupdf.Point((x - self.x0) * self.k + self.ox, (self.y1 - y) * self.k + self.oy)
def q(self, x: float, y: float) -> str:
"""PDF 좌표(y 위로) 글 — 선 명령."""
page_y = (self.y1 - y) * self.k + self.oy
return f"{_g((x - self.x0) * self.k + self.ox)} {_g(self.height - page_y)}"
def stroke(self, entity: dict[str, Any], fill: tuple | None = None) -> None:
width = max(float(entity.get("lineWidth") or 1), 0.1) * LINE_PT_PER_PX
dash = tuple(float(d) * DASH_PT_PER_PX for d in entity.get("lineDash") or [])
style = (paint_color(entity.get("lineColor")), width, dash, fill)
if style != self.style:
self.flush()
self.style = style
def flush(self) -> None:
if self.style is None or not self.ops:
return
(r, g, b), width, dash, fill = self.style
head = f"q 1 J 1 j {_g(r)} {_g(g)} {_g(b)} RG {_g(width)} w [{' '.join(map(_g, dash))}] 0 d"
if fill:
head += f" {_g(fill[0])} {_g(fill[1])} {_g(fill[2])} rg"
self.parts.append(f"{head}\n{''.join(self.ops)}{'B' if fill else 'S'} Q\n")
self.ops = []
def line(self, points: list[tuple[float, float]]) -> None:
self.ops.append(
f"{self.q(*points[0])} m " + " ".join(f"{self.q(*xy)} l" for xy in points[1:]) + "\n"
)
def circle(self, center: tuple[float, float], radius: float) -> None:
cx, cy = center
c = radius * _KAPPA
q = self.q
self.ops.append(
f"{q(cx + radius, cy)} m "
f"{q(cx + radius, cy + c)} {q(cx + c, cy + radius)} {q(cx, cy + radius)} c "
f"{q(cx - c, cy + radius)} {q(cx - radius, cy + c)} {q(cx - radius, cy)} c "
f"{q(cx - radius, cy - c)} {q(cx - c, cy - radius)} {q(cx, cy - radius)} c "
f"{q(cx + c, cy - radius)} {q(cx + radius, cy - c)} {q(cx + radius, cy)} c\n"
)
def text(self, label: str, base: tuple[float, float], options: dict[str, Any]) -> None:
if not label.strip():
return
size = float(options.get("fontSize") or DEFAULT_FONT_MM) * self.k
width = self.font.text_length(label, fontsize=size)
align = options.get("textAlign") or "center"
shift = {"left": 0.0, "right": width}.get(align, width / 2)
direction = _xy(options.get("textDirection")) or (1.0, 0.0)
angle = math.degrees(math.atan2(direction[1], direction[0]))
anchor = self.p(*base)
color = paint_color(options.get("textColor"))
# 기준점 = 세로 가운데 → 글자 바닥선은 (올림 + 내림) / 2 만큼 아래
drop = (self.font.ascender + self.font.descender) / 2 * size
if abs(angle) > 1e-6:
writer = pymupdf.TextWriter(self.page.rect, color=color)
self.turned.append((writer, anchor, angle))
else:
writer = self.writers.setdefault(color, pymupdf.TextWriter(self.page.rect, color=color))
writer.append(anchor + (-shift, drop), label, font=self.font, fontsize=size)
def finish(self) -> None:
"""선 → 글 차례로 붙임(글이 선 위)."""
self.flush()
if self.parts:
shape = self.page.new_shape()
shape.totalcont = "".join(self.parts)
shape.commit()
for writer in self.writers.values():
writer.write_text(self.page)
for writer, anchor, angle in self.turned:
writer.write_text(self.page, morph=(anchor, pymupdf.Matrix(-angle)))
def image(self, entity: dict[str, Any]) -> None:
shape = entity.get("shapeData") or {}
points = [xy for v in shape.get("points") or [] if (xy := _xy(v))]
data = str(shape.get("imageData") or "")
found = re.match(r"data:([\w/+.-]+);base64,(.*)", data, re.S)
if len(points) < 2 or not found:
self.skipped += 1
return
xs, ys = [x for x, _ in points], [y for _, y in points]
rect = pymupdf.Rect(self.p(min(xs), max(ys)), self.p(max(xs), min(ys)))
blob = base64.b64decode(found.group(2))
try:
if "svg" in found.group(1):
with pymupdf.open(stream=blob, filetype="svg") as svg:
with pymupdf.open("pdf", svg.convert_to_pdf()) as pdf:
self.page.show_pdf_page(rect, pdf, 0, keep_proportion=True)
else:
self.page.insert_image(rect, stream=blob, keep_proportion=True)
except (RuntimeError, ValueError):
self.skipped += 1
def arc(self, center: tuple[float, float], radius: float, shape: dict[str, Any]) -> None:
start, end = float(shape["startAngle"]), float(shape["endAngle"])
if shape.get("counterClockwise") is False:
start, end = end, start
sweep = (end - start) % (2 * math.pi) or 2 * math.pi
steps = max(8, int(sweep / (math.pi / 36)))
self.line(
[
(
center[0] + radius * math.cos(start + sweep * i / steps),
center[1] + radius * math.sin(start + sweep * i / steps),
)
for i in range(steps + 1)
]
)
def entity(self, entity: dict[str, Any]) -> None:
shape = entity.get("shapeData") or {}
kind = entity.get("type")
if kind == "Image":
self.image(entity)
return
if kind == "Table":
self.stroke(entity)
lines, texts = table_parts(shape)
for x1, y1, x2, y2 in lines:
self.line([(x1, y1), (x2, y2)])
color = (shape.get("style") or {}).get("textColor")
for one in texts: # ponytail: 칸 글색 · 굵게는 표 글색 하나로
options = {"fontSize": one["height"], "textAlign": one["align"], "textColor": color}
self.text(one["text"], (one["x"], one["y"]), options)
return
if kind == "Hatch":
ring = [xy for v in shape.get("points") or [] if (xy := _xy(v))]
if len(ring) >= 3:
options = shape.get("options") or {}
# ponytail: 무늬(선 · 격자) 해치는 색 테두리만 — 칸 채움 무늬가 필요하면 clip 경로로
fill = (
paint_color(options.get("color")) if options.get("style") == "solid" else None
)
self.stroke(entity, fill)
self.line([*ring, ring[0]])
return
start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint"))
if start and end:
self.stroke(entity)
self.line([start, end])
elif (base := _xy(shape.get("basePoint"))) and isinstance(shape.get("label"), str):
self.text(shape["label"], base, shape.get("options") or {})
elif point := _xy(shape.get("point")):
self.stroke(entity)
self.circle(point, POINT_RADIUS)
elif (center := _xy(shape.get("center"))) and isinstance(shape.get("radius"), (int, float)):
self.stroke(entity)
angles = (shape.get("startAngle"), shape.get("endAngle"))
if all(isinstance(a, (int, float)) for a in angles):
self.arc(center, float(shape["radius"]), shape)
else:
self.circle(center, float(shape["radius"]))
else:
vertices = [xy for v in shape.get("points") or [] if (xy := _xy(v))]
if len(vertices) >= 2:
self.stroke(entity)
self.line([*vertices, vertices[0]])
for child in entity.get("children") or []:
self.entity(child)
def add_drawing_page(doc: pymupdf.Document, drawing: dict[str, Any]) -> tuple[str, int]:
"""도면 한 장을 쪽 하나로 덧붙임 → (종이 이름, 못 넣은 그림 수)."""
entities = visible_entities(drawing)
world, (width_mm, height_mm) = page_frame(entities)
page = doc.new_page(width=width_mm * PT_PER_MM, height=height_mm * PT_PER_MM)
painter = _Painter(page, world, _font())
for entity in entities:
painter.entity(entity)
painter.finish()
return paper_name(width_mm, height_mm), painter.skipped
@@ -0,0 +1,99 @@
"""인쇄 미리보기 도면(PLAN 302-2) — 목록 · PDF 길.
GET /api/projects/{id}/print/drawings
→ [{id, source, kind, label, number, confirmed, paper, images_skipped}]
POST /api/projects/{id}/print/drawings-pdf {ids?} → application/pdf
목록 차례 · 장마다 한 쪽 · 머리 X-Aislo-Pages ·
X-Aislo-Skipped-Images(못 넣은 그림 {id: 수} JSON)
로그인 · 회사 · 프로젝트 권한은 이 라우터가 스스로 검(B09 라우터가 include 하든 앱이 바로 붙이든).
저장 안 함 — 매번 만듦 · B07 정본 · 확정 상태 그대로.
"""
from __future__ import annotations
import asyncio
import json
import logging
from uuid import UUID
import pymupdf
from fastapi import APIRouter, Depends, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B09_Estimation.draft import B09_Draft_Print_Drawings_Source as source
from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import add_drawing_page
from common_util.common_util_auth import require_company, require_project_access, verify_session
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/projects",
tags=["B09 Print Drawings (draft)"],
dependencies=[
Depends(verify_session),
Depends(require_company),
Depends(require_project_access),
],
)
class DrawingsPdfRequest(BaseModel):
ids: list[str] | None = None
def _error(status: int, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"status": "error", "message": message})
@router.get("/{project_id}/print/drawings")
async def get_print_drawings(project_id: UUID) -> JSONResponse:
try:
items, _ = await source.drawing_items(project_id)
except Exception:
logger.exception("인쇄 도면 목록 실패: %s", project_id)
return _error(500, "도면 목록을 읽지 못했습니다.")
return JSONResponse(items)
def _pdf(drawings: list[dict], ids: list[str]) -> tuple[bytes, dict[str, int]]:
doc = pymupdf.open()
skipped: dict[str, int] = {}
for drawing_id, drawing in zip(ids, drawings, strict=True):
_, missing = add_drawing_page(doc, drawing)
if missing:
skipped[drawing_id] = missing
doc.subset_fonts() # 한글 글꼴 = 쓴 글자만
return doc.tobytes(garbage=3, deflate=True), skipped
@router.post("/{project_id}/print/drawings-pdf")
async def post_print_drawings_pdf(
project_id: UUID, request: DrawingsPdfRequest | None = None
) -> Response:
try:
items, context = await source.drawing_items(project_id)
by_id = {item["id"]: item for item in items}
ids = request.ids if request and request.ids is not None else list(by_id)
unknown = [one for one in ids if one not in by_id]
if unknown:
return _error(400, f"목록에 없는 도면입니다: {', '.join(unknown)}")
if not ids:
return _error(404, "인쇄할 도면이 없습니다.")
chosen = [by_id[one] for one in ids]
drawings = await source.drawings_in_order(project_id, chosen, context)
body, skipped = await asyncio.to_thread(_pdf, drawings, ids)
except ValueError as exc:
return _error(400, str(exc))
except Exception:
logger.exception("인쇄 도면 PDF 실패: %s", project_id)
return _error(500, "도면 PDF 를 만들지 못했습니다.")
return Response(
body,
media_type="application/pdf",
headers={
"Content-Disposition": 'inline; filename="drawings.pdf"',
"X-Aislo-Pages": str(len(ids)),
"X-Aislo-Skipped-Images": json.dumps(skipped),
},
)
@@ -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
+2
View File
@@ -12,6 +12,7 @@ et_xmlfile==2.0.0
fastapi==0.104.1
bcrypt>=4.1,<5
email-validator>=2.1,<3
ezdxf==1.4.4
fiona==1.10.1
geopandas==1.1.4
h11==0.16.0
@@ -32,6 +33,7 @@ playwright==1.62.0
psutil>=6.1,<7
pydantic==2.5.0
pydantic_core==2.14.1
PyMuPDF==1.28.2
PyMySQL==1.2.0
pyparsing==3.3.2
pyproj==3.7.2
@@ -0,0 +1,273 @@
"""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()