"""인쇄 미리보기 도면(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'\n') def flush(self) -> None: if self.ops: self.path(self.ops, False) if self.dashed: self.path(self.dashed, True) self.ops, self.dashed = [], [] def line(self, points: list[tuple[float, float]]) -> None: _, _, dash, fill = self.style page = [self.p(*xy) for xy in points] pieces = dash_pieces(page, dash) if dash and not fill else [page] ops = self.dashed if dash and fill else self.ops for piece in pieces: ops.append("M" + "L".join(f"{_g(x)} {_g(y)}" for x, y in piece)) def circle(self, center: tuple[float, float], radius: float) -> None: cx, cy = center c = radius * _KAPPA q = self.q (self.dashed if self.style[2] else self.ops).append( f"M{q(cx + radius, cy)}" f"C{q(cx + radius, cy + c)} {q(cx + c, cy + radius)} {q(cx, cy + radius)}" f"C{q(cx - c, cy + radius)} {q(cx - radius, cy + c)} {q(cx - radius, cy)}" f"C{q(cx - radius, cy - c)} {q(cx - c, cy - radius)} {q(cx, cy - radius)}" f"C{q(cx + c, cy - radius)} {q(cx + radius, cy - c)} {q(cx + radius, cy)}" ) 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 anchor = {"left": "start", "right": "end"}.get(options.get("textAlign"), "middle") direction = _xy(options.get("textDirection")) or (1.0, 0.0) angle = math.degrees(math.atan2(direction[1], direction[0])) x, y = self.p(*base) turn = f' transform="rotate({_g(-angle)} {_g(x)} {_g(y)})"' if abs(angle) > 1e-6 else "" self.texts.append( f'' f"{html.escape(label, quote=False)}\n" ) def finish(self) -> str: """SVG 몸통 — 그림 → 선 → 글.""" self.flush() return ( "".join(self.images) + '\n' + "".join(self.parts) + f'\n\n' + "".join(self.texts) + "\n" ) 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 try: base64.b64decode(found.group(2), validate=True) except (binascii.Error, ValueError): self.skipped += 1 return xs, ys = [x for x, _ in points], [y for _, y in points] x0, y0 = self.p(min(xs), max(ys)) x1, y1 = self.p(max(xs), min(ys)) self.images.append( f'\n' ) 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) # 꼭짓점 목록 폴리선(B07 유역도 배경선) = 열린 선 self.line(vertices if kind == "PolyLine" else [*vertices, vertices[0]]) for child in entity.get("children") or []: self.entity(child) def drawing_page(drawing: dict[str, Any]) -> tuple[str, tuple[float, float], str, int]: """도면 한 장 → (쪽 HTML(SVG 한 장) · 종이 mm (가로, 세로) · 종이 이름 · 못 넣은 그림 수).""" entities = visible_entities(drawing) world, (width_mm, height_mm) = page_frame(entities) width, height = width_mm * PT_PER_MM, height_mm * PT_PER_MM painter = _Painter(world, (width, height)) for entity in entities: painter.entity(entity) svg = ( f'\n' f"{painter.finish()}" ) page = ( '{svg}" ) return page, (width_mm, height_mm), paper_name(width_mm, height_mm), painter.skipped