feat(b09-print): 인쇄 미리보기 도면 PDF — B07 도면 + 구조물 조합 도면 · 도각 크기 쪽 · 숨긴 층 뺌 · 벡터 · 한글 (302-2)
- 길 = GET /api/projects/{id}/print/drawings · POST /api/projects/{id}/print/drawings-pdf {ids?} (draft 라우터 · 로그인 · 회사 · 프로젝트 권한 스스로)
- 차례 = B07 도면번호 → 구조물 조합 도면(도번 차례 · 도번마다 한 번 · 더한장)
- B07 = B07 단건 조회 그대로(확정본 · 미확정 조립) · 미확정 횡단 장 구조물 = 화면 TS 를 서버 Node 로(번들 tmp · package.json 안 건드림)
- 쪽 = 도각 양식 실치수(A1 840×594) · 색 = openwebcad 밝은 바탕 규칙 · 글 = 맑은 고딕 심음(subset) · 그림 = data URL 넣음 · 못 넣은 수 = 응답 머리
- 화면 쪽 = fetchDrawingList · fetchDrawingsPdf(약속 꼴) · skippedImagesOf
- requirements = PyMuPDF · ezdxf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/* =============================================================================
|
||||
* B09_Draft_Print_Drawings.ts
|
||||
* 인쇄 미리보기 도면 영역(PLAN 302-2) — 서버가 만든 목록 · PDF 를 그대로 받음(보는 것 = 받는 것).
|
||||
* 차례 = B07 도면번호 → 구조물 조합 도면(도번마다 한 번) · 서버 `B09_Draft_Print_Drawings_Router.py`
|
||||
* PDF 머리 X-Aislo-Skipped-Images = 못 넣은 그림 {id: 수} → `skippedImagesOf(blob)`
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { DrawingItem, FetchDrawingList, FetchDrawingsPdf } from "./B09_Draft_Print_Types";
|
||||
|
||||
/** 도면 한 장 — 약속 꼴(id · name = label) + 서버가 주는 칸 */
|
||||
export interface PrintDrawingItem extends DrawingItem {
|
||||
source: "b07" | "structure";
|
||||
kind: string;
|
||||
label: string;
|
||||
number: string;
|
||||
confirmed: boolean;
|
||||
paper: string | null;
|
||||
/** PDF 를 만들어야 셈 — 목록에서는 null */
|
||||
images_skipped: number | null;
|
||||
}
|
||||
|
||||
async function failure(response: Response): Promise<Error> {
|
||||
const body = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
return new Error(body?.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
export const fetchDrawingList: FetchDrawingList = async (projectId) => {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/print/drawings`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) throw await failure(response);
|
||||
const items = (await response.json()) as Omit<PrintDrawingItem, "name">[];
|
||||
return items.map((item): PrintDrawingItem => ({ ...item, name: item.label }));
|
||||
};
|
||||
|
||||
const skipped = new WeakMap<Blob, Record<string, number>>();
|
||||
|
||||
export const fetchDrawingsPdf: FetchDrawingsPdf = async (projectId, ids) => {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/print/drawings-pdf`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(ids ? { ids } : {}),
|
||||
});
|
||||
if (!response.ok) throw await failure(response);
|
||||
const blob = await response.blob();
|
||||
try {
|
||||
skipped.set(blob, JSON.parse(response.headers.get("X-Aislo-Skipped-Images") ?? "{}"));
|
||||
} catch {
|
||||
/* 머리가 깨졌으면 빠진 그림 없음으로 */
|
||||
}
|
||||
return blob;
|
||||
};
|
||||
|
||||
/** 그 PDF 에서 못 넣은 그림 {도면 id: 수} — 없으면 빈 꼴 */
|
||||
export function skippedImagesOf(blob: Blob): Record<string, number> {
|
||||
return skipped.get(blob) ?? {};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/* =============================================================================
|
||||
* B09_Draft_Print_Drawings_Node.ts
|
||||
* 인쇄 미리보기 도면(PLAN 302-2) — 미확정 횡단 장에 구조물을 얹는 Node 진입점(CLAUDE.md 5장 ②).
|
||||
*
|
||||
* B07 화면이 미확정 장을 열 때 얹는 것과 **같은 코드**(`placeStructureEntities`)를 서버에서 돌린다.
|
||||
* 입력 상세 = 정본(`get_section_detail`) — 화면 캐시의 저장 안 한 편집은 안 실림(인쇄 = 저장본).
|
||||
*
|
||||
* 실행: node <번들> <입력.json> <출력.json>
|
||||
* 입력 { detail: 종횡단 상세, drawings: { [도면 id]: CAD 도면 } }
|
||||
* 출력 { drawings: { [도면 id]: 구조물을 얹은 CAD 도면 } }
|
||||
* 끝 코드: 0 성공 / 2 인자 오류
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import "../../B07_DesignDetail/B07_DesignDetail_Node_Dom"; // 최소 DOM — 그리기 코드보다 먼저
|
||||
import { placeStructureEntities } from "../../B07_DesignDetail/B07_DesignDetail_Cross_Overlay";
|
||||
|
||||
type Drawing = Parameters<typeof placeStructureEntities>[1];
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <번들> <입력.json> <출력.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as {
|
||||
detail?: SectionDetailResponse;
|
||||
drawings?: Record<string, Drawing>;
|
||||
};
|
||||
const sections = input.detail?.cross_sections ?? [];
|
||||
const drawings = input.drawings ?? {};
|
||||
for (const drawing of Object.values(drawings)) placeStructureEntities(sections, drawing);
|
||||
writeFileSync(outputPath, JSON.stringify({ drawings }), "utf8");
|
||||
@@ -0,0 +1,337 @@
|
||||
"""인쇄 미리보기 도면(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)
|
||||
self.line([*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
|
||||
@@ -0,0 +1,99 @@
|
||||
"""인쇄 미리보기 도면(PLAN 302-2) — 목록 · PDF 길.
|
||||
|
||||
GET /api/projects/{id}/print/drawings
|
||||
→ [{id, source, kind, label, number, confirmed, paper, images_skipped}]
|
||||
POST /api/projects/{id}/print/drawings-pdf {ids?} → application/pdf
|
||||
목록 차례 · 장마다 한 쪽 · 머리 X-Aislo-Pages ·
|
||||
X-Aislo-Skipped-Images(못 넣은 그림 {id: 수} JSON)
|
||||
|
||||
로그인 · 회사 · 프로젝트 권한은 이 라우터가 스스로 검(B09 라우터가 include 하든 앱이 바로 붙이든).
|
||||
저장 안 함 — 매번 만듦 · B07 정본 · 확정 상태 그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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 common_util.common_util_auth import require_company, require_project_access, verify_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(
|
||||
prefix="/api/projects",
|
||||
tags=["B09 Print Drawings (draft)"],
|
||||
dependencies=[
|
||||
Depends(verify_session),
|
||||
Depends(require_company),
|
||||
Depends(require_project_access),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class DrawingsPdfRequest(BaseModel):
|
||||
ids: list[str] | None = None
|
||||
|
||||
|
||||
def _error(status: int, message: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"status": "error", "message": message})
|
||||
|
||||
|
||||
@router.get("/{project_id}/print/drawings")
|
||||
async def get_print_drawings(project_id: UUID) -> JSONResponse:
|
||||
try:
|
||||
items, _ = await source.drawing_items(project_id)
|
||||
except Exception:
|
||||
logger.exception("인쇄 도면 목록 실패: %s", project_id)
|
||||
return _error(500, "도면 목록을 읽지 못했습니다.")
|
||||
return JSONResponse(items)
|
||||
|
||||
|
||||
def _pdf(drawings: list[dict], ids: list[str]) -> tuple[bytes, dict[str, int]]:
|
||||
doc = pymupdf.open()
|
||||
skipped: dict[str, int] = {}
|
||||
for drawing_id, drawing in zip(ids, drawings, strict=True):
|
||||
_, missing = add_drawing_page(doc, drawing)
|
||||
if missing:
|
||||
skipped[drawing_id] = missing
|
||||
doc.subset_fonts() # 한글 글꼴 = 쓴 글자만
|
||||
return doc.tobytes(garbage=3, deflate=True), skipped
|
||||
|
||||
|
||||
@router.post("/{project_id}/print/drawings-pdf")
|
||||
async def post_print_drawings_pdf(
|
||||
project_id: UUID, request: DrawingsPdfRequest | None = None
|
||||
) -> Response:
|
||||
try:
|
||||
items, context = await source.drawing_items(project_id)
|
||||
by_id = {item["id"]: item for item in items}
|
||||
ids = request.ids if request and request.ids is not None else list(by_id)
|
||||
unknown = [one for one in ids if one not in by_id]
|
||||
if unknown:
|
||||
return _error(400, f"목록에 없는 도면입니다: {', '.join(unknown)}")
|
||||
if not ids:
|
||||
return _error(404, "인쇄할 도면이 없습니다.")
|
||||
chosen = [by_id[one] for one in ids]
|
||||
drawings = await source.drawings_in_order(project_id, chosen, context)
|
||||
body, skipped = await asyncio.to_thread(_pdf, drawings, ids)
|
||||
except ValueError as exc:
|
||||
return _error(400, str(exc))
|
||||
except Exception:
|
||||
logger.exception("인쇄 도면 PDF 실패: %s", project_id)
|
||||
return _error(500, "도면 PDF 를 만들지 못했습니다.")
|
||||
return Response(
|
||||
body,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": 'inline; filename="drawings.pdf"',
|
||||
"X-Aislo-Pages": str(len(ids)),
|
||||
"X-Aislo-Skipped-Images": json.dumps(skipped),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""인쇄 미리보기 도면(PLAN 302-2) — 목록 · 도면 모으기.
|
||||
|
||||
차례 = B07 도면번호(B07 목록 그대로) →
|
||||
구조물 조합 도면(도번 차례 · 도번마다 한 번 · `도면` 다음 `더한장`).
|
||||
B07 도면 = B07 단건 조회 그대로(확정본 = 저장 JSON · 미확정 = 서버 조립) + 미확정 횡단 장 구조물 =
|
||||
B07 화면과 같은 TS 를 Node 로(`B09_Draft_Print_Drawings_Node.ts` · CLAUDE.md 5장 ②).
|
||||
구조물 도면 = 프로젝트 사본 `도면.json` + 얹는 도각(M02 `structure_frame` · 장 도번).
|
||||
저장 · 확정 상태는 손대지 않음 — 매번 읽어 만듦.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
entities_bbox,
|
||||
template_entities,
|
||||
use_project_templates,
|
||||
use_title_fields,
|
||||
)
|
||||
from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import paper_name
|
||||
from common_util.common_util_node_bundle import ROOT, bundle_stale, node_env, run_bundle_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
B07_PREFIX = "b07:"
|
||||
STRUCTURE_PREFIX = "structure:"
|
||||
_ENTRY = Path(__file__).with_name("B09_Draft_Print_Drawings_Node.ts")
|
||||
# 번들은 git 밖(tmp) — package.json 을 안 건드리려고 vite 를 바로 부름
|
||||
BUNDLE = ROOT / "tmp" / "print_drawings_node" / "B09_Draft_Print_Drawings_Node.js"
|
||||
_BUILD = [
|
||||
"node",
|
||||
"./config/node_modules/vite/bin/vite.js",
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts",
|
||||
"--outDir",
|
||||
"../tmp/print_drawings_node",
|
||||
]
|
||||
|
||||
|
||||
async def project_root(project_id: UUID) -> Path:
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
async with get_db_pool().acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
return Path(resolve_stored_project_path(stored)).resolve()
|
||||
|
||||
|
||||
async def title_fields(project_id: UUID) -> dict[str, str]:
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
|
||||
|
||||
return await title_block_fields(project_id)
|
||||
|
||||
|
||||
async def b07_list(project_id: UUID) -> tuple[int | None, list[dict[str, Any]]]:
|
||||
"""(노선 id, B07 목록) — 노선 미확정 · 자료 없음이면 (None, [])."""
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import get_design_drawing_list
|
||||
|
||||
response = await get_design_drawing_list(project_id)
|
||||
if not hasattr(response, "drawings"): # JSONResponse = 404 · 409 · 500
|
||||
return None, []
|
||||
return response.route_id, [item.model_dump() for item in response.drawings]
|
||||
|
||||
|
||||
async def b07_drawing(project_id: UUID, drawing_id: str) -> dict[str, Any]:
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import get_design_drawing
|
||||
|
||||
response = await get_design_drawing(project_id, drawing_id)
|
||||
if not hasattr(response, "drawing"):
|
||||
raise ValueError(f"B07 도면 「{drawing_id}」 을 읽지 못했습니다.")
|
||||
return response.model_dump()
|
||||
|
||||
|
||||
async def section_detail(project_id: UUID, route_id: int) -> dict[str, Any] | None:
|
||||
from B06_Section.B06_Section_Router import get_section_detail
|
||||
|
||||
response = await get_section_detail(project_id, route_id)
|
||||
dump = getattr(response, "model_dump", None)
|
||||
return dump(mode="json") if dump else None
|
||||
|
||||
|
||||
def _bundle_ready() -> bool:
|
||||
stale = bundle_stale(BUNDLE) or _ENTRY.stat().st_mtime > BUNDLE.stat().st_mtime
|
||||
if not stale:
|
||||
return True
|
||||
result = subprocess.run( # noqa: S603 — 고정 명령
|
||||
_BUILD, cwd=str(ROOT), env=node_env(), capture_output=True, text=True, timeout=300
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("인쇄 도면 Node 번들 빌드 실패:\n%s", result.stderr)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def place_structures(detail: dict[str, Any], drawings: dict[str, dict[str, Any]]) -> bool:
|
||||
"""미확정 횡단 장에 구조물을 얹음(제자리) — 실패면 거짓(서버 그림만 나감)."""
|
||||
if not drawings or not _bundle_ready():
|
||||
return False
|
||||
output = run_bundle_json(BUNDLE, "", {"detail": detail, "drawings": drawings})
|
||||
placed = (output or {}).get("drawings") if isinstance(output, dict) else None
|
||||
if not isinstance(placed, dict):
|
||||
return False
|
||||
for key, drawing in placed.items():
|
||||
if key in drawings and isinstance(drawing, dict):
|
||||
drawings[key].clear()
|
||||
drawings[key].update(drawing)
|
||||
return True
|
||||
|
||||
|
||||
def sheet_number(number: str, index: int) -> str:
|
||||
"""장 도번 — 1장 = 도번 · n 째 장 = 도번-n(M02 `sheetNumber` 와 같음)."""
|
||||
return "" if not number else number if index == 0 else f"{number}-{index + 1}"
|
||||
|
||||
|
||||
def structure_sheets(root: Path) -> list[dict[str, Any]]:
|
||||
"""구조물 조합 도면 장 — 도번 차례 · 같은 도번은 한 번 · {id, label, number, drawing}."""
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots, structures_dir
|
||||
from M02_MasterTemplete.M02_Item_Source import read_json
|
||||
|
||||
docs: dict[str, tuple[str, dict[str, Any]]] = {}
|
||||
for owner, copy in linked_snapshots(root).items():
|
||||
if "도면" not in copy["부분"]:
|
||||
continue
|
||||
doc = read_json(structures_dir(root) / copy["폴더"] / "도면.json")
|
||||
if not isinstance(doc, dict) or not isinstance(doc.get("도면"), dict):
|
||||
continue
|
||||
number = str(doc.get("도번") or "")
|
||||
name = str((copy.get("항목") or {}).get("name") or copy["폴더"])
|
||||
docs.setdefault(number or f"~{owner}", (name, doc))
|
||||
sheets = []
|
||||
for key in sorted(docs):
|
||||
name, doc = docs[key]
|
||||
number = "" if key.startswith("~") else key
|
||||
pages = [{"이름": "", "도면": doc["도면"]}, *(doc.get("더한장") or [])]
|
||||
for index, page in enumerate(pages):
|
||||
if not isinstance(page, dict) or not isinstance(page.get("도면"), dict):
|
||||
continue
|
||||
label = f"{name} · {page['이름']}" if page.get("이름") else name
|
||||
numbered = sheet_number(number, index) or f"{key.lstrip('~')}-{index + 1}"
|
||||
sheets.append(
|
||||
{
|
||||
"id": f"{STRUCTURE_PREFIX}{numbered}",
|
||||
"label": label,
|
||||
"number": sheet_number(number, index),
|
||||
"drawing": page["도면"],
|
||||
}
|
||||
)
|
||||
return sheets
|
||||
|
||||
|
||||
def with_frame(drawing: dict[str, Any], number: str) -> dict[str, Any]:
|
||||
"""구조물 도면 + 얹는 도각(M02 화면과 같은 `structure_frame` · 표제란 = 프로젝트 값)."""
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import structure_frame
|
||||
|
||||
frame = structure_frame(number)
|
||||
layers = [*(drawing.get("layers") or []), *([frame["layer"]] if frame["layer"] else [])]
|
||||
return {
|
||||
**drawing,
|
||||
"entities": [*frame["entities"], *drawing.get("entities", [])],
|
||||
"layers": layers,
|
||||
}
|
||||
|
||||
|
||||
async def drawing_items(project_id: UUID) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
"""(목록, 만들 때 쓸 문맥).
|
||||
|
||||
목록 칸 = {id, source, kind, label, number, confirmed, paper, images_skipped}.
|
||||
"""
|
||||
root = await project_root(project_id)
|
||||
use_project_templates(root)
|
||||
template = entities_bbox(template_entities())
|
||||
paper = paper_name(template[2] - template[0], template[3] - template[1]) if template else None
|
||||
route_id, b07 = await b07_list(project_id)
|
||||
items = [
|
||||
{
|
||||
"id": f"{B07_PREFIX}{item['id']}",
|
||||
"source": "b07",
|
||||
"kind": item["kind"],
|
||||
"label": item["label"],
|
||||
"number": str(number),
|
||||
"confirmed": bool(item.get("confirmed")),
|
||||
"paper": paper,
|
||||
"images_skipped": None, # PDF 만들 때 셈 — 응답 머리 X-Aislo-Skipped-Images
|
||||
}
|
||||
for number, item in enumerate(b07, start=1)
|
||||
]
|
||||
sheets = await asyncio.to_thread(structure_sheets, root)
|
||||
items += [
|
||||
{
|
||||
"id": sheet["id"],
|
||||
"source": "structure",
|
||||
"kind": "structure",
|
||||
"label": sheet["label"],
|
||||
"number": sheet["number"],
|
||||
"confirmed": True,
|
||||
"paper": paper,
|
||||
"images_skipped": None,
|
||||
}
|
||||
for sheet in sheets
|
||||
]
|
||||
return items, {"root": root, "route_id": route_id, "sheets": {s["id"]: s for s in sheets}}
|
||||
|
||||
|
||||
async def drawings_in_order(
|
||||
project_id: UUID, items: list[dict[str, Any]], context: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""고른 목록 차례대로 CAD 도면 — 미확정 횡단 장은 구조물을 얹어서."""
|
||||
base_fields = await title_fields(project_id)
|
||||
drawings: list[dict[str, Any]] = []
|
||||
unconfirmed_cross: dict[str, dict[str, Any]] = {}
|
||||
for item in items:
|
||||
if item["source"] == "b07":
|
||||
response = await b07_drawing(project_id, item["id"][len(B07_PREFIX) :])
|
||||
drawing = response["drawing"]
|
||||
if item["kind"] == "cross" and not response.get("confirmed"):
|
||||
unconfirmed_cross[item["id"]] = drawing
|
||||
else:
|
||||
sheet = context["sheets"][item["id"]]
|
||||
use_project_templates(context["root"])
|
||||
use_title_fields(base_fields)
|
||||
drawing = with_frame(sheet["drawing"], sheet["number"])
|
||||
drawings.append(drawing)
|
||||
if unconfirmed_cross and context.get("route_id") is not None:
|
||||
detail = await section_detail(project_id, int(context["route_id"]))
|
||||
if detail is None or not await asyncio.to_thread(
|
||||
place_structures, detail, unconfirmed_cross
|
||||
):
|
||||
logger.warning(
|
||||
"인쇄 도면 — 미확정 횡단 장 구조물을 못 얹음(서버 그림만): %s", project_id
|
||||
)
|
||||
return drawings
|
||||
@@ -12,6 +12,7 @@ et_xmlfile==2.0.0
|
||||
fastapi==0.104.1
|
||||
bcrypt>=4.1,<5
|
||||
email-validator>=2.1,<3
|
||||
ezdxf==1.4.4
|
||||
fiona==1.10.1
|
||||
geopandas==1.1.4
|
||||
h11==0.16.0
|
||||
@@ -32,6 +33,7 @@ playwright==1.62.0
|
||||
psutil>=6.1,<7
|
||||
pydantic==2.5.0
|
||||
pydantic_core==2.14.1
|
||||
PyMuPDF==1.28.2
|
||||
PyMySQL==1.2.0
|
||||
pyparsing==3.3.2
|
||||
pyproj==3.7.2
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""PLAN 302-2 인쇄 미리보기 도면 PDF — 목록 차례 · 쪽 수 · 쪽 크기 · 한글 · 숨긴 층 · 권한.
|
||||
|
||||
임시 폴더 견본만 씀(공용 storage 안 씀) — B07 목록 · 단건 · 표제란 값은 바꿔 끼움.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pymupdf
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
frame_entities,
|
||||
usable_bbox,
|
||||
use_project_templates,
|
||||
use_title_fields,
|
||||
)
|
||||
from B09_Estimation.draft import B09_Draft_Print_Drawings_Router as router_module
|
||||
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,
|
||||
paint_color,
|
||||
)
|
||||
from common_util import common_util_auth as auth
|
||||
|
||||
TITLE = {"공사명": "가나다임도개설공사", "용역회사": "라마바설계"}
|
||||
|
||||
|
||||
def _text(label: str, x: float, y: float, layer: str) -> dict:
|
||||
return {
|
||||
"id": str(uuid4()),
|
||||
"type": "Text",
|
||||
"lineColor": "#ffffff",
|
||||
"lineWidth": 1,
|
||||
"layerId": layer,
|
||||
"shapeData": {
|
||||
"basePoint": {"x": x, "y": y},
|
||||
"label": label,
|
||||
"options": {"fontSize": 10, "textAlign": "left", "textColor": "#ffffff"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _b07_drawing(drawing_id: str) -> dict:
|
||||
"""도각(시스템 A1 · 표제란 = TITLE) + 보이는 글 + 숨긴 층 · 동결 층 글."""
|
||||
use_project_templates(None)
|
||||
use_title_fields(TITLE)
|
||||
entities = frame_entities(drawing_id, usable_bbox(), fit=False, fields={"도면명": "종단면도"})
|
||||
entities += [
|
||||
_text("보이는글", 200, 300, "draw"),
|
||||
_text("숨은글", 200, 320, "hidden"),
|
||||
_text("얼린글", 200, 340, "frozen"),
|
||||
]
|
||||
layers = [
|
||||
{"id": "draw", "name": "그림", "isVisible": True, "isLocked": False},
|
||||
{"id": "hidden", "name": "숨김", "isVisible": False, "isLocked": False},
|
||||
{"id": "frozen", "name": "동결", "isVisible": True, "isLocked": False, "isFrozen": True},
|
||||
]
|
||||
return {"format": "openwebcad", "entities": entities, "layers": layers}
|
||||
|
||||
|
||||
def _structure_doc(number: str, more: list[str] = ()) -> dict:
|
||||
sheet = {
|
||||
"format": "openwebcad",
|
||||
"entities": [_text("구조물그림", 300, 300, "기본")],
|
||||
"layers": [],
|
||||
}
|
||||
return {
|
||||
"도번": number,
|
||||
"도면": sheet,
|
||||
"더한장": [{"장id": f"s{i}", "이름": name, "도면": sheet} for i, name in enumerate(more)],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample(tmp_path, monkeypatch):
|
||||
"""B07 둘(종단 · 표지) + 구조물 사본 셋(SD-2 둘 = 한 번 · 더한장 하나 · SD-1 하나)."""
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshot
|
||||
|
||||
base = tmp_path / "structures"
|
||||
copies = {
|
||||
"pipe@10__a": ("A", _structure_doc("SD-000002", ["상세"])),
|
||||
"pipe@20__b": ("B", _structure_doc("SD-000002", ["상세"])),
|
||||
"pipe@30__c": ("C", _structure_doc("SD-000001")),
|
||||
}
|
||||
linked = {}
|
||||
for owner, (folder, doc) in copies.items():
|
||||
(base / folder).mkdir(parents=True)
|
||||
(base / folder / "도면.json").write_text(json.dumps(doc, ensure_ascii=False), "utf-8")
|
||||
linked[owner] = {"항목": {"name": f"구조물{folder}"}, "폴더": folder, "부분": ["도면"]}
|
||||
monkeypatch.setattr(snapshot, "linked_snapshots", lambda root: linked)
|
||||
monkeypatch.setattr(snapshot, "structures_dir", lambda root: base)
|
||||
|
||||
async def root(_pid):
|
||||
return tmp_path
|
||||
|
||||
async def fields(_pid):
|
||||
return dict(TITLE)
|
||||
|
||||
async def b07_list(_pid):
|
||||
return 7, [
|
||||
{
|
||||
"id": "longitudinal_1",
|
||||
"kind": "longitudinal",
|
||||
"label": "종단도(1)",
|
||||
"confirmed": True,
|
||||
},
|
||||
{"id": "cover", "kind": "cover", "label": "표지", "confirmed": False},
|
||||
]
|
||||
|
||||
async def b07_drawing(_pid, drawing_id):
|
||||
return {"drawing": _b07_drawing(drawing_id), "confirmed": True}
|
||||
|
||||
monkeypatch.setattr(source, "project_root", root)
|
||||
monkeypatch.setattr(source, "title_fields", fields)
|
||||
monkeypatch.setattr(source, "b07_list", b07_list)
|
||||
monkeypatch.setattr(source, "b07_drawing", b07_drawing)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _client(session: dict | None = None, project_company: int | None = None) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
if session is not None:
|
||||
app.dependency_overrides[auth.verify_session] = lambda: session
|
||||
if project_company is None:
|
||||
app.dependency_overrides[auth.require_project_access] = lambda: session
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
SESSION = {"user_id": 1, "company_id": 1, "role": "USER", "is_master": False}
|
||||
|
||||
|
||||
def test_paint_color_theme_짝():
|
||||
"""openwebcad theme.ts paintColor(밝은 바탕) 손셈.
|
||||
|
||||
자동색 검정 · 밝은 노랑 휘도 0.3 으로 · 어두운 색 그대로.
|
||||
"""
|
||||
assert paint_color("#ffffff") == paint_color("#FFF") == paint_color("#f5f7fa") == (0, 0, 0)
|
||||
assert paint_color("#f6d55c") == (89 / 255, 77 / 255, 33 / 255)
|
||||
assert paint_color("#336699") == (0x33 / 255, 0x66 / 255, 0x99 / 255)
|
||||
|
||||
|
||||
def test_목록_차례_B07_번호_다음_도번_한_번(sample):
|
||||
pid = uuid4()
|
||||
body = _client(SESSION).get(f"/api/projects/{pid}/print/drawings").json()
|
||||
assert [item["id"] for item in body] == [
|
||||
"b07:longitudinal_1",
|
||||
"b07:cover",
|
||||
"structure:SD-000001",
|
||||
"structure:SD-000002",
|
||||
"structure:SD-000002-2",
|
||||
]
|
||||
assert [item["number"] for item in body][:2] == ["1", "2"]
|
||||
assert body[3]["label"] == "구조물A" and body[4]["label"] == "구조물A · 상세"
|
||||
assert {item["paper"] for item in body} == {"A1"}
|
||||
assert {item["source"] for item in body} == {"b07", "structure"}
|
||||
|
||||
|
||||
def test_PDF_쪽수_크기_한글_숨긴층(sample):
|
||||
pid = uuid4()
|
||||
response = _client(SESSION).post(f"/api/projects/{pid}/print/drawings-pdf", json={})
|
||||
assert response.status_code == 200, response.text
|
||||
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()
|
||||
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()
|
||||
|
||||
|
||||
def test_PDF_고른_차례_모르는_id(sample):
|
||||
pid = uuid4()
|
||||
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()
|
||||
bad = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": ["b07:없음"]})
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
class _FakePool:
|
||||
"""require_project_access 가 읽는 프로젝트 회사 한 줄."""
|
||||
|
||||
def __init__(self, company_id: int):
|
||||
self.row = (company_id,)
|
||||
|
||||
def acquire(self):
|
||||
pool = self
|
||||
|
||||
class _Cursor:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, *_):
|
||||
return None
|
||||
|
||||
async def fetchone(self):
|
||||
return pool.row
|
||||
|
||||
class _Connection:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
def cursor(self):
|
||||
return _Cursor()
|
||||
|
||||
return _Connection()
|
||||
|
||||
|
||||
def test_로그인_회사_권한(sample, monkeypatch):
|
||||
pid = uuid4()
|
||||
path = f"/api/projects/{pid}/print/drawings"
|
||||
assert _client().get(path).status_code == 401 # 쿠키 없음
|
||||
no_company = {**SESSION, "company_id": None}
|
||||
assert _client(no_company, project_company=0).get(path).status_code == 403
|
||||
monkeypatch.setattr(auth, "get_db_pool", lambda: _FakePool(2))
|
||||
other = _client(SESSION, project_company=2)
|
||||
assert other.get(path).status_code == 403 # 남의 회사 프로젝트
|
||||
assert other.post(f"/api/projects/{pid}/print/drawings-pdf", json={}).status_code == 403
|
||||
monkeypatch.setattr(auth, "get_db_pool", lambda: _FakePool(1))
|
||||
assert _client(SESSION, project_company=1).get(path).status_code == 200
|
||||
|
||||
|
||||
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}]
|
||||
return {"type": "Image", "layerId": "a", "shapeData": {"points": points, "imageData": data}}
|
||||
|
||||
drawing = {
|
||||
"entities": [
|
||||
image("data:image/png;base64," + base64.b64encode(png).decode()),
|
||||
image("/api/assets/없는그림.png"),
|
||||
],
|
||||
"layers": [],
|
||||
}
|
||||
doc = pymupdf.open()
|
||||
paper, skipped = add_drawing_page(doc, drawing)
|
||||
assert skipped == 1 and len(doc[0].get_images()) == 1
|
||||
|
||||
|
||||
def test_미확정_횡단_구조물_Node_번들이_돎():
|
||||
"""서버 Node(화면 TS 그대로) — 번들 빌드 · 실행 · 배치 없는 장은 그대로."""
|
||||
entity = _text("서버그림", 0, 0, "draw")
|
||||
drawings = {"b07:cross_x": {"entities": [entity], "cross_placements": []}}
|
||||
assert source.place_structures({"cross_sections": []}, drawings) is True
|
||||
assert drawings["b07:cross_x"]["entities"] == [entity]
|
||||
assert Path(source.BUNDLE).is_file()
|
||||
Reference in New Issue
Block a user