본문 바로가기
  • You find inspiration to create your own path !
업무 자동화/python

PDF를 DXF로 변환 하기

by ToolBOX01 2026. 6. 19.
반응형

■ 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. 프로그램 실행 화면

반응형