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:
2026-10-25 19:42:54 +09:00
co-authored by Claude Opus 5.5
parent 9add13ccbc
commit b8ef5d033d
4 changed files with 193 additions and 122 deletions
@@ -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
@@ -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 를 만들지 못했습니다.")
+1 -1
View File
@@ -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()
+49 -19
View File
@@ -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("<image ") == 1
def test_점선_조각_빈칸_그대로():
"""점선 = 미리 자른 실선 조각(Chromium 이 윤곽으로 안 풂) · 무늬 = 꺾은선 처음부터 이어 셈."""
pieces = dash_pieces([(0, 0), (10, 0), (10, 10)], (6, 3))
assert pieces == [
[(0, 0), (6.0, 0.0)],
[(9.0, 0.0), (10, 0), (10.0, 5.0)],
[(10.0, 8.0), (10, 10)],
]
assert dash_pieces([(0, 0), (1, 0)], (0, 0)) == [[(0, 0), (1, 0)]] # 무늬 없음 = 실선
line = {"type": "Line", "layerId": "a", "lineDash": [6, 3]}
line["shapeData"] = {"startPoint": {"x": 0, "y": 0}, "endPoint": {"x": 100, "y": 0}}
text = _text("축 척", 50, 50, "a")
page, size, paper, _ = drawing_page({"entities": [line, text], "layers": []})
assert "stroke-dasharray" not in page and page.count("M") > 5
assert "축 척" in page and "white-space: pre" in page
def test_미확정_횡단_구조물_Node_번들이_돎():