반응형
■ DXF 파일 변환
도면 내부의 치수/기호뿐만 아니라 우측 하단의 표제란(Title Block) 정보도 함께 변한 합니다. PDF 형식의 엔지니어링 도면을 CAD에서 편집 가능한 DXF(Drawing Exchange Format) 파일로 변환 하는 기능 입니다. 선, 호 등의 기하학적 도형(벡터)뿐만 아니라, 한글 주서나 치수 문자까지 깨지지 않게 유니코드(MTEXT)로 변환해 주는 핵심 기능들을 포함 합니다.
필요한 라이브러리
PDF를 읽기 위한 PyMuPDF와 CAD 파일(DXF)을 생성하기 위한 ezdxf 라이브러리가 필요합니다.
| pip install PySide6 pymupdf ezdxf |
1. 주요 기능 설명
① 도면 선과 도형 복원 (벡터 경로 변환)
- PDF 내부에 저장된 벡터 그래픽 데이터(page.get_drawings())를 추적합니다.
- 직선(l)은 물론, 캐드에서 다루기 까다로운 3차 베지어 곡선(c)을 12개의 세그먼트로 미세하게 분할 근사하여 자연스러운 곡선(폴리라인)으로 복원합니다.
- PDF와 CAD의 반대되는 Y축 좌표계를 뒤집어 주는 계산(flip_y, flip_rect)이 내장되어 도면이 뒤집히지 않고 올바르게 배치됩니다.
② 선폭과 종류에 따른 스마트 레이어 자동 분류
- 선의 두께, 채우기 여부, 점선 패턴 등을 분석하여 8개의 캐드 표준 레이어(LAYER_CONFIG)로 자동 분류합니다.
- 두꺼운 선($\ge 0.5\text{pt}$)은 도면선(DRAWING), 얇은 선은 THIN_LINE으로 보냅니다.
- 단면 해치와 같이 채워진 도형은 HATCH 레이어로, 대시 패턴이 있는 선은 HIDDEN_LINE이나 CENTER_LINE으로 분기합니다.
③ 한글 깨짐 방지 및 유니코드 MTEXT 변환
- 단순히 텍스트만 추출하는 것이 아니라, 캐드에서 한글을 지원할 수 있도록 Arial Unicode MS 및 한글 SHX 폰트(whgtxt.shx) 스타일을 KOREAN이라는 이름으로 DXF 내부에 사전 등록합니다.
- 텍스트를 일반 단선 텍스트(TEXT)가 아닌 다중행 텍스트(MTEXT)로 삽입하여 도면 내부의 한글 유니코드가 깨지지 않고 안전하게 보존되도록 처리했습니다.
2. 파이썬 코드
"""
PDF → DXF 변환기 GUI
PySide6 기반 파일 선택 UI + 변환 진행 표시
실행: python pdf_to_dxf_gui.py
의존성: pip install PySide6 pymupdf ezdxf
"""
import sys
import os
import math
import re
import threading
import fitz
import ezdxf
from ezdxf import units
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QFileDialog, QComboBox, QCheckBox,
QLineEdit, QProgressBar, QTextEdit, QGroupBox, QSizePolicy,
QFrame
)
from PySide6.QtCore import Qt, Signal, QObject, QThread
from PySide6.QtGui import QFont, QDragEnterEvent, QDropEvent, QPalette, QColor
# ───────────────────────────────────────────────
# 변환 로직 (pdf_to_dxf.py 에서 가져옴)
# ───────────────────────────────────────────────
LAYER_CONFIG = {
"DRAWING": {"color": 7, "linetype": "Continuous"},
"THIN_LINE": {"color": 8, "linetype": "Continuous"},
"HIDDEN_LINE": {"color": 3, "linetype": "DASHED"},
"CENTER_LINE": {"color": 1, "linetype": "CENTER"},
"HATCH": {"color": 6, "linetype": "Continuous"},
"TEXT": {"color": 2, "linetype": "Continuous"},
"FRAME": {"color": 9, "linetype": "Continuous"},
"DIMENSION": {"color": 4, "linetype": "Continuous"},
}
def flip_y(pt, page_height):
return (pt[0], page_height - pt[1])
def flip_rect(rect, page_height):
x0, y0, x1, y1 = rect.x0, rect.y0, rect.x1, rect.y1
return x0, page_height - y1, x1, page_height - y0
def detect_layer(path):
width = path.get("width") or 0
dashes = path.get("dashes", "[] 0")
fill = path.get("fill")
if fill and str(fill) != "None":
return "HATCH"
if dashes and dashes != "[] 0":
try:
nums = re.findall(r"\d+\.?\d*", str(dashes))
if nums and float(nums[0]) > 15:
return "CENTER_LINE"
except Exception:
pass
return "HIDDEN_LINE"
return "DRAWING" if width >= 0.5 else "THIN_LINE"
def _close(p1, p2, tol=0.5):
return math.hypot(p1[0] - p2[0], p1[1] - p2[1]) < tol
def _flush_polyline(msp, pts, layer, lw_mm):
if len(pts) >= 2:
msp.add_lwpolyline(pts, dxfattribs={"layer": layer, "lineweight": lw_mm})
def _cubic_bezier_approx(p0, p1, p2, p3, segments=12):
pts = []
for i in range(segments + 1):
t = i / segments
mt = 1 - t
x = mt**3*p0[0] + 3*mt**2*t*p1[0] + 3*mt*t**2*p2[0] + t**3*p3[0]
y = mt**3*p0[1] + 3*mt**2*t*p1[1] + 3*mt*t**2*p2[1] + t**3*p3[1]
pts.append((x, y))
return pts
def convert_pdf_to_dxf(pdf_path, dxf_path, dxf_version="R2010",
korean=True, auto_layer=True, log_fn=None):
"""실제 변환 함수. log_fn(str) 으로 진행 상황 전달."""
def log(msg):
if log_fn:
log_fn(msg)
log(f"PDF 열기: {os.path.basename(pdf_path)}")
pdf_doc = fitz.open(pdf_path)
page = pdf_doc[0]
page_h = page.rect.height
page_w = page.rect.width
log(f"페이지 크기: {page_w:.0f} × {page_h:.0f} pts")
log("DXF 문서 초기화...")
dxf_doc = ezdxf.new(dxfversion=dxf_version)
dxf_doc.units = units.MM
for name, cfg in LAYER_CONFIG.items():
layer = dxf_doc.layers.new(name=name)
layer.color = cfg["color"]
try:
dxf_doc.linetypes.get(cfg["linetype"])
layer.linetype = cfg["linetype"]
except Exception:
pass
if korean:
if "KOREAN" not in dxf_doc.styles:
dxf_doc.styles.new("KOREAN", dxfattribs={
"font": "Arial Unicode MS", "bigfont": "whgtxt.shx", "width": 1.0
})
if "NORMAL" not in dxf_doc.styles:
dxf_doc.styles.new("NORMAL", dxfattribs={"font": "Century Gothic", "width": 1.0})
msp = dxf_doc.modelspace()
# ── 벡터 경로 변환
log("벡터 경로 변환 중...")
paths = page.get_drawings()
line_count = poly_count = 0
for path in paths:
layer_name = detect_layer(path) if auto_layer else "DRAWING"
items = path.get("items", [])
width = path.get("width") or 0.25
lw_mm = max(0, min(211, round(width * 0.352778 * 100)))
pts_seq = []
for item in items:
kind = item[0]
if kind == "l":
p1 = flip_y((item[1].x, item[1].y), page_h)
p2 = flip_y((item[2].x, item[2].y), page_h)
if pts_seq and _close(pts_seq[-1], p1):
pts_seq.append(p2)
else:
if len(pts_seq) >= 2:
_flush_polyline(msp, pts_seq, layer_name, lw_mm)
poly_count += 1
pts_seq = [p1, p2]
line_count += 1
elif kind == "c":
bz = _cubic_bezier_approx(
flip_y((item[1].x, item[1].y), page_h),
flip_y((item[2].x, item[2].y), page_h),
flip_y((item[3].x, item[3].y), page_h),
flip_y((item[4].x, item[4].y), page_h),
)
if pts_seq and _close(pts_seq[-1], bz[0]):
pts_seq.extend(bz[1:])
else:
if len(pts_seq) >= 2:
_flush_polyline(msp, pts_seq, layer_name, lw_mm)
poly_count += 1
pts_seq = bz
elif kind == "re":
if len(pts_seq) >= 2:
_flush_polyline(msp, pts_seq, layer_name, lw_mm)
poly_count += 1
pts_seq = []
x0, y0, x1, y1 = flip_rect(item[1], page_h)
msp.add_lwpolyline(
[(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)],
dxfattribs={"layer": layer_name, "lineweight": lw_mm},
)
poly_count += 1
if len(pts_seq) >= 2:
_flush_polyline(msp, pts_seq, layer_name, lw_mm)
poly_count += 1
log(f"경로 완료: 선분 {line_count}개 → 폴리라인 {poly_count}개")
# ── 텍스트 변환
log("텍스트(한글 포함) 변환 중...")
blocks_data = page.get_text("dict", flags=fitz.TEXT_PRESERVE_LIGATURES)
text_count = 0
for block in blocks_data["blocks"]:
if block["type"] != 0:
continue
for line in block["lines"]:
for span in line["spans"]:
text = span["text"].strip()
if not text:
continue
ox, oy = span["origin"]
x, y = flip_y((ox, oy), page_h)
size = span["size"] * 0.352778
font = span["font"]
if size < 3.0:
tl = "FRAME"
elif any(c >= "\uAC00" and c <= "\uD7A3" for c in text):
tl = "TEXT"
else:
tl = "DIMENSION"
style = "KOREAN" if korean and ("Arial" in font or "Batang" in font or "Unicode" in font) else "NORMAL"
msp.add_mtext(text, dxfattribs={
"layer": tl, "style": style,
"char_height": max(size, 1.0),
"insert": (x, y),
"attachment_point": 7,
})
text_count += 1
log(f"텍스트 완료: {text_count}개")
# ── 저장
log(f"DXF 저장: {os.path.basename(dxf_path)}")
dxf_doc.saveas(dxf_path)
size_kb = os.path.getsize(dxf_path) / 1024
log(f"✓ 완료! ({size_kb:.1f} KB)")
return size_kb
# ───────────────────────────────────────────────
# 백그라운드 변환 워커
# ───────────────────────────────────────────────
class ConvertSignals(QObject):
log = Signal(str)
progress = Signal(int)
done = Signal(float) # KB
error = Signal(str)
class ConvertWorker(QThread):
def __init__(self, pdf_path, dxf_path, version, korean, auto_layer):
super().__init__()
self.pdf_path = pdf_path
self.dxf_path = dxf_path
self.version = version
self.korean = korean
self.auto_layer = auto_layer
self.signals = ConvertSignals()
def run(self):
steps_done = [0]
total_steps = 5
def log_fn(msg):
self.signals.log.emit(msg)
steps_done[0] += 1
pct = min(95, int(steps_done[0] / total_steps * 100))
self.signals.progress.emit(pct)
try:
size_kb = convert_pdf_to_dxf(
self.pdf_path, self.dxf_path,
dxf_version=self.version,
korean=self.korean,
auto_layer=self.auto_layer,
log_fn=log_fn,
)
self.signals.progress.emit(100)
self.signals.done.emit(size_kb)
except Exception as e:
self.signals.error.emit(str(e))
# ───────────────────────────────────────────────
# 드래그앤드롭 지원 파일 선택 영역
# ───────────────────────────────────────────────
class DropArea(QLabel):
file_dropped = Signal(str)
def __init__(self):
super().__init__()
self.setAcceptDrops(True)
self.setAlignment(Qt.AlignCenter)
self.setText("📄 PDF 파일을 드래그하거나\n클릭하여 선택")
self.setMinimumHeight(120)
self.setStyleSheet("""
QLabel {
border: 2px dashed #AAAAAA;
border-radius: 10px;
background: #F8F8F8;
color: #666666;
font-size: 14px;
padding: 20px;
}
QLabel:hover {
border-color: #378ADD;
background: #EBF4FF;
color: #185FA5;
}
""")
self.setCursor(Qt.PointingHandCursor)
def mousePressEvent(self, event):
path, _ = QFileDialog.getOpenFileName(
self, "PDF 파일 선택", "", "PDF 파일 (*.pdf *.PDF)"
)
if path:
self.file_dropped.emit(path)
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
self.setStyleSheet(self.styleSheet().replace("#F8F8F8", "#EBF4FF"))
def dragLeaveEvent(self, event):
self.setStyleSheet(self.styleSheet().replace("#EBF4FF", "#F8F8F8"))
def dropEvent(self, event: QDropEvent):
self.setStyleSheet(self.styleSheet().replace("#EBF4FF", "#F8F8F8"))
urls = event.mimeData().urls()
if urls:
path = urls[0].toLocalFile()
if path.lower().endswith(".pdf"):
self.file_dropped.emit(path)
else:
self.setText("❌ PDF 파일만 지원합니다\n다시 시도해 주세요")
# ───────────────────────────────────────────────
# 메인 윈도우
# ───────────────────────────────────────────────
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PDF → DXF 변환기")
self.setMinimumWidth(520)
self.setMinimumHeight(600)
self.pdf_path = None
self.worker = None
self._build_ui()
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(12)
# ── 제목
title = QLabel("PDF → DXF 변환기")
title.setFont(QFont("", 16, QFont.Bold))
layout.addWidget(title)
sub = QLabel("엔지니어링 도면 PDF를 DXF로 변환합니다. 한글 텍스트를 지원합니다.")
sub.setStyleSheet("color: #666666; font-size: 12px;")
sub.setWordWrap(True)
layout.addWidget(sub)
# ── 파일 선택 영역
self.drop_area = DropArea()
self.drop_area.file_dropped.connect(self.on_file_selected)
layout.addWidget(self.drop_area)
# ── 선택된 파일 표시
self.file_label = QLabel("선택된 파일 없음")
self.file_label.setStyleSheet("color: #888888; font-size: 12px; padding: 4px 0;")
layout.addWidget(self.file_label)
# ── 옵션 그룹
opts = QGroupBox("변환 옵션")
opts_layout = QVBoxLayout(opts)
opts_layout.setSpacing(8)
# DXF 버전
row1 = QHBoxLayout()
row1.addWidget(QLabel("DXF 버전:"))
self.cmb_version = QComboBox()
for v in ["R2010", "R2000", "R2004", "R2007", "R2013", "R2018"]:
self.cmb_version.addItem(v)
self.cmb_version.setCurrentText("R2010")
row1.addWidget(self.cmb_version)
row1.addStretch()
opts_layout.addLayout(row1)
# 출력 파일명
row2 = QHBoxLayout()
row2.addWidget(QLabel("출력 파일명:"))
self.txt_outname = QLineEdit()
self.txt_outname.setPlaceholderText("비워두면 입력 파일명 사용")
row2.addWidget(self.txt_outname)
opts_layout.addLayout(row2)
# 체크박스
self.chk_korean = QCheckBox("한글 텍스트 보존 (Arial Unicode MS 스타일)")
self.chk_korean.setChecked(True)
opts_layout.addWidget(self.chk_korean)
self.chk_layer = QCheckBox("레이어 자동 분류 (선폭·패턴 기준)")
self.chk_layer.setChecked(True)
opts_layout.addWidget(self.chk_layer)
layout.addWidget(opts)
# ── 변환 버튼
self.btn_convert = QPushButton("DXF로 변환")
self.btn_convert.setMinimumHeight(40)
self.btn_convert.setEnabled(False)
self.btn_convert.setStyleSheet("""
QPushButton {
background: #185FA5; color: white;
border: none; border-radius: 8px;
font-size: 14px; font-weight: bold;
}
QPushButton:hover { background: #0C447C; }
QPushButton:disabled { background: #CCCCCC; color: #888888; }
""")
self.btn_convert.clicked.connect(self.start_convert)
layout.addWidget(self.btn_convert)
# ── 진행바
self.progress = QProgressBar()
self.progress.setRange(0, 100)
self.progress.setValue(0)
self.progress.setVisible(False)
self.progress.setStyleSheet("""
QProgressBar { border-radius: 4px; background: #EEEEEE; height: 8px; text-align: center; }
QProgressBar::chunk { background: #378ADD; border-radius: 4px; }
""")
layout.addWidget(self.progress)
# ── 로그
self.log_box = QTextEdit()
self.log_box.setReadOnly(True)
self.log_box.setMaximumHeight(130)
self.log_box.setVisible(False)
self.log_box.setStyleSheet("""
QTextEdit {
background: #F5F5F5; border: 1px solid #DDDDDD;
border-radius: 6px; font-family: monospace; font-size: 11px;
color: #444444; padding: 6px;
}
""")
layout.addWidget(self.log_box)
# ── 결과 영역
self.result_frame = QFrame()
self.result_frame.setVisible(False)
self.result_frame.setStyleSheet("""
QFrame { background: #EAF7F0; border: 1px solid #5DCAA5;
border-radius: 8px; padding: 8px; }
""")
rf_layout = QHBoxLayout(self.result_frame)
self.result_label = QLabel("✓ 변환 완료")
self.result_label.setStyleSheet("color: #0F6E56; font-weight: bold; font-size: 13px;")
rf_layout.addWidget(self.result_label)
rf_layout.addStretch()
self.btn_open = QPushButton("📂 폴더 열기")
self.btn_open.setStyleSheet("""
QPushButton { background: white; border: 1px solid #5DCAA5;
border-radius: 6px; padding: 4px 12px; color: #0F6E56; }
QPushButton:hover { background: #D5F2E8; }
""")
self.btn_open.clicked.connect(self.open_output_folder)
rf_layout.addWidget(self.btn_open)
layout.addWidget(self.result_frame)
layout.addStretch()
# ── 슬롯
def on_file_selected(self, path):
self.pdf_path = path
name = os.path.basename(path)
size = os.path.getsize(path)
size_str = f"{size/1024:.0f} KB" if size < 1024*1024 else f"{size/1024/1024:.1f} MB"
self.file_label.setText(f"📄 {name} ({size_str})")
self.file_label.setStyleSheet("color: #185FA5; font-size: 12px; padding: 4px 0;")
self.drop_area.setText(f"✅ {name}\n\n다른 파일을 선택하려면 클릭하세요")
self.btn_convert.setEnabled(True)
self.result_frame.setVisible(False)
self.log_box.clear()
self.log_box.setVisible(False)
self.progress.setVisible(False)
self.progress.setValue(0)
def start_convert(self):
if not self.pdf_path:
return
# 출력 경로 결정
custom = self.txt_outname.text().strip()
if custom:
base = custom if custom.lower().endswith(".dxf") else custom + ".dxf"
dxf_path = os.path.join(os.path.dirname(self.pdf_path), base)
else:
dxf_path = os.path.splitext(self.pdf_path)[0] + ".dxf"
self.dxf_path = dxf_path
self.btn_convert.setEnabled(False)
self.btn_convert.setText("변환 중...")
self.progress.setVisible(True)
self.progress.setValue(0)
self.log_box.setVisible(True)
self.log_box.clear()
self.result_frame.setVisible(False)
self.worker = ConvertWorker(
self.pdf_path, dxf_path,
version=self.cmb_version.currentText(),
korean=self.chk_korean.isChecked(),
auto_layer=self.chk_layer.isChecked(),
)
self.worker.signals.log.connect(self.on_log)
self.worker.signals.progress.connect(self.progress.setValue)
self.worker.signals.done.connect(self.on_done)
self.worker.signals.error.connect(self.on_error)
self.worker.start()
def on_log(self, msg):
self.log_box.append(msg)
def on_done(self, size_kb):
self.btn_convert.setEnabled(True)
self.btn_convert.setText("DXF로 변환")
name = os.path.basename(self.dxf_path)
self.result_label.setText(f"✓ {name} ({size_kb:.0f} KB)")
self.result_frame.setVisible(True)
def on_error(self, msg):
self.btn_convert.setEnabled(True)
self.btn_convert.setText("DXF로 변환")
self.log_box.append(f"❌ 오류: {msg}")
def open_output_folder(self):
if hasattr(self, 'dxf_path') and self.dxf_path:
folder = os.path.dirname(self.dxf_path)
import subprocess, platform
if platform.system() == "Windows":
subprocess.Popen(["explorer", folder])
elif platform.system() == "Darwin":
subprocess.Popen(["open", folder])
else:
subprocess.Popen(["xdg-open", folder])
# ───────────────────────────────────────────────
# 실행 진입점
# ───────────────────────────────────────────────
if __name__ == "__main__":
app = QApplication(sys.argv)
app.setStyle("Fusion")
win = MainWindow()
win.show()
sys.exit(app.exec())
3. 프로그램 실행 화면

반응형
'업무 자동화 > python' 카테고리의 다른 글
| 도면 PDF에서 문자 정보 가져오기 (0) | 2026.06.20 |
|---|---|
| 이미지 관리 프로그램 # 2 (0) | 2026.06.14 |
| 아이폰 사진 속성 정보 가져오기 (0) | 2026.06.13 |
| 이미지 관리 프로그램 # 1 (1) | 2026.06.12 |
| Get dimensions from a Creo model #2 (1) | 2025.09.18 |