PyQt5 Excel → Markdown 변환기 (GUI)

누구나 그대로 따라 만들어서 빌드/배포까지 할 수 있도록 단계별로 정리했습니다.

1) 환경 준비

  • Windows 10/11, macOS 최신 버전 권장
  • Python 3.10.x 설치
  • 가상환경 권장
Bash
# (선택) 가상환경
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS/Linux
source .venv/bin/activate

2) 의존성 설치

Bash
pip install PyQt5 aspose-cells pyinstaller
  • PyQt5: GUI 프레임워크
  • aspose-cells: Excel → Markdown 저장
  • PyInstaller: 배포용 실행파일 생성

3) 프로젝트 구조 만들기

excel2md/
├── excel2md.py
└── img/
    └── icon/
        └── excel2md_transparent.ico

아이콘 파일은 반드시 img/icon/excel2md_transparent.ico에 두세요.

4) 전체 로직

  • 각 시트를 개별 .md로 저장
  • “Evaluation Only” 줄 제거
  • 전체 통합 파일도 함께 생성
Python
from PyQt5.QtWidgets import (
    QApplication, QWidget, QPushButton, QLabel, QFileDialog,
    QVBoxLayout, QHBoxLayout, QMessageBox, QProgressBar, QFrame
)
from PyQt5.QtGui import QIcon, QFont, QPalette, QColor
from PyQt5.QtCore import Qt
from aspose.cells import Workbook
import os
import sys
import platform


def get_resource_path(relative_path):
    """PyInstaller 환경에서 리소스 경로 가져오기"""
    if hasattr(sys, '_MEIPASS'):
        # PyInstaller 임시 디렉토리
        return os.path.join(sys._MEIPASS, relative_path)
    else:
        # 일반 실행 환경
        return os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path)


