Files
Aislo/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py
T
eomsangdonandClaude Opus 5.5 f4889352f9 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
2026-10-13 08:01:47 +09:00

338 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""인쇄 미리보기 도면(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