diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py index d2503a454..30cb95fa5 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py @@ -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'\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'' + f"{html.escape(label, quote=False)}\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) + + '\n' + + "".join(self.parts) + + f'\n\n' + + "".join(self.texts) + + "\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'\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'\n' + f"{painter.finish()}" + ) + page = ( + '{svg}" + ) + return page, (width_mm, height_mm), paper_name(width_mm, height_mm), painter.skipped diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py index e87803cb7..260835fa8 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py @@ -8,6 +8,7 @@ 로그인 · 회사 · 프로젝트 권한은 이 라우터가 스스로 검(B09 라우터가 include 하든 앱이 바로 붙이든). 저장 안 함 — 매번 만듦 · B07 정본 · 확정 상태 그대로. +PDF = 장마다 SVG 쪽 → 공용 PDF 엔진(Chromium · 브라우저 한 번) → pypdf 로 한 벌. """ from __future__ import annotations @@ -17,14 +18,14 @@ import json import logging from uuid import UUID -import pymupdf from fastapi import APIRouter, Depends, Response from fastapi.responses import JSONResponse from pydantic import BaseModel from B09_Estimation.draft import B09_Draft_Print_Drawings_Source as source -from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import add_drawing_page +from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import drawing_page from common_util.common_util_auth import require_company, require_project_access, verify_session +from common_util.common_util_print_pdf import PrintError, merge_pdfs, render_htmls_to_pdfs logger = logging.getLogger(__name__) router = APIRouter( @@ -56,15 +57,20 @@ async def get_print_drawings(project_id: UUID) -> JSONResponse: return JSONResponse(items) +#: PDF 만들기 시간 한도 — 기본 + 장마다(큰 도면 묶음) +TIMEOUT_BASE_S, TIMEOUT_PER_SHEET_S = 60, 10 + + def _pdf(drawings: list[dict], ids: list[str]) -> tuple[bytes, dict[str, int]]: - doc = pymupdf.open() + jobs: list[dict] = [] skipped: dict[str, int] = {} for drawing_id, drawing in zip(ids, drawings, strict=True): - _, missing = add_drawing_page(doc, drawing) + page, size, _, missing = drawing_page(drawing) + jobs.append({"html": page, "page_size": size}) if missing: skipped[drawing_id] = missing - doc.subset_fonts() # 한글 글꼴 = 쓴 글자만 - return doc.tobytes(garbage=3, deflate=True), skipped + timeout = TIMEOUT_BASE_S + TIMEOUT_PER_SHEET_S * len(jobs) + return merge_pdfs(render_htmls_to_pdfs(jobs, timeout)), skipped @router.post("/{project_id}/print/drawings-pdf") @@ -85,6 +91,8 @@ async def post_print_drawings_pdf( body, skipped = await asyncio.to_thread(_pdf, drawings, ids) except ValueError as exc: return _error(400, str(exc)) + except PrintError as exc: + return _error(503, str(exc)) except Exception: logger.exception("인쇄 도면 PDF 실패: %s", project_id) return _error(500, "도면 PDF 를 만들지 못했습니다.") diff --git a/common_util/common_util_print_pdf.py b/common_util/common_util_print_pdf.py index 071fbf76b..af885cb90 100644 --- a/common_util/common_util_print_pdf.py +++ b/common_util/common_util_print_pdf.py @@ -158,7 +158,7 @@ def merge_pdfs(parts: list[bytes]) -> bytes: writer = PdfWriter() for part in parts: writer.append(io.BytesIO(part)) - writer.compress_identical_objects(remove_identicals=True, remove_orphans=True) + writer.compress_identical_objects(remove_duplicates=True, remove_unreferenced=True) out = io.BytesIO() writer.write(out) return out.getvalue() diff --git a/resources/tester/test_302_2_print_drawings.py b/resources/tester/test_302_2_print_drawings.py index 27e722b59..2169aa975 100644 --- a/resources/tester/test_302_2_print_drawings.py +++ b/resources/tester/test_302_2_print_drawings.py @@ -1,18 +1,20 @@ -"""PLAN 302-2 인쇄 미리보기 도면 PDF — 목록 차례 · 쪽 수 · 쪽 크기 · 한글 · 숨긴 층 · 권한. +"""PLAN 302-2 · 535-2 인쇄 미리보기 도면 PDF — 목록 차례 · 쪽 수 · 쪽 크기 · 한글 · 숨긴 층 · 권한 · +점선 조각 · 그림 · 빈칸 그대로(PDF 읽기 = pypdf). 임시 폴더 견본만 씀(공용 storage 안 씀) — B07 목록 · 단건 · 표제란 값은 바꿔 끼움. """ from __future__ import annotations +import io import json from pathlib import Path from uuid import uuid4 -import pymupdf import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from pypdf import PdfReader from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( frame_entities, @@ -24,7 +26,8 @@ from B09_Estimation.draft import B09_Draft_Print_Drawings_Router as router_modul from B09_Estimation.draft import B09_Draft_Print_Drawings_Source as source from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import ( PT_PER_MM, - add_drawing_page, + dash_pieces, + drawing_page, paint_color, ) from common_util import common_util_auth as auth @@ -170,18 +173,21 @@ def test_PDF_쪽수_크기_한글_숨긴층(sample): assert response.headers["content-type"] == "application/pdf" assert response.headers["x-aislo-pages"] == "5" assert json.loads(response.headers["x-aislo-skipped-images"]) == {} - doc = pymupdf.open(stream=response.content, filetype="pdf") - assert doc.page_count == 5 - for page in doc: - assert abs(page.rect.width / PT_PER_MM - 840) < 0.5 - assert abs(page.rect.height / PT_PER_MM - 594) < 0.5 - first = doc[0].get_text() + if response.status_code == 503: + pytest.skip(response.json()["message"]) # 서버에 Edge · Chrome 없음 + pages = PdfReader(io.BytesIO(response.content)).pages + assert len(pages) == 5 + for page in pages: + assert abs(float(page.mediabox.width) / PT_PER_MM - 840) < 0.5 + assert abs(float(page.mediabox.height) / PT_PER_MM - 594) < 0.5 + texts = [page.extract_text() for page in pages] + first = texts[0] assert "가나다임도개설공사" in first and "라마바설계" in first # 표제란 한글 그대로 assert "보이는글" in first assert "숨은글" not in first and "얼린글" not in first # 구조물 장 = 얹은 도각 + 장 도번 · 표제란 = 프로젝트 값 - assert "구조물그림" in doc[2].get_text() and "SD-000001" in doc[2].get_text() - assert "SD-000002-2" in doc[4].get_text() and "가나다임도개설공사" in doc[4].get_text() + assert "구조물그림" in texts[2] and "SD-000001" in texts[2] + assert "SD-000002-2" in texts[4] and "가나다임도개설공사" in texts[4] def test_PDF_고른_차례_모르는_id(sample): @@ -189,8 +195,10 @@ def test_PDF_고른_차례_모르는_id(sample): client = _client(SESSION) picked = ["structure:SD-000001", "b07:cover"] response = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": picked}) - doc = pymupdf.open(stream=response.content, filetype="pdf") - assert doc.page_count == 2 and "구조물그림" in doc[0].get_text() + if response.status_code == 503: + pytest.skip(response.json()["message"]) + pages = PdfReader(io.BytesIO(response.content)).pages + assert len(pages) == 2 and "구조물그림" in pages[0].extract_text() bad = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": ["b07:없음"]}) assert bad.status_code == 400 @@ -244,9 +252,14 @@ def test_로그인_회사_권한(sample, monkeypatch): assert _client(SESSION, project_company=1).get(path).status_code == 200 +#: 1×1 PNG +PNG = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAE" + "hQGAhKmMIQAAAABJRU5ErkJggg==" +) + + def test_그림_넣기_못_넣은_수(): - png = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 4, 4), 0).tobytes("png") - import base64 def image(data: str) -> dict: points = [{"x": 10, "y": 10}, {"x": 50, "y": 10}, {"x": 50, "y": 50}, {"x": 10, "y": 50}] @@ -254,14 +267,31 @@ def test_그림_넣기_못_넣은_수(): drawing = { "entities": [ - image("data:image/png;base64," + base64.b64encode(png).decode()), + image("data:image/png;base64," + PNG), image("/api/assets/없는그림.png"), + image("data:image/png;base64,@@깨진@@"), ], "layers": [], } - doc = pymupdf.open() - paper, skipped = add_drawing_page(doc, drawing) - assert skipped == 1 and len(doc[0].get_images()) == 1 + page, size, paper, skipped = drawing_page(drawing) + assert skipped == 2 and page.count(" 5 + assert "축 척" in page and "white-space: pre" in page def test_미확정_횡단_구조물_Node_번들이_돎():