"""인쇄 미리보기 도면(PLAN 302-2 · 535-2) — CAD 도면(openwebcad JSON) 한 장 → SVG 한 쪽(벡터) →
공용 PDF 엔진(`common_util_print_pdf` · Chromium)이 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 거울) 그대로
한글 = 시스템 글꼴(맑은 고딕 → Noto Sans KR → 나눔고딕) · Chromium 이 쓴 글자만 심음.
"""
from __future__ import annotations
import base64
import binascii
import html
import math
import re
from typing import Any
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)}
FONTS = "'Malgun Gothic','맑은 고딕','Noto Sans KR','NanumGothic','Noto Sans CJK KR',sans-serif"
_AUTO_COLORS = {"#000000", "#ffffff", "#f5f7fa", "black", "white"}
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(".")
def _rgb(color: tuple[float, float, float]) -> str:
return "#" + "".join(f"{round(v * 255):02x}" for v in color)
def dash_pieces(
points: list[tuple[float, float]], pattern: tuple[float, ...]
) -> list[list[tuple[float, float]]]:
"""꺾은선 → 점선 「켜짐」 조각들(쪽 좌표 · 무늬는 꺾은선 처음부터 이어 셈).
Chromium PDF 는 점선을 조각마다 채운 윤곽으로 풀어 PDF 가 몇 배 커짐 —
미리 잘라 실선 조각으로 넘기면 선 명령 그대로(둥근 끝 = 점선 둥근 끝과 같은 모양).
"""
if len(pattern) % 2:
pattern += pattern
if sum(pattern) <= 0 or min(pattern) < 0:
return [points]
pieces: list[list[tuple[float, float]]] = []
index, left, on = 0, pattern[0], True
piece = [points[0]]
for (x0, y0), (x1, y1) in zip(points, points[1:]):
length, t = math.hypot(x1 - x0, y1 - y0), 0.0
while length - t > left:
t += left
point = (x0 + (x1 - x0) * t / length, y0 + (y1 - y0) * t / length)
if on:
pieces.append([*piece, point])
piece = [point]
on, index = not on, (index + 1) % len(pattern)
left = pattern[index]
left -= length - t
if on:
piece.append((x1, y1))
if on and len(piece) > 1:
pieces.append(piece)
return pieces
class _Painter:
"""세계 좌표(mm · y 위로) → SVG(pt · y 아래로).
같은 선 모양 = 한 path · 차례 = 그림 → 선 → 글(글이 선 위).
점선 직선 = 미리 자른 실선 조각(ops) · 점선 곡선 · 채운 해치 = SVG 점선(dashed).
"""
def __init__(self, world: tuple[float, ...], page: tuple[float, float]):
self.width, self.height = page
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(self.width / width, self.height / height)
# 도각 비율이 종이와 다르면 가운데
self.ox = (self.width - width * self.k) / 2
self.oy = (self.height - height * self.k) / 2
self.images: list[str] = []
self.parts: list[str] = []
self.texts: list[str] = []
self.ops: list[str] = []
self.dashed: list[str] = []
self.style: tuple | None = None
self.skipped = 0
def p(self, x: float, y: float) -> tuple[float, float]:
return (x - self.x0) * self.k + self.ox, (self.y1 - y) * self.k + self.oy
def q(self, x: float, y: float) -> str:
page_x, page_y = self.p(x, y)
return f"{_g(page_x)} {_g(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 path(self, ops: list[str], dash: bool) -> None:
"""지금 선 모양 path 한 줄 붙임."""
color, width, pattern, fill = self.style
attrs = f'stroke="{_rgb(color)}" stroke-width="{_g(width)}"'
if dash:
attrs += f' stroke-dasharray="{" ".join(map(_g, pattern))}"'
attrs += f' fill="{_rgb(fill)}"' if fill else ' fill="none"'
self.parts.append(f'