Files
Aislo/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py
T
eomsangdonandClaude Opus 5.5 c316dbb106 feat(514-3 B07 유역도 등고선 가볍게): 유역도 등고선 · 계류 = 폴리선 하나 + 꼭짓점 목록(자식 Line · 선분마다 UUID 없음) — 요구 328-4
- 엔진: Engine_Cad_Basin 배경선만 꼭짓점 목록 폴리선(id 는 옛 폴리선과 같음) · 노선 · 유역 · 표 · 도각은 옛 꼴 그대로 · B05 · B06 길 손대지 않음
- CAD 앱: PolyLineEntity.fromJson 이 자식 없고 꼭짓점 목록이면 선분으로 펼침(색 · 굵기 · 층 = 부모) · 다시 내보내면 옛 꼴
- DXF 내보내기 · B09 도면 인쇄 PDF: 꼭짓점 목록 폴리선 = 열린 선(해치만 닫음)
- 검증_sub2 유역도 5 m 23.17 → 3.22 MB · 10 m 11.91 → 1.77 MB · 좌표 · id · 색 해시 전후 같음 · B05 · B06 등고선 · 배수 응답 해시 같음
- 시험: test_514_3_b07_basin_light 새로 · vitest cad/polyline-points-514-3 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:10:01 +09:00

339 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)
# 꼭짓점 목록 폴리선(B07 유역도 배경선) = 열린 선
self.line(vertices if kind == "PolyLine" else [*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