class ExcelToMarkdownApp(QWidget):
    def __init__(self):
        super().__init__()
        self.setup_window()
        self.file_path = ""
        self.output_dir = ""
        self.init_ui()

    def setup_window(self):
        """윈도우 설정"""
        self.setWindowTitle("Excel to Markdown Converter")
        
        # PyInstaller 환경에서 아이콘 찾기
        icon_path = get_resource_path(os.path.join("img", "icon", "excel2md_transparent.ico"))
        
        if os.path.exists(icon_path):
            try:
                self.setWindowIcon(QIcon(icon_path))
            except Exception as e:
                print(f"아이콘 로드 실패: {e}")
        else:
            print(f"아이콘 파일을 찾을 수 없습니다: {icon_path}")
        
        self.resize(500, 600)
        self.setMinimumSize(450, 300)

    def init_ui(self):
        """UI 초기화"""
        # 메인 레이아웃
        main_layout = QVBoxLayout()
        main_layout.setSpacing(10)
        main_layout.setContentsMargins(20, 20, 20, 20)
        
        # 제목
        title = QLabel("Excel to Markdown Converter")
        title.setFont(QFont("맑은 고딕", 18, QFont.Bold))
        title.setAlignment(Qt.AlignCenter)
        title.setStyleSheet("color: #2c3e50; margin-bottom: 10px;")
        
        # 서브타이틀
        subtitle = QLabel("Excel 파일을 시트별/통합 Markdown 파일로 변환합니다")
        subtitle.setFont(QFont("맑은 고딕", 11))
        subtitle.setAlignment(Qt.AlignCenter)
        subtitle.setStyleSheet("color: #7f8c8d; margin-bottom: 20px;")
        
        # 파일 선택 영역
        file_section = self.create_file_section()
        
        # 출력 폴더 선택 영역
        output_section = self.create_output_section()
        
        # 진행 상태
        progress_section = self.create_progress_section()
        
        # 변환 버튼
        convert_button = QPushButton("변환 시작")
        convert_button.setFont(QFont("맑은 고딕", 12, QFont.Bold))
        convert_button.setMinimumHeight(45)
        convert_button.setStyleSheet("""
            QPushButton {
                background-color: #3498db;
                color: white;
                border: none;
                border-radius: 6px;
                padding: 0px;
            }
            QPushButton:hover {
                background-color: #2980b9;
            }
            QPushButton:pressed {
                background-color: #21618c;
            }
        """)
        convert_button.clicked.connect(self.run_convert)
        
        # 상태 라벨
        self.status_label = QLabel("")
        self.status_label.setFont(QFont("맑은 고딕", 10))
        self.status_label.setAlignment(Qt.AlignCenter)
        self.status_label.setStyleSheet("color: #7f8c8d;")
        
        # 레이아웃에 추가
        main_layout.addWidget(title)
        main_layout.addWidget(subtitle)
        main_layout.addWidget(file_section)
        main_layout.addWidget(output_section)
        main_layout.addWidget(progress_section)
        main_layout.addWidget(convert_button)
        main_layout.addWidget(self.status_label)
        
        self.setLayout(main_layout)

    def create_file_section(self):
        """파일 선택 섹션"""
        frame = QFrame()
        frame.setFrameStyle(QFrame.StyledPanel)
        frame.setStyleSheet("""
            QFrame {
                background-color: #f8f9fa;
                border: 1px solid #dee2e6;
                border-radius: 8px;
                padding: 0px;
            }
        """)
        
        layout = QVBoxLayout()
        layout.setSpacing(2)
        layout.setContentsMargins(15, 15, 15, 15)
        
        # 제목
        title = QLabel("📁 Excel 파일 선택")
        title.setFont(QFont("맑은 고딕", 12, QFont.Bold))
        title.setStyleSheet("color: #2c3e50; margin-bottom: 2px;")
        
        # 파일 경로 표시
        self.file_label = QLabel("파일을 선택하세요")
        self.file_label.setFont(QFont("맑은 고딕", 10))
        self.file_label.setStyleSheet("""
            color: #6c757d;
            background-color: white;
            border: 1px solid #ced4da;
            border-radius: 4px;
            padding: 0px;
            min-height: 25px;
        """)
        
        # 선택 버튼
        select_button = QPushButton("파일 선택")
        select_button.setFont(QFont("맑은 고딕", 10))
        select_button.setStyleSheet("""
            QPushButton {
                background-color: #6c757d;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 0px;
                min-height: 30px;
            }
            QPushButton:hover {
                background-color: #5a6268;
            }
        """)
        select_button.clicked.connect(self.pick_file)
        
        layout.addWidget(title)
        layout.addWidget(self.file_label)
        layout.addWidget(select_button)
        frame.setLayout(layout)
        
        return frame

    def create_output_section(self):
        """출력 폴더 선택 섹션"""
        frame = QFrame()
        frame.setFrameStyle(QFrame.StyledPanel)
        frame.setStyleSheet("""
            QFrame {
                background-color: #f8f9fa;
                border: 1px solid #dee2e6;
                border-radius: 8px;
                padding: 0px;
            }
        """)
        
        layout = QVBoxLayout()
        layout.setSpacing(2)
        layout.setContentsMargins(15, 15, 15, 15)
        
        # 제목
        title = QLabel("📂 출력 폴더 선택")
        title.setFont(QFont("맑은 고딕", 12, QFont.Bold))
        title.setStyleSheet("color: #2c3e50; margin-bottom: 2px;")
        
        # 폴더 경로 표시
        self.output_label = QLabel("폴더를 선택하세요")
        self.output_label.setFont(QFont("맑은 고딕", 10))
        self.output_label.setStyleSheet("""
            color: #6c757d;
            background-color: white;
            border: 1px solid #ced4da;
            border-radius: 4px;
            padding: 0px;
            min-height: 25px;
        """)
        
        # 선택 버튼
        select_button = QPushButton("폴더 선택")
        select_button.setFont(QFont("맑은 고딕", 10))
        select_button.setStyleSheet("""
            QPushButton {
                background-color: #6c757d;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 0px;
                min-height: 30px;
            }
            QPushButton:hover {
                background-color: #5a6268;
            }
        """)
        select_button.clicked.connect(self.pick_output)
        
        layout.addWidget(title)
        layout.addWidget(self.output_label)
        layout.addWidget(select_button)
        frame.setLayout(layout)
        
        return frame

    def create_progress_section(self):
        """진행 상태 섹션"""
        frame = QFrame()
        frame.setFrameStyle(QFrame.StyledPanel)
        frame.setStyleSheet("""
            QFrame {
                background-color: #f8f9fa;
                border: 1px solid #dee2e6;
                border-radius: 8px;
                padding: 0px;
            }
        """)
        
        layout = QVBoxLayout()
        layout.setSpacing(2)
        layout.setContentsMargins(15, 15, 15, 15)
        
        # 제목
        title = QLabel("진행 상태")
        title.setFont(QFont("맑은 고딕", 12, QFont.Bold))
        title.setStyleSheet("color: #2c3e50; margin-bottom: 2px;")
        
        # 프로그레스 바
        self.progress = QProgressBar()
        self.progress.setTextVisible(True)
        self.progress.setStyleSheet("""
            QProgressBar {
                border: 1px solid #ced4da;
                border-radius: 4px;
                background-color: white;
                height: 25px;
                text-align: center;
                font-weight: bold;
                color: #2c3e50;
                padding: 0px;
            }
            QProgressBar::chunk {
                background-color: #3498db;
                border-radius: 3px;
                margin: 1px;
            }
        """)
        
        layout.addWidget(title)
        layout.addWidget(self.progress)
        frame.setLayout(layout)
        
        return frame

    def pick_file(self):
        """파일 선택"""
        fname, _ = QFileDialog.getOpenFileName(
            self, 
            "Excel 파일 선택", 
            "", 
            "Excel Files (*.xlsx *.xls);;All Files (*)"
        )
        if fname:
            self.file_path = fname
            filename = os.path.basename(fname)
            self.file_label.setText(f"선택됨: {filename}")
            self.file_label.setFont(QFont("맑은 고딕", 10))
            self.file_label.setStyleSheet("""
                color: #28a745;
                background-color: white;
                border: 1px solid #28a745;
                border-radius: 4px;
                padding: 0px;
                min-height: 25px;
            """)
            self.update_status("Excel 파일이 선택되었습니다")

    def pick_output(self):
        """출력 폴더 선택"""
        folder = QFileDialog.getExistingDirectory(self, "출력 폴더 선택")
        if folder:
            self.output_dir = folder
            # 절대 경로 전체 표시
            self.output_label.setText(f"선택됨: {folder}")
            self.output_label.setFont(QFont("맑은 고딕", 10))
            self.output_label.setStyleSheet("""
                color: #28a745;
                background-color: white;
                border: 1px solid #28a745;
                border-radius: 4px;
                padding: 0px;
                min-height: 25px;
            """)
            self.update_status("출력 폴더가 선택되었습니다")

    def update_status(self, message):
        """상태 메시지 업데이트"""
        self.status_label.setText(message)
        self.status_label.setFont(QFont("맑은 고딕", 10))
        self.status_label.setStyleSheet("color: #28a745; font-weight: bold;")

    def update_progress(self, value, maximum):
        """진행률 업데이트"""
        self.progress.setMaximum(maximum)
        self.progress.setValue(value)
        if value > 0:
            percentage = int((value / maximum) * 100)
            self.update_status(f"변환 중... {percentage}% 완료")

    def run_convert(self):
        """변환 실행"""
        if not self.file_path:
            QMessageBox.warning(self, "경고", "Excel 파일을 선택해주세요.")
            return
        if not self.output_dir:
            QMessageBox.warning(self, "경고", "출력 폴더를 선택해주세요.")
            return
        
        try:
            self.update_status("변환을 시작합니다...")
            self.progress.setValue(0)
            
            workbook = Workbook(self.file_path)
            sheet_count = len(workbook.worksheets)
            
            if sheet_count == 0:
                QMessageBox.warning(self, "경고", "Excel 파일에 시트가 없습니다.")
                return
            
            self.update_progress(0, sheet_count)
            combined_content = []

            for i in range(sheet_count):
                sheet = workbook.worksheets[i]
                
                # 안전한 파일명 생성
                safe_name = sheet.name
                for ch in ['<', '>', ':', '"', '/', '\\', '|', '?', '*', ' ']:
                    safe_name = safe_name.replace(ch, '_')
                
                md_filename = os.path.join(self.output_dir, f"{i+1:02d}_{safe_name}.md")
                
                # 새 워크북 생성하여 시트 복사
                new_wb = Workbook()
                new_sheet = new_wb.worksheets[0]
                new_sheet.name = sheet.name
                
                # 데이터 복사
                max_row = sheet.cells.max_data_row
                max_col = sheet.cells.max_data_column
                
                for r in range(max_row + 1):
                    for c in range(max_col + 1):
                        val = sheet.cells.get(r, c).value
                        new_sheet.cells.get(r, c).put_value(val)
                
                # 파일 저장
                new_wb.save(md_filename)
                
                # 파일 내용 읽기 및 정리
                with open(md_filename, 'r', encoding='utf-8') as f:
                    content = f.read()
                
                # Evaluation Only 라인 제거
                lines = content.splitlines()
                filtered_lines = []
                for line in lines:
                    if "# Evaluation" in line or "Evaluation Only" in line:
                        break
                    filtered_lines.append(line)
                
                content = "\n".join(filtered_lines).rstrip('\n')
                
                # 정리된 내용으로 다시 저장
                with open(md_filename, 'w', encoding='utf-8') as f:
                    f.write(content)
                
                combined_content.append(content)
                self.update_progress(i + 1, sheet_count)
                QApplication.processEvents()
            
            # 통합 파일 생성
            base_name = os.path.splitext(os.path.basename(self.file_path))[0]
            combined_filename = os.path.join(self.output_dir, f"{base_name}_ALL_COMBINED.md")
            
            with open(combined_filename, 'w', encoding='utf-8') as f:
                f.write("# Excel to Markdown 변환 결과\n\n")
                f.write("## 목차\n\n")
                
                for i in range(sheet_count):
                    sheet_name = workbook.worksheets[i].name
                    anchor_name = sheet_name.replace(' ', '-').lower()
                    f.write(f"{i+1}. [{sheet_name}](#{i+1:02d}-{anchor_name})\n")
                
                f.write("\n---\n\n")
                
                for i, content in enumerate(combined_content):
                    if content.strip():
                        sheet_name = workbook.worksheets[i].name
                        anchor_name = sheet_name.replace(' ', '-').lower()
                        f.write(f"<div id=\"{i+1:02d}-{anchor_name}\"></div>\n\n")
                        f.write(f"## {i+1:02d}. {sheet_name}\n\n")
                        f.write(content.replace(f"# {sheet_name}", ""))
                        f.write("\n\n---\n\n")
            
            self.progress.setValue(0)
            self.update_status("변환이 완료되었습니다! 🎉")
            
            QMessageBox.information(
                self, 
                "변환 완료", 
                f"모든 시트 변환이 완료되었습니다!\n\n"
                f"📊 총 {sheet_count}개 시트\n"
                f"📁 {sheet_count + 1}개 파일 생성\n"
                f"📂 출력 위치: {self.output_dir}"
            )
            
        except Exception as e:
            self.progress.setValue(0)
            self.update_status("변환 중 오류가 발생했습니다")
            QMessageBox.critical(
                self, 
                "오류", 
                f"변환 중 오류가 발생했습니다:\n\n{str(e)}"
            )


