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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""인쇄 미리보기 도면(PLAN 302-2) — CAD 도면(openwebcad JSON) 한 장 → PDF 한 쪽(벡터).
|
||||
"""인쇄 미리보기 도면(PLAN 302-2 · 535-2) — CAD 도면(openwebcad JSON) 한 장 → SVG 한 쪽(벡터) →
|
||||
공용 PDF 엔진(`common_util_print_pdf` · Chromium)이 PDF 한 쪽으로.
|
||||
|
||||
쪽 = 도각 — 도각 층 범위를 그 양식 실치수(A1 840×594 · 회사 양식이면 그 크기) 종이에 맞춤 ·
|
||||
도각이 없으면 그림 범위 1:1. 숨긴 층 · 동결 층은 뺌(화면에 안 보이는 것 = 안 나감).
|
||||
@@ -10,20 +11,18 @@
|
||||
호 = 세계 좌표 반시계(counterClockwise 거짓이면 시계) · 점 = 반지름 5 원 ·
|
||||
그림 = 칸 안 비율 지켜 가운데
|
||||
표 = `B07 Frame_Export.table_parts`(openwebcad table-geometry 거울) 그대로
|
||||
한글 = 시스템 TTF(맑은 고딕 → Noto Sans KR → 나눔고딕) 심음 · 끝에 문서가 글꼴을 줄임(subset).
|
||||
한글 = 시스템 글꼴(맑은 고딕 → Noto Sans KR → 나눔고딕) · Chromium 이 쓴 글자만 심음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import html
|
||||
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
|
||||
@@ -41,23 +40,10 @@ STRUCTURE_FRAME_LAYER = "m02-structure-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",
|
||||
)
|
||||
FONTS = "'Malgun Gothic','맑은 고딕','Noto Sans KR','NanumGothic','Noto Sans CJK KR',sans-serif"
|
||||
_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)
|
||||
@@ -134,40 +120,72 @@ 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 위로) → PDF 그리기 명령(pt).
|
||||
"""세계 좌표(mm · y 위로) → SVG(pt · y 아래로).
|
||||
|
||||
같은 선 모양 = 한 묶음 · 글 = 색마다 TextWriter.
|
||||
|
||||
선은 명령 글을 목록에 모아 한 번에 붙임 —
|
||||
pymupdf Shape 를 개체마다 부르면 장 하나 수만 줄에 수십 초.
|
||||
같은 선 모양 = 한 path · 차례 = 그림 → 선 → 글(글이 선 위).
|
||||
점선 직선 = 미리 자른 실선 조각(ops) · 점선 곡선 · 채운 해치 = SVG 점선(dashed).
|
||||
"""
|
||||
|
||||
def __init__(self, page: pymupdf.Page, world: tuple[float, ...], font: pymupdf.Font):
|
||||
self.page = page
|
||||
self.height = page.rect.height
|
||||
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(page.rect.width / width, page.rect.height / height)
|
||||
self.k = min(self.width / width, self.height / height)
|
||||
# 도각 비율이 종이와 다르면 가운데
|
||||
self.ox = (page.rect.width - width * self.k) / 2
|
||||
self.oy = (page.rect.height - height * self.k) / 2
|
||||
self.font = font
|
||||
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.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 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:
|
||||
"""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)}"
|
||||
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
|
||||
@@ -177,64 +195,68 @@ class _Painter:
|
||||
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.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 = []
|
||||
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:
|
||||
self.ops.append(
|
||||
f"{self.q(*points[0])} m " + " ".join(f"{self.q(*xy)} l" for xy in points[1:]) + "\n"
|
||||
)
|
||||
_, _, 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.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"
|
||||
(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
|
||||
width = self.font.text_length(label, fontsize=size)
|
||||
align = options.get("textAlign") or "center"
|
||||
shift = {"left": 0.0, "right": width}.get(align, width / 2)
|
||||
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]))
|
||||
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)
|
||||
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) -> None:
|
||||
"""선 → 글 차례로 붙임(글이 선 위)."""
|
||||
def finish(self) -> str:
|
||||
"""SVG 몸통 — 그림 → 선 → 글."""
|
||||
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)))
|
||||
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 {}
|
||||
@@ -244,18 +266,18 @@ class _Painter:
|
||||
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):
|
||||
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"])
|
||||
@@ -326,13 +348,24 @@ class _Painter:
|
||||
self.entity(child)
|
||||
|
||||
|
||||
def add_drawing_page(doc: pymupdf.Document, drawing: dict[str, Any]) -> tuple[str, int]:
|
||||
"""도면 한 장을 쪽 하나로 덧붙임 → (종이 이름, 못 넣은 그림 수)."""
|
||||
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)
|
||||
page = doc.new_page(width=width_mm * PT_PER_MM, height=height_mm * PT_PER_MM)
|
||||
painter = _Painter(page, world, _font())
|
||||
width, height = width_mm * PT_PER_MM, height_mm * PT_PER_MM
|
||||
painter = _Painter(world, (width, height))
|
||||
for entity in entities:
|
||||
painter.entity(entity)
|
||||
painter.finish()
|
||||
return paper_name(width_mm, height_mm), painter.skipped
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user