Files
Aislo/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py
T
eomsangdonandClaude Opus 5.5 b8ef5d033d feat(535-2): B09 도면 PDF PyMuPDF(AGPL) 제거 — 장마다 SVG 쪽 → 공용 PDF 엔진(Chromium) · pypdf 합치기
- 점선 = 미리 자른 실선 조각(Chromium 이 점선을 채운 윤곽으로 풀어 PDF 3 배 커지는 것 막음)
- 글 안 빈칸 그대로(white-space: pre) · 세운 글 방향 = 화면(openwebcad canvas)과 같게
- 못 넣은 그림 = data: 아님 · base64 깨짐
- test_302_2: PDF 읽기 pymupdf → pypdf · add_drawing_page(pymupdf 문서) 시험 → drawing_page(SVG) 로 고침 · 점선 조각 · 빈칸 시험 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:42:54 +09:00

372 lines
16 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 · 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'<path {attrs} d="{"".join(ops)}"/>\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'<text x="{_g(x)}" y="{_g(y)}" font-size="{_g(size)}" text-anchor="{anchor}" '
f'fill="{_rgb(paint_color(options.get("textColor")))}"{turn}>'
f"{html.escape(label, quote=False)}</text>\n"
)
def finish(self) -> str:
"""SVG 몸통 — 그림 → 선 → 글."""
self.flush()
return (
"".join(self.images)
+ '<g stroke-linecap="round" stroke-linejoin="round">\n'
+ "".join(self.parts)
+ f'</g>\n<g font-family="{FONTS}" dominant-baseline="central">\n'
+ "".join(self.texts)
+ "</g>\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'<image x="{_g(x0)}" y="{_g(y0)}" width="{_g(x1 - x0)}" height="{_g(y1 - y0)}" '
f'preserveAspectRatio="xMidYMid meet" href="{html.escape(data)}"/>\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'<svg xmlns="http://www.w3.org/2000/svg" width="{_g(width_mm)}mm" '
f'height="{_g(height_mm)}mm" viewBox="0 0 {_g(width)} {_g(height)}">\n'
f"{painter.finish()}</svg>"
)
page = (
'<!doctype html><html><head><meta charset="utf-8"><style>'
f"@page {{ size: {_g(width_mm)}mm {_g(height_mm)}mm; margin: 0; }}"
"html, body { margin: 0; overflow: hidden; } svg { display: block; }"
"svg text { white-space: pre; }" # 글 안 빈칸 그대로(표제란 「축 척」)
f"</style></head><body>{svg}</body></html>"
)
return page, (width_mm, height_mm), paper_name(width_mm, height_mm), painter.skipped