if __name__ == "__main__":
    app = QApplication(sys.argv)
    
    # Windows에서 한글 폰트 설정
    if platform.system() == "Windows":
        app.setFont(QFont("맑은 고딕", 9))
    elif platform.system() == "Darwin":  # macOS
        app.setFont(QFont("Apple SD Gothic Neo", 10))
    else:  # Linux
        app.setFont(QFont("Arial", 9))
    
    window = ExcelToMarkdownApp()
    window.show()
    sys.exit(app.exec_())

5) 빌드/배포

리소스(아이콘 폴더)를 실행 파일에 동봉해야 런타임에 접근 가능합니다.

Bash
# 리소스 포함 권장 (img 폴더)
pyinstaller --onefile --windowed --add-data "img;img" -i img\icon\excel2md_transparent.ico excel2md.py
  • 결과 파일: dist/excel2md.exe (Windows)
  • 실행 중 임시 디렉토리 예: C:\Users\{사용자}\AppData\Local\Temp\_MEIxxxxx

6) 동작 확인 체크리스트

  • 앱 타이틀/아이콘 정상 표시
  • Windows: 글꼴이 “맑은 고딕” (굴림체 아님)
  • 컴포넌트 패딩 0, 텍스트 잘 보임
  • 출력 폴더 라벨에 절대 경로 그대로 표시
  • 변환 후 개별 md + 통합 md 생성

7) 문제 해결 팁

  • PyInstaller 경고:
  • Hidden import “sip” 경고는 보통 무시 가능
  • 필요 시 –hidden-import sip 추가
  • Bash
    pyinstaller --onefile --windowed --add-data "img;img" -i img\icon\excel2md_transparent.ico excel2md.py
    # 또는 아래 (결과 용량 같음)
    pyinstaller --onefile --windowed --add-data "img;img" --hidden-import sip -i img\icon\excel2md_transparent.ico excel2md.py

    부록: 실행 파일 아이콘과 창 아이콘 차이

    • -i …ico: 실행 파일 자체의 아이콘 (탐색기에서 보임)
    • self.setWindowIcon(…): 앱 창/작업표시줄/Alt+Tab에 보이는 런타임 아이콘 둘 다 설정해야 일관됩니다.
    • 요약
      • 리소스 경로: get_resource_path() 필수
      • 폰트: OS별 한글 폰트 지정
      • UI: padding 0 + min-height + 레이아웃 마진
      • 빌드: –add-data “img;img” 꼭 포함

    소스파일

    실행파일


게시됨

카테고리

작성자

태그: