- 길 = 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
338 lines
15 KiB
Python
338 lines
15 KiB
Python
"""인쇄 미리보기 도면(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
|