first commit

This commit is contained in:
김민성
2025-07-16 17:33:20 +09:00
commit 4b9161db45
51 changed files with 23478 additions and 0 deletions

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# 환경 변수 설정 파일
# 실제 사용 시 이 파일을 .env로 복사하고 실제 값으로 변경하세요
# Gemini API 키 (필수)
GEMINI_API_KEY=your_gemini_api_key_here
# 애플리케이션 설정
APP_TITLE=PDF 도면 분석기
APP_VERSION=1.0.0
DEBUG=False
# 파일 업로드 설정
MAX_FILE_SIZE_MB=50
ALLOWED_EXTENSIONS=pdf
UPLOAD_FOLDER=uploads
# Gemini API 설정
GEMINI_MODEL=gemini-2.5-flash
DEFAULT_PROMPT=pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.

90
.gemini/settings.json Normal file
View File

@@ -0,0 +1,90 @@
{
"mcpServers": {
"MyMCP": {
"command": "D:/MYCLAUDE_PROJECT/mcpmaker/OpenXmlMcpServer/bin/Debug/net8.0/OpenXmlMcpServer.exe",
"args": []
},
"github": {
"command": "npx",
"args": [
"-y",
"@smithery/cli@latest",
"run",
"@smithery-ai/github",
"--key",
"faeea742-0fdc-4de1-bd2b-63d6875186d1",
"--profile",
"tasty-muskox-jiXD8J"
]
},
"supabase": {
"command": "cmd",
"args": [
"/c",
"npx",
"-y",
"@supabase/mcp-server-supabase@latest",
"--access-token",
"sbp_18b8744ff5e37ae58101c29ea552f15382a7d250"
]
},
"taskmaster-ai": {
"command": "npx",
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
"env": {
"GOOGLE_API_KEY": "AIzaSyAUyWPGBkl9fxpAh1O4uIGU87I2dpgSYOg",
"MODEL": "gemini-1.5-flash",
"MAX_TOKENS": "64000",
"TEMPERATURE": "0.2",
"DEFAULT_SUBTASKS": "5",
"DEFAULT_PRIORITY": "medium"
}
},
"markitdown": {
"command": "markitdown-mcp"
},
"playwright-stealth": {
"command": "npx",
"args": ["-y", "@pvinis/playwright-stealth-mcp-server"]
},
"blender": {
"command": "uvx",
"args": ["blender-mcp"]
},
"terminal": {
"command": "npx",
"args": ["-y", "@dillip285/mcp-terminal"]
},
"googleSearch": {
"command": "npx",
"args": ["-y", "g-search-mcp"]
},
"text-editor": {
"command": "npx",
"args": ["mcp-server-text-editor"]
},
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"D:\\MYCLAUDE_PROJECT"
]
},
"context7-mcp": {
"command": "cmd",
"args": [
"/c",
"npx",
"-y",
"@smithery/cli@latest",
"run",
"@upstash/context7-mcp",
"--key",
"faeea742-0fdc-4de1-bd2b-63d6875186d1",
"--profile",
"tasty-muskox-jiXD8J"
]
}
}
}

177
.gitignore vendored Normal file
View File

@@ -0,0 +1,177 @@
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
results/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python

121
DXF_INTEGRATION_COMPLETE.md Normal file
View File

@@ -0,0 +1,121 @@
# PDF/DXF 통합 분석기 - DXF 지원 기능 통합 완료 보고서
## 📋 프로젝트 개요
- **프로젝트명**: Flet 기반 PDF/DXF 도면 분석기
- **완료일**: 2025-07-09
- **진행률**: 100% (DXF 지원 기능 통합 완료)
## 🎯 구현된 핵심 기능
### 1. 멀티 파일 포맷 지원
- **PDF 분석**: Gemini API를 통한 이미지 기반 도면 분석
- **DXF 분석**: ezdxf 라이브러리를 통한 도곽(Title Block) 정보 추출
- **자동 파일 타입 감지**: 확장자에 따른 적절한 분석 방법 자동 선택
### 2. 통합된 사용자 인터페이스
- **좌우 분할 레이아웃**: 좌측 설정 패널, 우측 결과 표시
- **반응형 디자인**: ResponsiveRow를 활용한 화면 크기별 최적화
- **파일 타입별 UI**: PDF 미리보기, DXF 정보 표시 등 차별화된 인터페이스
### 3. DXF 분석 기능
- **블록 참조 추출**: Block Reference와 Attribute Reference 분석
- **도곽 정보 추출**: 건설분야, 건설단계, 도면명, 축척, 도면번호 등
- **좌표 정보**: Text Bounding Box 및 최외곽 경계 계산
- **요약 정보**: 전체 블록 수, 속성 수 등 통계 정보
## 🔧 기술적 성과
### 수정된 파일들
1. **main.py** - 핵심 통합 작업
- `DocumentAnalyzerApp` 클래스로 통일
- `on_file_selected` 메서드 PDF/DXF 지원으로 완전 교체
- `run_analysis` 메서드 파일 타입별 분석으로 분할
- DXF 분석 결과 표시 기능 추가
- 변수명 통일 (`current_file_path`, `current_file_type`)
2. **dxf_support_methods.py** - 지원 메서드 설계
- PDF/DXF 파일 선택 처리 로직
- DXF 분석 실행 및 결과 표시 메서드
- 파일 상태 초기화 및 오류 처리
3. **project_plan.md** - 진행 상황 업데이트
- 단계 11 DXF 지원 기능 100% 완료
- 최종 진행률 100% 달성
- 상세한 구현 내용 기록
### 연구 및 검증
- **20개 이상 웹사이트 심층 연구**: Flet, ezdxf, CAD 분석 최신 기술
- **기술적 검증**: FilePicker 다중 파일 타입, DXF 처리, UI 패턴 연구
- **모범 사례 적용**: 최신 Flet 기능 및 ezdxf 라이브러리 활용
## 🚀 사용법
### 1. 환경 설정
```bash
# 의존성 설치
pip install -r requirements.txt
# 환경 변수 설정 (.env 파일)
GEMINI_API_KEY=your_gemini_api_key_here
```
### 2. 애플리케이션 실행
```bash
# 기본 실행
python main.py
# 임포트 테스트 (권장)
python test_imports.py
```
### 3. 파일 분석 과정
1. **파일 선택**: 좌측 패널에서 PDF 또는 DXF 파일 선택
2. **분석 설정**: 조직 스키마, 페이지 선택, 분석 모드 설정
3. **분석 실행**: "🚀 분석 시작" 버튼 클릭
4. **결과 확인**: 우측 패널에서 분석 결과 확인
5. **결과 저장**: 텍스트 또는 JSON 형식으로 저장
### 4. 지원 파일 형식
- **PDF**: `.pdf` (Gemini API 이미지 분석)
- **DXF**: `.dxf` (ezdxf 도곽 정보 추출)
## 📊 분석 결과 예시
### PDF 분석 결과
- 문서 유형 및 주요 내용
- 도면/도표 정보
- 텍스트 내용 추출
- 조직별 스키마 적용 (국토교통부/한국도로공사)
### DXF 분석 결과
- 전체 블록 수 및 속성 정보
- 도곽 블록 식별 및 정보 추출
- 건설 관련 필드 (도면명, 도면번호, 건설분야 등)
- 좌표 및 크기 정보
- 블록 참조 목록
## 🎉 프로젝트 완료 요약
### 달성된 목표
- ✅ PDF와 DXF 파일 통합 분석 시스템 구축
- ✅ 사용자 친화적인 Flet 기반 GUI 구현
- ✅ 파일 타입별 최적화된 분석 방법 제공
- ✅ 결과 저장 및 내보내기 기능
- ✅ 모듈화된 코드 구조로 유지보수성 확보
### 기술적 혁신
- **멀티 포맷 지원**: 단일 애플리케이션에서 AI 기반 PDF 분석과 구조적 DXF 분석
- **자동화된 워크플로**: 파일 타입 감지부터 결과 표시까지 완전 자동화
- **확장 가능한 아키텍처**: 새로운 파일 형식이나 분석 방법 쉽게 추가 가능
### 향후 활용 방안
- 건설/건축 업계 도면 분석 자동화
- CAD 파일 메타데이터 추출 및 관리
- AI 기반 도면 내용 분석 및 분류
- 대용량 도면 파일 일괄 처리 시스템
---
**✨ PDF/DXF 통합 분석기 개발 완료! ✨**
이제 사용자는 하나의 애플리케이션에서 PDF와 DXF 파일을 모두 분석할 수 있으며, 각 파일 형식에 최적화된 분석 결과를 얻을 수 있습니다.

405
GEMINI.md Normal file
View File

@@ -0,0 +1,405 @@
# Flet 기반 PDF 입력 및 Gemini API 이미지 분석 UI 프로젝트 계획
## 1. 프로젝트 목표
Flet 프레임워크를 사용하여 사용자가 PDF 및 DXF 파일을 업로드하고, 다음과 같은 분석을 수행하는 애플리케이션을 개발합니다:
- **PDF 파일**: Gemini API를 통한 이미지 분석
- **DXF 파일**: ezdxf 라이브러리를 통한 도곽(Title Block) 정보 추출 및 Block Reference/Attribute Reference 분석
## 2. 기술 스택
- **UI 프레임워크**: Flet v0.25.1+
- **API 연동**: Google Gemini API (Python SDK - google-genai v1.0+)
- **PDF 처리**: PyMuPDF v1.26.3+ 또는 pdf2image v1.17.0+
- **DXF 처리**: ezdxf v1.4.2+ (CAD 도면 파일 처리)
- **데이터 인코딩**: base64 (Python 내장 라이브러리)
- **환경 변수 관리**: python-dotenv v1.0.0+
- **UI 디자인**: Flet Material Library (선택 사항)
- **좌표 계산**: numpy v1.24.0+ (Bounding Box 계산)
## 3. 프로젝트 구조
```
fletimageanalysis/
├── main.py # Flet UI 메인 애플리케이션
├── gemini_analyzer.py # Gemini API 연동 모듈
├── pdf_processor.py # PDF 처리 모듈
├── dxf_processor.py # DXF 처리 모듈 (NEW)
├── ui_components.py # UI 컴포넌트 모듈
├── config.py # 설정 관리 모듈
├── requirements.txt # 프로젝트 의존성 목록
├── .env # 환경 변수 파일 (API 키 등)
├── uploads/ # 업로드된 파일 저장 폴더
├── assets/ # 애플리케이션 자산
└── docs/ # 문서화 파일
```
## 4. 주요 기능 및 UI 구성
### 4.1 메인 UI 구성
- **헤더**: 애플리케이션 제목 및 로고
- **파일 업로드 영역**: PDF 파일 선택 버튼 및 파일 정보 표시
- **분석 설정 영역**: 분석 옵션 설정 (페이지 선택, 분석 모드 등)
- **분석 버튼**: 분석 시작 버튼
- **결과 표시 영역**: 분석 결과 및 PDF 미리보기
- **상태 표시줄**: 진행 상태 및 오류 메시지
### 4.2 핵심 기능
**PDF 분석 기능:**
- PDF 파일 업로드 및 검증
- PDF 페이지 이미지 변환
- Gemini API를 통한 이미지 분석
**DXF 분석 기능 (NEW):**
- DXF 파일 업로드 및 검증
- Block Reference 추출 및 분석
- Attribute Reference에서 도곽 정보 추출
- 도곽 위치, 배치, 크기 정보 추출
- Text Bounding Box 좌표 계산
**공통 기능:**
- 분석 결과 실시간 표시
- 분석 진행률 표시
- 오류 처리 및 사용자 피드백
## 5. 개발 단계 및 진행 상황
### 단계 1: 프로젝트 초기 설정 ✅
- [x] 프로젝트 폴더 구조 생성
- [x] project_plan.md 작성
- [x] requirements.txt 작성
- [x] 기본 설정 파일 구성 (.env.example, config.py)
- [x] 업로드/자산 폴더 생성
### 단계 2: 핵심 모듈 구현 ✅
- [x] PDF 처리 모듈 (pdf_processor.py) 구현
- [x] Gemini API 연동 모듈 (gemini_analyzer.py) 구현
- [x] UI 컴포넌트 모듈 (ui_components.py) 구현
- [x] 메인 애플리케이션 (main.py) 구현
### 단계 3: 기본 기능 구현 ✅
- [x] PDF 파일 읽기 및 검증
- [x] PDF 페이지 이미지 변환 (PyMuPDF)
- [x] Base64 인코딩 처리
- [x] Gemini API 클라이언트 구성
- [x] 이미지 분석 요청 처리
- [x] API 응답 처리 및 파싱
### 단계 4: UI 구현 ✅
- [x] 메인 애플리케이션 레이아웃 설계
- [x] 파일 업로드 UI 구현
- [x] 분석 설정 UI 구현
- [x] 진행률 표시 UI 구현
- [x] 결과 표시 UI 구현
- [x] PDF 미리보기 UI 구현
### 단계 5: 통합 및 이벤트 처리 ✅
- [x] 파일 업로드 이벤트 처리
- [x] 분석 진행률 표시
- [x] 결과 표시 기능
- [x] 오류 처리 및 사용자 알림
- [x] 백그라운드 스레드 처리
### 단계 6: 고급 기능 구현 ✅
- [x] PDF 미리보기 기능 (advanced_features.py)
- [x] 분석 결과 저장 기능 (텍스트/JSON)
- [x] 고급 설정 관리 (AdvancedSettings)
- [x] 오류 처리 및 로깅 시스템 (ErrorHandler)
- [x] 분석 히스토리 관리 (AnalysisHistory)
- [x] 사용자 정의 프롬프트 관리 (CustomPromptManager)
### 단계 7: 문서화 및 테스트 ✅
- [x] README.md 작성 (상세한 사용법 및 설치 가이드)
- [x] 사용자 가이드 (docs/user_guide.md)
- [x] 개발자 가이드 (docs/developer_guide.md)
- [x] 테스트 스크립트 (test_project.py)
- [x] 설치 스크립트 (setup.py)
- [x] 라이선스 파일 (LICENSE - MIT)
### 단계 8: 고급 기능 확장 ✅ (NEW)
- [x] 조직별 스키마 선택 기능 구현
- [x] 국토교통부/한국도로공사 전용 스키마 적용
- [x] UI 조직 선택 드롭다운 추가
- [x] Gemini API 스키마 매개변수 동적 전달
- [x] 조직별 분석 결과 차별화
### 단계 9: 최종 최적화 및 배포 준비 ✅
- [x] 코드 정리 및 최적화
- [x] 오류 처리 강화
- [x] 사용자 경험 개선
- [x] 최종 테스트 및 검증
### 단계 10: UI 레이아웃 개선 ✅
- [x] 좌우 분할 레이아웃으로 UI 재구성
- [x] ResponsiveRow를 활용한 반응형 디자인 적용
- [x] 좌측: 파일 업로드 + 분석 설정 + 진행률 + 분석 시작 버튼
- [x] 우측: 분석 결과 표시 영역 확장
- [x] PDF 뷰어를 별도 모달 창으로 분리
- [x] AlertDialog를 사용한 PDF 미리보기 기능 구현
- [x] 페이지 네비게이션 기능 추가 (이전/다음 페이지)
- [x] pdf_processor.py에 이미지 바이트 변환 메서드 추가
- [x] 기존 UI와 새 UI 백업 및 교체 완료
### 단계 11: DXF 파일 지원 추가 ✅ (COMPLETED)
- [x] DXF 파일 형식 지원 추가 (.dxf 확장자)
- [x] ezdxf 라이브러리 설치 및 설정 (requirements.txt 업데이트)
- [x] DXF 파일 업로드 및 검증 기능 (dxf_processor.py 완성)
- [x] Block Reference 추출 모듈 구현 (dxf_processor.py)
- [x] Attribute Reference 분석 모듈 구현 (dxf_processor.py)
- [x] 도곽 정보 추출 로직 구현 (dxf_processor.py)
- [x] Bounding Box 계산 기능 구현 (dxf_processor.py)
- [x] config.py DXF 지원 설정 추가
- [x] ui_components.py DXF 지원 텍스트 업데이트
- [x] main.py 기본 DXF 지원 구조 수정 (import, 클래스명)
- [x] DXF 지원 메서드들 설계 및 구현 (dxf_support_methods.py)
- [x] main.py에 DXF 지원 메서드들 완전 통합
- [x] 파일 선택 로직 DXF 지원으로 완전 업데이트
- [x] DXF 분석 결과 UI 통합
- [x] 파일 타입별 분석 방법 자동 선택
- [x] DocumentAnalyzerApp 클래스명 통일
- [x] 변수명 통일 (current_file_path, current_file_type)
- [x] 좌우 분할 레이아웃으로 UI 재구성
- [x] ResponsiveRow를 활용한 반응형 디자인 적용
- [x] 좌측: 파일 업로드 + 분석 설정 + 진행률 + 분석 시작 버튼
- [x] 우측: 분석 결과 표시 영역 확장
- [x] PDF 뷰어를 별도 모달 창으로 분리
- [x] AlertDialog를 사용한 PDF 미리보기 기능 구현
- [x] 페이지 네비게이션 기능 추가 (이전/다음 페이지)
- [x] pdf_processor.py에 이미지 바이트 변환 메서드 추가
- [x] 기존 UI와 새 UI 백업 및 교체 완료
## 6. 연구된 웹사이트 (70+개)
### 6.1 Flet 프레임워크 관련 (12개)
1. Flet 공식 문서 - https://flet.dev/docs/
2. Flet GitHub - https://github.com/flet-dev/flet
3. Flet 드롭다운 컴포넌트 - https://flet.dev/docs/controls/dropdown/
4. Flet 컨트롤 참조 - https://flet.dev/docs/controls/
5. Flet FilePicker 문서 - https://flet.dev/docs/controls/filepicker/
6. Flet 2024 개발 동향 - DEV Community
7. Flet 초보자 가이드 - DEV Community
8. Flet 예제 코드 - https://github.com/flet-dev/examples
9. Flet UI 컴포넌트 라이브러리 - Gumroad
10. Flet 개발 토론 - GitHub Discussions
11. Flet 소개 및 특징 - Analytics Vidhya
12. Talk Python 팟캐스트 Flet 업데이트 - TalkPython.fm
### 6.2 Gemini API 및 구조화된 출력 (10개)
13. Gemini API Structured Output - https://ai.google.dev/gemini-api/docs/structured-output
14. Firebase Vertex AI Structured Output - Firebase 문서
15. Google Gen AI SDK - https://googleapis.github.io/python-genai/
16. Gemini JSON 모드 - Google Cloud 커뮤니티 Medium
17. Vertex AI Structured Output - Google Cloud 문서
18. Gemini API 퀵스타트 - Google AI 개발자
19. Gemini API 참조 - Google AI 개발자
20. Google Developers Blog 제어된 생성 - Google 개발자 블로그
21. Controlled Generation 매거진 - tanaikech GitHub
22. Gemini API JSON 구조화 가이드 - Medium
### 6.3 PDF 처리 라이브러리 비교 (8개)
23. PyMuPDF 성능 비교 - PyMuPDF 문서
24. Python PDF 라이브러리 비교 2024 - Pythonify
25. PDF 에코시스템 2023/2024 - Medium
26. PyMuPDF vs pdf2image - GitHub 토론
27. PDF 텍스트 추출 도구 평가 - Unstract
28. Python PDF 라이브러리 성능 벤치마크 - GitHub
29. PDF 처리 방법 비교 - Medium
30. Python PDF 라이브러리 비교 - IronPDF
### 6.4 한국 건설/교통 표준 (8개)
31. 국토교통부 (MOLIT) 공식사이트 - https://www.molit.go.kr/
32. 한국 건설표준센터 - KCSC
33. 한국 건설 표준 용어집 - SPACE Magazine
34. 국제 건설 코드 한국 - ICC
35. 한국 건설 안전 규정 - CAPA
36. 건설 도면 표준 번호 체계 - Archtoolbox
37. 건설 문서 가이드 - Monograph
38. 미국 건설 도면 규격 - Acquisition.gov
### 6.5 DXF 파일 처리 및 ezdxf 라이브러리 (20개)
39. ezdxf 공식 문서 - https://ezdxf.readthedocs.io/en/stable/
40. ezdxf PyPI 패키지 - https://pypi.org/project/ezdxf/
41. ezdxf GitHub 리포지토리 - https://github.com/mozman/ezdxf
42. ezdxf 블록 튜토리얼 - Block Management Documentation
43. ezdxf 데이터 추출 튜토리얼 - Getting Data Tutorial
44. ezdxf DXF 엔티티 문서 - DXF Entities Documentation
45. Stack Overflow - ezdxf Block Reference 추출
46. Stack Overflow - DXF 텍스트 추출 방법
47. Stack Overflow - ezdxf Attribute Reference 처리
48. ezdxf Block Management Structures
49. ezdxf DXF Tags 문서
50. AutoCAD DXF Reference - Autodesk
51. FileFormat.com - ezdxf 라이브러리 가이드
52. ezdxf Usage for Beginners
53. ezdxf Quick-Info 문서
54. GitHub - ezdxf 포크 프로젝트들
55. PyDigger - ezdxf 패키지 정보
56. GDAL AutoCAD DXF 드라이버 문서
57. FME Support - AutoCAD DWG Block Attribute 추출
58. ezdxf MText 문서
### 6.6 CAD 도면 분석 및 AI 기반 처리 (12개)
59. 엔지니어링 도면 데이터 추출 - Infrrd.ai
60. werk24 PyPI - AI 기반 기술 도면 분석
61. ResearchGate - 도면 제목 블록 정보 추출 연구
62. ScienceDirect - 엔지니어링 도면에서 치수 요구사항 추출
63. Medium - TensorFlow, Keras-OCR, OpenCV를 이용한 기술 도면 정보 추출
64. GitHub - 엔지니어링 도면 추출기 (Bakkopi)
65. Werk24 - 기술 도면 특징 추출 API
66. Stack Overflow - PyPDF2 엔지니어링 도면 파싱
67. BusinesswareTech - 기술 도면 데이터 추출 AI 솔루션
68. Stack Overflow - OCR을 이용한 CAD 기술 도면 특정 데이터 추출
69. Autodesk Forums - 제목 블록/텍스트 속성 문제
70. AutoCAD DXF 공식 레퍼런스 문서
31. 국토교통부 (MOLIT) 공식사이트 - https://www.molit.go.kr/
32. 한국 건설표준센터 - KCSC
33. 한국 건설 표준 용어집 - SPACE Magazine
34. 국제 건설 코드 한국 - ICC
35. 한국 건설 안전 규정 - CAPA
36. 건설 도면 표준 번호 체계 - Archtoolbox
37. 건설 문서 가이드 - Monograph
38. 미국 건설 도면 규격 - Acquisition.gov
## 7. 개발 일정 (예상 8주)
- **1-2주차**: 프로젝트 설정 및 기본 UI 구성
- **3-4주차**: PDF 처리 및 Gemini API 연동
- **5-6주차**: UI/백엔드 연동 및 핵심 기능 구현
- **7-8주차**: 고급 기능, 테스트 및 최적화
## 8. 고려 사항
- **보안**: API 키 안전한 관리 (.env 파일 사용)
- **성능**: 대용량 PDF 파일 처리 최적화
- **사용자 경험**: 직관적인 UI 및 명확한 피드백
- **오류 처리**: 포괄적인 예외 처리 및 사용자 알림
- **호환성**: 다양한 운영체제에서의 동작 확인
## 9. 버전 관리
- Python: 3.9+
- Flet: 0.25.1+
- google-genai: 1.0+
- PyMuPDF: 1.26.3+ 또는 pdf2image: 1.17.0+
- ezdxf: 1.4.2+ (NEW - DXF 파일 처리)
- numpy: 1.24.0+ (NEW - 좌표 계산)
- python-dotenv: 1.0.0+
## 10. 다음 단계
1. requirements.txt 파일 작성
2. 기본 프로젝트 구조 생성
3. 메인 애플리케이션 뼈대 구현
4. PDF 처리 모듈 구현
5. Gemini API 연동 모듈 구현
---
**최종 업데이트**: 2025-07-09
**현재 진행률**: 100% (DXF 파일 지원 기능 통합 완료)
## 12. 최근 업데이트 (2025-07-09)
### 12.1 새로 구현된 기능
1. **조직별 스키마 선택 시스템**
- 국토교통부: 일반 토목/건설 도면 표준 스키마
- 한국도로공사: 고속도로 전용 도면 스키마
- UI에서 드롭다운으로 선택 가능
2. **gemini_analyzer.py 확장**
- `organization_type` 매개변수 추가
- 동적 스키마 선택 로직 구현
- `schema_transportation`, `schema_expressway` 분리
3. **UI 컴포넌트 개선**
- `create_analysis_settings_section_with_refs()` 함수 추가
- 조직별 설명 텍스트 포함
- 직관적인 선택 인터페이스 제공
4. **main.py 통합**
- 조직 선택 이벤트 핸들러 추가
- 분석 시 선택된 조직 유형 전달
- 결과에 조직 정보 포함
### 12.2 기술적 개선사항
- 30개 이상 웹사이트 심층 연구 완료
- Flet 최신 드롭다운 API 활용
- Gemini API Structured Output 최신 기능 적용
- 한국 건설 표준 및 도로공사 규격 조사
### 12.3 UI 레이아웃 개선 세부사항 (2025-07-09)
- 창 크기 조정: 1400x900 기본, 최소 1200x800
- ResponsiveRow 반응형 브레이크포인트: sm(12), md(5/7), lg(4/8)
- PDF 뷰어 모달: 650x750 크기, 이미지 600x700 컴테이너
- 50개 이상의 웹사이트 연구로 Flet 최신 기능 적용
### 12.4 DXF 파일 지원 구현 완료 (2025-07-09) ✅
**완성된 기능:**
1. **파일 형식 확장**: requirements.txt에 ezdxf v1.4.2+, numpy v1.24.0+ 추가
2. **DXF 처리 모듈**: dxf_processor.py 완전 구현
- Block Reference 추출 및 분석
- Attribute Reference에서 도곽 정보 추출
- 도곽 식별 로직 (건설분야, 건설단계, 도면명, 축척, 도면번호)
- Text Bounding Box 좌표 계산
- 최외곽 Bounding Box 계산
3. **설정 업데이트**: config.py에 DXF 파일 지원 추가
4. **UI 업데이트**: ui_components.py에 PDF/DXF 지원 텍스트 추가
5. **메인 애플리케이션**: main.py 기본 구조 수정 및 DXF 지원 메서드 설계
6. **지원 메서드**: dxf_support_methods.py에 다음 기능 구현
- DXF 파일 선택 처리
- DXF 분석 실행 로직
- DXF 결과 표시 UI
**기술적 세부사항:**
- ezdxf 라이브러리를 통한 DXF 파일 파싱
- Block Reference 순회 및 Attribute Reference 추출
- 도곽 식별을 위한 키워드 매칭 알고리즘
- numpy를 이용한 좌표 계산 및 Bounding Box 처리
- 데이터클래스를 통한 구조화된 데이터 처리
- 포괄적인 오류 처리 및 로깅 시스템
**다음 단계 (완료):**
1. ✅ main.py에 dxf_support_methods.py의 메서드들 통합 완료
2. ✅ 파일 선택 로직 DXF 지원으로 완전 업데이트 완료
3. ✅ DXF 분석 결과 UI 통합 및 테스트 완료
4. ✅ 전체 기능 통합 및 테스트 완료
### 12.6 DXF 지원 기능 통합 완료 (2025-07-09) ✅
**완성된 통합 작업:**
1. **파일 선택기 확장**: PDF와 DXF 파일 확장자 모두 지원
2. **파일 선택 로직 업데이트**:
- `on_file_selected` 메서드를 PDF/DXF 파일 타입 자동 감지로 완전 교체
- `_handle_pdf_file_selection``_handle_dxf_file_selection` 메서드 추가
- `_reset_file_state` 메서드로 파일 상태 초기화
3. **분석 실행 로직 확장**:
- `run_analysis` 메서드를 PDF/DXF 타입별 분석으로 완전 교체
- `_run_pdf_analysis``_run_dxf_analysis` 메서드 분리
- `display_dxf_analysis_results` 메서드 추가
4. **UI 통합**:
- 파일 타입별 미리보기 활성화/비활성화
- DXF 분석 결과 전용 UI 표시
- 파일 정보 표시 개선 (PDF/DXF 구분)
5. **변수명 통일**:
- `current_pdf_path``current_file_path`
- `current_file_type` 변수로 PDF/DXF 구분
- `DocumentAnalyzerApp` 클래스명 통일
6. **저장 기능 확장**: PDF와 DXF 분석 결과 모두 지원
**기술적 성과:**
- 단일 애플리케이션에서 PDF(Gemini API)와 DXF(ezdxf) 분석 완전 지원
- 파일 타입 자동 감지 및 적절한 분석 방법 선택
- 20개 이상의 웹사이트 연구를 통한 최신 기술 적용
- Flet 프레임워크의 FilePicker 최신 기능 활용
- 모듈화된 코드 구조로 유지보수성 향상
### 12.7 프로젝트 완료
- ✅ PDF/DXF 통합 문서 분석기 완전 구현
- ✅ 모든 핵심 기능 동작 확인
- ✅ 사용자 인터페이스 최종 완성
- ✅ 프로젝트 목표 100% 달성
## 11. 구현 완료된 파일들
-`config.py` - 환경 변수 및 설정 관리
-`pdf_processor.py` - PDF 처리 및 이미지 변환
-`gemini_analyzer.py` - Gemini API 연동 (조직별 스키마 지원)
-`ui_components.py` - UI 컴포넌트 정의 (조직 선택 기능 포함)
-`main.py` - 메인 애플리케이션 (조직별 분석 통합)
-`requirements.txt` - 의존성 목록
-`.env.example` - 환경 변수 템플릿
-`advanced_features.py` - 고급 기능 모듈
-`utils.py` - 유틸리티 함수들
-`test_project.py` - 테스트 스크립트

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 PDF Drawing Analyzer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

266
README.md Normal file
View File

@@ -0,0 +1,266 @@
# PDF/DXF 문서 분석기
Flet 기반의 PDF 및 DXF 파일 업로드 및 분석 애플리케이션입니다. PDF 파일은 Google Gemini AI를 통해 이미지 분석을, DXF 파일은 ezdxf 라이브러리를 통해 도곽 정보 및 Block Reference/Attribute Reference를 추출하여 상세한 정보를 제공합니다.
![Python](https://img.shields.io/badge/Python-3.9+-blue.svg)
![Flet](https://img.shields.io/badge/Flet-0.25.1+-orange.svg)
![ezdxf](https://img.shields.io/badge/ezdxf-1.4.2+-red.svg)
![License](https://img.shields.io/badge/License-MIT-green.svg)
## 🌟 주요 기능
### PDF 분석 기능
- 📄 **PDF 파일 업로드**: 간편한 드래그 앤 드롭 인터페이스
- 🔍 **AI 이미지 분석**: Google Gemini API를 통한 고급 이미지 분석
- 🏢 **조직별 스키마**: 국토교통부/한국도로공사 전용 분석 스키마
- 👁️ **PDF 뷰어 모달**: 별도 창에서 PDF 미리보기 및 페이지 네비게이션
### DXF 분석 기능 (NEW)
- 🏗️ **DXF 파일 지원**: CAD 도면 파일 (.dxf) 업로드 및 분석
- 📐 **도곽 정보 추출**: 도면명, 도면번호, 건설분야, 건설단계, 축척 등
- 🔧 **Block Reference 분석**: 블록 참조 및 속성 정보 완전 추출
- 📋 **Attribute Reference**: 모든 속성의 tag, text, prompt, position, bounding box 정보
- 📏 **바운딩 박스 계산**: 텍스트 및 블록의 정확한 좌표 정보
- 🎯 **ATTDEF 정보 수집**: 블록 정의에서 프롬프트 정보 자동 매핑
### 공통 기능
- 📊 **실시간 진행률**: 분석 과정을 실시간으로 확인
- 🎨 **현대적인 UI**: 좌우 분할 레이아웃 및 Material Design 기반 인터페이스
- ⚙️ **다양한 분석 모드**: 기본, 상세, 사용자 정의 분석
- 💾 **결과 저장**: 분석 결과를 텍스트/JSON 파일로 저장
- 📱 **반응형 디자인**: 다양한 화면 크기에 대응하는 인터페이스
## 🚀 빠른 시작
### 1. 요구 사항
- Python 3.9 이상
- Google Gemini API 키
### 2. 설치
```bash
# 저장소 클론
git clone https://github.com/your-username/pdf-analyzer.git
cd pdf-analyzer
# 가상 환경 생성 (권장)
python -m venv venv
# 가상 환경 활성화
# Windows:
venv\\Scripts\\activate
# macOS/Linux:
source venv/bin/activate
# 의존성 설치
pip install -r requirements.txt
```
### 3. 환경 설정
1. `.env.example` 파일을 `.env`로 복사:
```bash
copy .env.example .env # Windows
cp .env.example .env # macOS/Linux
```
2. `.env` 파일을 편집하여 Gemini API 키 설정:
```env
GEMINI_API_KEY=your_actual_gemini_api_key_here
```
### 4. 실행
```bash
python main.py
```
## 🛠️ 설정
### 환경 변수
`.env` 파일에서 다음 설정을 조정할 수 있습니다:
```env
# 필수: Gemini API 키
GEMINI_API_KEY=your_gemini_api_key
# 애플리케이션 설정
APP_TITLE=PDF 도면 분석기
APP_VERSION=1.0.0
DEBUG=False
# 파일 업로드 설정
MAX_FILE_SIZE_MB=50
ALLOWED_EXTENSIONS=pdf
UPLOAD_FOLDER=uploads
# Gemini API 설정
GEMINI_MODEL=gemini-2.5-pro
DEFAULT_PROMPT=pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.
```
### Gemini API 키 획득
1. [Google AI Studio](https://makersuite.google.com/app/apikey)에 접속
2. Google 계정으로 로그인
3. "Create API Key" 클릭
4. 생성된 API 키를 `.env` 파일에 추가
## 📖 사용법
### 기본 사용법
1. **PDF 파일 선택**: "PDF 파일 선택" 버튼을 클릭하여 분석할 PDF 파일을 선택합니다.
2. **분석 설정**:
- **페이지 선택**: 첫 번째 페이지만 또는 모든 페이지 분석 선택
- **분석 모드**: 기본, 상세, 사용자 정의 중 선택
3. **분석 시작**: "분석 시작" 버튼을 클릭하여 AI 분석을 시작합니다.
4. **결과 확인**: 분석 완료 후 결과를 확인하고 필요시 저장합니다.
### 분석 모드
- **기본 분석**: 문서 유형 및 기본 정보 분석
- **상세 분석**: 도면, 도표, 텍스트 등 상세 정보 분석
- **사용자 정의**: 원하는 분석 내용을 직접 입력
## 🏗️ 프로젝트 구조
```
fletimageanalysis/
├── main.py # 메인 애플리케이션
├── config.py # 설정 관리
├── pdf_processor.py # PDF 처리 모듈
├── gemini_analyzer.py # Gemini API 연동
├── ui_components.py # UI 컴포넌트
├── requirements.txt # 의존성 목록
├── .env.example # 환경 변수 템플릿
├── uploads/ # 업로드 폴더
├── assets/ # 자산 폴더
└── docs/ # 문서 폴더
```
## 🔧 개발
### 개발 환경 설정
```bash
# 개발용 의존성 설치
pip install black flake8 pytest
# 코드 포맷팅
black .
# 코드 검사
flake8 .
# 테스트 실행
pytest
```
### 모듈 설명
#### `pdf_processor.py`
- PDF 파일 검증 및 정보 추출
- PDF 페이지를 이미지로 변환
- Base64 인코딩 처리
#### `gemini_analyzer.py`
- Gemini API 클라이언트 관리
- 이미지 분석 요청 및 응답 처리
- 스트리밍 분석 지원
#### `ui_components.py`
- Flet UI 컴포넌트 정의
- 재사용 가능한 UI 요소들
- Material Design 스타일 적용
#### `main.py`
- 메인 애플리케이션 로직
- 이벤트 처리 및 UI 통합
- 백그라운드 작업 관리
## 🐛 문제 해결
### 일반적인 문제들
**1. API 키 오류**
```
오류: Gemini API 키가 설정되지 않았습니다.
해결: .env 파일에 올바른 GEMINI_API_KEY를 설정하세요.
```
**2. PDF 파일 오류**
```
오류: 유효하지 않은 PDF 파일입니다.
해결: 손상되지 않은 PDF 파일을 사용하거나 다른 PDF로 시도하세요.
```
**3. 의존성 설치 오류**
```bash
# PyMuPDF 설치 문제가 있을 경우
pip install --upgrade pip
pip install PyMuPDF --no-cache-dir
```
**4. 메모리 부족 오류**
```
해결: 큰 PDF 파일의 경우 첫 번째 페이지만 분석하거나
zoom 값을 낮춰서 이미지 크기를 줄이세요.
```
### 로그 확인
애플리케이션 실행 시 콘솔에서 상세한 로그를 확인할 수 있습니다:
```bash
python main.py 2>&1 | tee app.log
```
## 🤝 기여하기
1. 이 저장소를 포크합니다
2. 기능 브랜치를 생성합니다 (`git checkout -b feature/AmazingFeature`)
3. 변경사항을 커밋합니다 (`git commit -m 'Add some AmazingFeature'`)
4. 브랜치에 푸시합니다 (`git push origin feature/AmazingFeature`)
5. Pull Request를 생성합니다
## 📝 라이선스
이 프로젝트는 MIT 라이선스 하에 배포됩니다. 자세한 내용은 [LICENSE](LICENSE) 파일을 참조하세요.
## 🙏 감사의 말
- [Flet](https://flet.dev/) - 뛰어난 Python UI 프레임워크
- [Google Gemini](https://ai.google.dev/) - 강력한 AI 분석 API
- [PyMuPDF](https://pymupdf.readthedocs.io/) - PDF 처리 라이브러리
## 📞 지원
문제가 있거나 질문이 있으시면 [Issues](https://github.com/your-username/pdf-analyzer/issues) 페이지에서 이슈를 생성해 주세요.
---
**🔗 관련 링크**
- [Flet 문서](https://flet.dev/docs/)
- [Gemini API 문서](https://ai.google.dev/gemini-api/docs)
- [PyMuPDF 문서](https://pymupdf.readthedocs.io/)

428
advanced_features.py Normal file
View File

@@ -0,0 +1,428 @@
"""
고급 기능 모듈
PDF 미리보기, 고급 설정, 확장 기능들을 제공합니다.
"""
import base64
import io
import json
from pathlib import Path
from typing import Optional, List, Dict, Any
import logging
from PIL import Image, ImageDraw, ImageFont
import flet as ft
logger = logging.getLogger(__name__)
class PDFPreviewGenerator:
"""PDF 미리보기 생성 클래스"""
def __init__(self, pdf_processor):
self.pdf_processor = pdf_processor
self.preview_cache = {}
def generate_preview(
self,
file_path: str,
page_number: int = 0,
max_size: tuple = (300, 400)
) -> Optional[str]:
"""PDF 페이지 미리보기 이미지 생성 (base64 반환)"""
try:
cache_key = f"{file_path}_{page_number}_{max_size}"
# 캐시 확인
if cache_key in self.preview_cache:
return self.preview_cache[cache_key]
# PDF 페이지를 이미지로 변환
image = self.pdf_processor.convert_pdf_page_to_image(
file_path, page_number, zoom=1.0
)
if not image:
return None
# 미리보기 크기로 조정
preview_image = self._resize_for_preview(image, max_size)
# base64로 인코딩
buffer = io.BytesIO()
preview_image.save(buffer, format='PNG')
base64_data = base64.b64encode(buffer.getvalue()).decode()
# 캐시에 저장
self.preview_cache[cache_key] = base64_data
logger.info(f"PDF 미리보기 생성 완료: 페이지 {page_number + 1}")
return base64_data
except Exception as e:
logger.error(f"미리보기 생성 실패: {e}")
return None
def _resize_for_preview(self, image: Image.Image, max_size: tuple) -> Image.Image:
"""이미지를 미리보기 크기로 조정"""
# 비율 유지하면서 크기 조정
image.thumbnail(max_size, Image.Resampling.LANCZOS)
# 캔버스 생성 (회색 배경)
canvas = Image.new('RGB', max_size, color='#f0f0f0')
# 이미지를 중앙에 배치
x = (max_size[0] - image.width) // 2
y = (max_size[1] - image.height) // 2
canvas.paste(image, (x, y))
return canvas
def clear_cache(self):
"""캐시 정리"""
self.preview_cache.clear()
logger.info("미리보기 캐시 정리 완료")
class AdvancedSettings:
"""고급 설정 관리 클래스"""
def __init__(self, settings_file: str = "settings.json"):
self.settings_file = Path(settings_file)
self.default_settings = {
"ui": {
"theme_mode": "light",
"window_width": 1200,
"window_height": 800,
"auto_save_results": False,
"show_preview": True
},
"processing": {
"default_zoom": 2.0,
"image_format": "PNG",
"jpeg_quality": 95,
"max_pages_batch": 5
},
"analysis": {
"default_mode": "basic",
"save_format": "both", # text, json, both
"auto_analyze": False,
"custom_prompts": []
}
}
self.settings = self.load_settings()
def load_settings(self) -> Dict[str, Any]:
"""설정 파일 로드"""
try:
if self.settings_file.exists():
with open(self.settings_file, 'r', encoding='utf-8') as f:
loaded_settings = json.load(f)
# 기본 설정과 병합
settings = self.default_settings.copy()
self._deep_merge(settings, loaded_settings)
return settings
else:
return self.default_settings.copy()
except Exception as e:
logger.error(f"설정 로드 실패: {e}")
return self.default_settings.copy()
def save_settings(self) -> bool:
"""설정 파일 저장"""
try:
with open(self.settings_file, 'w', encoding='utf-8') as f:
json.dump(self.settings, f, indent=2, ensure_ascii=False)
logger.info("설정 저장 완료")
return True
except Exception as e:
logger.error(f"설정 저장 실패: {e}")
return False
def get(self, section: str, key: str, default=None):
"""설정 값 조회"""
return self.settings.get(section, {}).get(key, default)
def set(self, section: str, key: str, value):
"""설정 값 변경"""
if section not in self.settings:
self.settings[section] = {}
self.settings[section][key] = value
def _deep_merge(self, base: dict, update: dict):
"""딕셔너리 깊은 병합"""
for key, value in update.items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
self._deep_merge(base[key], value)
else:
base[key] = value
class ErrorHandler:
"""고급 오류 처리 클래스"""
def __init__(self):
self.error_log = []
self.error_callbacks = []
def handle_error(
self,
error: Exception,
context: str = "",
user_message: str = None
) -> Dict[str, Any]:
"""오류 처리 및 정보 수집"""
error_info = {
"timestamp": self._get_timestamp(),
"error_type": type(error).__name__,
"error_message": str(error),
"context": context,
"user_message": user_message or self._get_user_friendly_message(error)
}
# 오류 로그에 추가
self.error_log.append(error_info)
# 로거에 기록
logger.error(f"[{context}] {error_info['error_type']}: {error_info['error_message']}")
# 콜백 실행
for callback in self.error_callbacks:
try:
callback(error_info)
except Exception as e:
logger.error(f"오류 콜백 실행 실패: {e}")
return error_info
def add_error_callback(self, callback):
"""오류 발생 시 실행할 콜백 추가"""
self.error_callbacks.append(callback)
def get_recent_errors(self, count: int = 10) -> List[Dict[str, Any]]:
"""최근 오류 목록 반환"""
return self.error_log[-count:]
def clear_error_log(self):
"""오류 로그 정리"""
self.error_log.clear()
def _get_timestamp(self) -> str:
"""현재 타임스탬프 반환"""
from datetime import datetime
return datetime.now().isoformat()
def _get_user_friendly_message(self, error: Exception) -> str:
"""사용자 친화적 오류 메시지 생성"""
error_type = type(error).__name__
friendly_messages = {
"FileNotFoundError": "파일을 찾을 수 없습니다. 파일 경로를 확인해주세요.",
"PermissionError": "파일에 접근할 권한이 없습니다. 관리자 권한으로 실행해보세요.",
"ConnectionError": "네트워크 연결에 문제가 있습니다. 인터넷 연결을 확인해주세요.",
"TimeoutError": "요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.",
"ValueError": "입력 값이 올바르지 않습니다. 설정을 확인해주세요.",
"KeyError": "필요한 설정 값이 없습니다. 설정을 다시 확인해주세요.",
"ImportError": "필요한 라이브러리가 설치되지 않았습니다. 의존성을 확인해주세요."
}
return friendly_messages.get(error_type, "예상치 못한 오류가 발생했습니다.")
class AnalysisHistory:
"""분석 히스토리 관리 클래스"""
def __init__(self, history_file: str = "analysis_history.json"):
self.history_file = Path(history_file)
self.history = self.load_history()
def load_history(self) -> List[Dict[str, Any]]:
"""히스토리 파일 로드"""
try:
if self.history_file.exists():
with open(self.history_file, 'r', encoding='utf-8') as f:
return json.load(f)
else:
return []
except Exception as e:
logger.error(f"히스토리 로드 실패: {e}")
return []
def save_history(self):
"""히스토리 파일 저장"""
try:
with open(self.history_file, 'w', encoding='utf-8') as f:
json.dump(self.history, f, indent=2, ensure_ascii=False)
logger.info("히스토리 저장 완료")
except Exception as e:
logger.error(f"히스토리 저장 실패: {e}")
def add_analysis(
self,
pdf_filename: str,
analysis_mode: str,
pages_analyzed: int,
analysis_time: float,
success: bool = True
):
"""분석 기록 추가"""
record = {
"timestamp": self._get_timestamp(),
"pdf_filename": pdf_filename,
"analysis_mode": analysis_mode,
"pages_analyzed": pages_analyzed,
"analysis_time": analysis_time,
"success": success
}
self.history.append(record)
# 최대 100개 기록 유지
if len(self.history) > 100:
self.history = self.history[-100:]
self.save_history()
def get_recent_analyses(self, count: int = 10) -> List[Dict[str, Any]]:
"""최근 분석 기록 반환"""
return self.history[-count:]
def get_statistics(self) -> Dict[str, Any]:
"""분석 통계 반환"""
if not self.history:
return {
"total_analyses": 0,
"successful_analyses": 0,
"total_pages": 0,
"average_time": 0,
"most_used_mode": None
}
successful = [h for h in self.history if h["success"]]
mode_counts = {}
for h in self.history:
mode = h["analysis_mode"]
mode_counts[mode] = mode_counts.get(mode, 0) + 1
return {
"total_analyses": len(self.history),
"successful_analyses": len(successful),
"total_pages": sum(h["pages_analyzed"] for h in self.history),
"average_time": sum(h["analysis_time"] for h in successful) / len(successful) if successful else 0,
"most_used_mode": max(mode_counts.items(), key=lambda x: x[1])[0] if mode_counts else None
}
def clear_history(self):
"""히스토리 정리"""
self.history.clear()
self.save_history()
def _get_timestamp(self) -> str:
"""현재 타임스탬프 반환"""
from datetime import datetime
return datetime.now().isoformat()
class CustomPromptManager:
"""사용자 정의 프롬프트 관리 클래스"""
def __init__(self, prompts_file: str = "custom_prompts.json"):
self.prompts_file = Path(prompts_file)
self.prompts = self.load_prompts()
def load_prompts(self) -> List[Dict[str, str]]:
"""프롬프트 파일 로드"""
try:
if self.prompts_file.exists():
with open(self.prompts_file, 'r', encoding='utf-8') as f:
return json.load(f)
else:
return self._get_default_prompts()
except Exception as e:
logger.error(f"프롬프트 로드 실패: {e}")
return self._get_default_prompts()
def save_prompts(self):
"""프롬프트 파일 저장"""
try:
with open(self.prompts_file, 'w', encoding='utf-8') as f:
json.dump(self.prompts, f, indent=2, ensure_ascii=False)
logger.info("프롬프트 저장 완료")
except Exception as e:
logger.error(f"프롬프트 저장 실패: {e}")
def add_prompt(self, name: str, content: str, description: str = ""):
"""새 프롬프트 추가"""
prompt = {
"name": name,
"content": content,
"description": description,
"created_at": self._get_timestamp()
}
self.prompts.append(prompt)
self.save_prompts()
def get_prompt(self, name: str) -> Optional[str]:
"""프롬프트 내용 조회"""
for prompt in self.prompts:
if prompt["name"] == name:
return prompt["content"]
return None
def get_prompt_list(self) -> List[str]:
"""프롬프트 이름 목록 반환"""
return [prompt["name"] for prompt in self.prompts]
def delete_prompt(self, name: str) -> bool:
"""프롬프트 삭제"""
for i, prompt in enumerate(self.prompts):
if prompt["name"] == name:
del self.prompts[i]
self.save_prompts()
return True
return False
def _get_default_prompts(self) -> List[Dict[str, str]]:
"""기본 프롬프트 목록 반환"""
return [
{
"name": "기본 도면 분석",
"content": "이 도면을 분석하여 문서 유형, 주요 내용, 치수 정보를 알려주세요.",
"description": "일반적인 도면 분석용",
"created_at": self._get_timestamp()
},
{
"name": "건축 도면 분석",
"content": "이 건축 도면을 분석하여 건물 유형, 평면 구조, 주요 치수, 방의 용도를 상세히 설명해주세요.",
"description": "건축 도면 전용",
"created_at": self._get_timestamp()
},
{
"name": "기계 도면 분석",
"content": "이 기계 도면을 분석하여 부품명, 치수, 공차, 재료, 가공 방법을 파악해주세요.",
"description": "기계 설계 도면 전용",
"created_at": self._get_timestamp()
}
]
def _get_timestamp(self) -> str:
"""현재 타임스탬프 반환"""
from datetime import datetime
return datetime.now().isoformat()
# 사용 예시
if __name__ == "__main__":
# 고급 기능 테스트
print("고급 기능 모듈 테스트")
# 설정 관리 테스트
settings = AdvancedSettings()
print(f"현재 테마: {settings.get('ui', 'theme_mode')}")
# 오류 처리 테스트
error_handler = ErrorHandler()
try:
raise ValueError("테스트 오류")
except Exception as e:
error_info = error_handler.handle_error(e, "테스트 컨텍스트")
print(f"오류 처리: {error_info['user_message']}")
# 프롬프트 관리 테스트
prompt_manager = CustomPromptManager()
prompts = prompt_manager.get_prompt_list()
print(f"사용 가능한 프롬프트: {prompts}")

View File

@@ -0,0 +1,109 @@
# 간단한 PDF 배치 분석기 사용법
## 🎯 개요
사용자 요구사항: **"복잡하게 하지 말고 기존 모듈 그대로 사용해서 여러 개 처리하고 CSV로 만들기"**
**완전 구현 완료!** getcode.py와 똑같은 방식으로 여러 PDF를 처리하고 결과를 CSV로 저장하는 시스템이 준비되어 있습니다.
## 🚀 실행 방법
### 방법 1: 간단한 실행기 사용
```bash
python run_simple_batch.py
```
### 방법 2: 직접 실행
```bash
python simple_batch_analyzer_app.py
```
## 📱 UI 사용법
1. **📂 파일 선택**: "PDF 파일 선택" 버튼 클릭 또는 드래그&드롭
2. **✏️ 프롬프트 설정** (선택사항): 기본값은 getcode.py와 동일
- 기본: "pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘"
3. **▶️ 분석 시작**: "배치 분석 시작" 버튼 클릭
4. **📊 진행률 확인**: 실시간 진행률 및 처리 상태 표시
5. **💾 결과 확인**: 자동으로 CSV 파일 저장 및 요약 통계 표시
## 📄 CSV 출력 형식
생성되는 CSV 파일에는 다음 컬럼들이 포함됩니다:
| 컬럼명 | 설명 |
|--------|------|
| file_name | 파일 이름 |
| file_size_mb | 파일 크기 (MB) |
| processing_time_seconds | 처리 시간 (초) |
| success | 성공 여부 (True/False) |
| analysis_result | getcode.py 스타일 분석 결과 |
| analysis_timestamp | 분석 완료 시간 |
| prompt_used | 사용된 프롬프트 |
| model_used | 사용된 AI 모델 |
| error_message | 오류 메시지 (실패시) |
| processed_at | 처리 완료 시간 |
## 🔧 환경 설정
### 1. API 키 설정
`.env` 파일에 Gemini API 키 설정:
```
GEMINI_API_KEY=your_api_key_here
```
### 2. 필요 패키지 설치
```bash
pip install -r requirements.txt
```
주요 패키지:
- `flet>=0.25.1` - UI 프레임워크
- `google-genai>=1.0` - Gemini API
- `PyMuPDF>=1.26.3` - PDF 처리
- `pandas>=1.5.0` - CSV 출력
- `python-dotenv>=1.0.0` - 환경변수
## ⚡ 핵심 특징
-**단순성**: getcode.py와 동일한 방식, 복잡한 기능 제거
- 🔄 **배치 처리**: 한 번에 여러 PDF 파일 처리
- 📊 **CSV 출력**: JSON 분석 결과를 자동으로 CSV 변환
-**성능**: 비동기 처리로 빠른 배치 분석
- 📱 **사용성**: 직관적이고 간단한 UI
## 🗂️ 파일 저장 위치
- CSV 결과: `D:/MYCLAUDE_PROJECT/fletimageanalysis/results/`
- 파일명 형식: `batch_analysis_results_YYYY-MM-DD_HH-MM-SS.csv`
## 🎯 예상 사용 시나리오
1. **여러 도면 PDF 분석**: 한 번에 10-50개 도면 파일 분석
2. **일괄 메타데이터 추출**: 도면 정보, 제목, 축척 등 추출
3. **분석 결과 관리**: CSV로 저장하여 Excel에서 관리
4. **품질 보증**: 성공률 및 처리 시간 통계로 분석 품질 확인
## 🔍 문제 해결
### Q: 분석이 실패하는 경우
A:
- Gemini API 키가 올바르게 설정되었는지 확인
- PDF 파일이 손상되지 않았는지 확인
- 인터넷 연결 상태 확인
### Q: UI가 실행되지 않는 경우
A:
- Python 3.9+ 버전 확인
- Flet 패키지 설치 확인: `pip install flet`
- 작업 디렉토리가 올바른지 확인
### Q: CSV 파일을 찾을 수 없는 경우
A:
- `results/` 폴더가 자동 생성됩니다
- 완료 메시지에서 정확한 파일 경로 확인
- 파일 권한 문제 확인
## 📞 지원
이 시스템은 사용자 요구사항에 맞춰 **단순하고 직관적**으로 설계되었습니다.
getcode.py의 장점을 그대로 유지하면서 배치 처리 기능만 추가했습니다.

View File

@@ -0,0 +1,94 @@
"""
테스트용 DXF 파일 생성 스크립트
도곽 블록과 속성을 포함한 간단한 DXF 파일 생성
"""
import ezdxf
import os
def create_test_dxf():
"""테스트용 DXF 파일 생성"""
# 새 문서 생성
doc = ezdxf.new('R2010')
# 모델스페이스 가져오기
msp = doc.modelspace()
# 도곽 블록 생성
title_block = doc.blocks.new(name='TITLE_BLOCK')
# 블록에 기본 도형 추가 (도곽 테두리)
title_block.add_lwpolyline([
(0, 0), (210, 0), (210, 297), (0, 297), (0, 0)
], dxfattribs={'layer': 'BORDER'})
# 도곽 블록에 속성 정의 추가
title_block.add_attdef('DRAWING_NAME', (150, 20),
dxfattribs={'height': 5, 'prompt': '도면명'})
title_block.add_attdef('DRAWING_NUMBER', (150, 15),
dxfattribs={'height': 3, 'prompt': '도면번호'})
title_block.add_attdef('SCALE', (150, 10),
dxfattribs={'height': 3, 'prompt': '축척'})
title_block.add_attdef('DESIGNER', (150, 5),
dxfattribs={'height': 3, 'prompt': '설계자'})
title_block.add_attdef('DATE', (200, 5),
dxfattribs={'height': 3, 'prompt': '날짜'})
# 블록에 일반 텍스트도 추가
title_block.add_text('도면 제목', dxfattribs={'height': 4, 'insert': (10, 280)})
title_block.add_text('프로젝트명', dxfattribs={'height': 3, 'insert': (10, 275)})
# 모델스페이스에 도곽 블록 참조 추가
blockref = msp.add_blockref('TITLE_BLOCK', (0, 0))
# 블록 참조에 속성 값 추가
blockref.add_auto_attribs({
'DRAWING_NAME': '평면도 및 종단면도',
'DRAWING_NUMBER': 'DWG-001',
'SCALE': '1:1000',
'DESIGNER': '김설계',
'DATE': '2025-07-09'
})
# 추가 블록 생성 (일반 블록)
detail_block = doc.blocks.new(name='DETAIL_MARK')
detail_block.add_circle((0, 0), 5)
detail_block.add_attdef('DETAIL_NO', (0, 0),
dxfattribs={'height': 3, 'prompt': '상세번호'})
# 상세 마크 블록 참조 추가
detail_ref = msp.add_blockref('DETAIL_MARK', (50, 50))
detail_ref.add_auto_attribs({'DETAIL_NO': 'A'})
detail_ref2 = msp.add_blockref('DETAIL_MARK', (100, 100))
detail_ref2.add_auto_attribs({'DETAIL_NO': 'B'})
# 독립적인 텍스트 엔티티 추가
msp.add_text('독립 텍스트 1', dxfattribs={'height': 5, 'insert': (30, 150)})
msp.add_mtext('여러줄\n텍스트', dxfattribs={'char_height': 4, 'insert': (30, 130)})
return doc
def main():
"""메인 함수"""
try:
# 테스트 DXF 파일 생성
doc = create_test_dxf()
# uploads 폴더 생성
os.makedirs('uploads', exist_ok=True)
# 파일 저장
output_path = 'uploads/test_drawing.dxf'
doc.saveas(output_path)
print(f"[SUCCESS] 테스트 DXF 파일 생성 완료: {output_path}")
print(" - TITLE_BLOCK: 도곽 블록 (5개 속성)")
print(" - DETAIL_MARK: 상세 마크 블록 (2개 인스턴스)")
print(" - 독립 텍스트 엔티티 2개")
except Exception as e:
print(f"[ERROR] DXF 파일 생성 실패: {e}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,313 @@
# -*- coding: utf-8 -*-
"""
DXF 파일 지원을 위한 추가 메서드들
main.py에 추가할 메서드들을 정의합니다.
"""
def handle_file_selection_update(self, e):
"""
기존 on_file_selected 메서드를 DXF 지원으로 업데이트하는 로직
이 메서드들을 main.py에 추가하거나 기존 메서드를 대체해야 합니다.
"""
def on_file_selected_updated(self, e):
"""파일 선택 결과 핸들러 - PDF/DXF 지원"""
if e.files:
file = e.files[0]
self.current_file_path = file.path
# 파일 확장자로 타입 결정
file_extension = file.path.lower().split('.')[-1]
if file_extension == 'pdf':
self.current_file_type = 'pdf'
self._handle_pdf_file_selection(file)
elif file_extension == 'dxf':
self.current_file_type = 'dxf'
self._handle_dxf_file_selection(file)
else:
self.selected_file_text.value = f"❌ 지원하지 않는 파일 형식입니다: {file_extension}"
self.selected_file_text.color = ft.Colors.RED_600
self.upload_button.disabled = True
self.pdf_preview_button.disabled = True
self._reset_file_state()
else:
self.selected_file_text.value = "선택된 파일이 없습니다"
self.selected_file_text.color = ft.Colors.GREY_600
self.upload_button.disabled = True
self.pdf_preview_button.disabled = True
self._reset_file_state()
self.page.update()
def _handle_pdf_file_selection(self, file):
"""PDF 파일 선택 처리"""
if self.pdf_processor.validate_pdf_file(self.current_file_path):
# PDF 정보 조회
self.current_pdf_info = self.pdf_processor.get_pdf_info(self.current_file_path)
# 파일 크기 정보 추가
file_size_mb = self.current_pdf_info['file_size'] / (1024 * 1024)
file_info = f"{file.name} (PDF)\n📄 {self.current_pdf_info['page_count']}페이지, {file_size_mb:.1f}MB"
self.selected_file_text.value = file_info
self.selected_file_text.color = ft.Colors.GREEN_600
self.upload_button.disabled = False
self.pdf_preview_button.disabled = False
# 페이지 정보 업데이트
self.page_info_text.value = f"1 / {self.current_pdf_info['page_count']}"
self.current_page_index = 0
logger.info(f"PDF 파일 선택됨: {file.name}")
else:
self.selected_file_text.value = "❌ 유효하지 않은 PDF 파일입니다"
self.selected_file_text.color = ft.Colors.RED_600
self.upload_button.disabled = True
self.pdf_preview_button.disabled = True
self._reset_file_state()
def _handle_dxf_file_selection(self, file):
"""DXF 파일 선택 처리"""
try:
if self.dxf_processor.validate_dxf_file(self.current_file_path):
# DXF 파일 크기 계산
import os
file_size_mb = os.path.getsize(self.current_file_path) / (1024 * 1024)
file_info = f"{file.name} (DXF)\n🏗️ CAD 도면 파일, {file_size_mb:.1f}MB"
self.selected_file_text.value = file_info
self.selected_file_text.color = ft.Colors.GREEN_600
self.upload_button.disabled = False
self.pdf_preview_button.disabled = True # DXF는 미리보기 비활성화
# DXF는 페이지 개념이 없으므로 기본값 설정
self.page_info_text.value = "DXF 파일"
self.current_page_index = 0
self.current_pdf_info = None # DXF는 PDF 정보 없음
logger.info(f"DXF 파일 선택됨: {file.name}")
else:
self.selected_file_text.value = "❌ 유효하지 않은 DXF 파일입니다"
self.selected_file_text.color = ft.Colors.RED_600
self.upload_button.disabled = True
self.pdf_preview_button.disabled = True
self._reset_file_state()
except Exception as e:
logger.error(f"DXF 파일 검증 오류: {e}")
self.selected_file_text.value = f"❌ DXF 파일 처리 오류: {str(e)}"
self.selected_file_text.color = ft.Colors.RED_600
self.upload_button.disabled = True
self.pdf_preview_button.disabled = True
self._reset_file_state()
def _reset_file_state(self):
"""파일 상태 초기화"""
self.current_file_path = None
self.current_file_type = None
self.current_pdf_info = None
def run_analysis_updated(self):
"""분석 실행 (백그라운드 스레드) - PDF/DXF 지원"""
try:
self.analysis_start_time = time.time()
if self.current_file_type == 'pdf':
self._run_pdf_analysis()
elif self.current_file_type == 'dxf':
self._run_dxf_analysis()
else:
raise ValueError(f"지원하지 않는 파일 타입: {self.current_file_type}")
except Exception as e:
logger.error(f"분석 중 오류 발생: {e}")
self.update_progress_ui(False, f"❌ 분석 오류: {str(e)}")
self.show_error_dialog("분석 오류", f"분석 중 오류가 발생했습니다:\n{str(e)}")
def _run_pdf_analysis(self):
"""PDF 파일 분석 실행"""
self.update_progress_ui(True, "PDF 이미지 변환 중...")
# 조직 유형 결정
organization_type = "transportation"
if self.organization_selector and self.organization_selector.value:
if self.organization_selector.value == "한국도로공사":
organization_type = "expressway"
else:
organization_type = "transportation"
logger.info(f"선택된 조직 유형: {organization_type}")
# 분석할 페이지 결정
if self.page_selector.value == "첫 번째 페이지":
pages_to_analyze = [0]
else:
pages_to_analyze = list(range(self.current_pdf_info['page_count']))
# 분석 프롬프트 결정
if self.analysis_mode.value == "custom":
prompt = self.custom_prompt.value or Config.DEFAULT_PROMPT
elif self.analysis_mode.value == "detailed":
prompt = "이 PDF 이미지를 자세히 분석하여 다음 정보를 제공해주세요: 1) 문서 유형, 2) 주요 내용, 3) 도면/도표 정보, 4) 텍스트 내용, 5) 기타 특징"
else:
prompt = Config.DEFAULT_PROMPT
# 페이지별 분석 수행
total_pages = len(pages_to_analyze)
self.analysis_results = {}
for i, page_num in enumerate(pages_to_analyze):
progress = (i + 1) / total_pages
self.update_progress_ui(
True,
f"페이지 {page_num + 1} 분석 중... ({i + 1}/{total_pages})",
progress
)
# PDF 페이지를 base64로 변환
base64_data = self.pdf_processor.pdf_page_to_base64(
self.current_file_path,
page_num
)
if base64_data:
# Gemini API로 분석
result = self.gemini_analyzer.analyze_image_from_base64(
base64_data=base64_data,
prompt=prompt,
organization_type=organization_type
)
if result:
self.analysis_results[page_num] = result
else:
self.analysis_results[page_num] = f"페이지 {page_num + 1} 분석 실패"
else:
self.analysis_results[page_num] = f"페이지 {page_num + 1} 이미지 변환 실패"
# 결과 표시
self.display_analysis_results()
# 완료 상태로 업데이트
if self.analysis_start_time:
duration = time.time() - self.analysis_start_time
duration_str = DateTimeUtils.format_duration(duration)
self.update_progress_ui(False, f"✅ PDF 분석 완료! (소요시간: {duration_str})", 1.0)
else:
self.update_progress_ui(False, "✅ PDF 분석 완료!", 1.0)
def _run_dxf_analysis(self):
"""DXF 파일 분석 실행"""
self.update_progress_ui(True, "DXF 파일 분석 중...")
try:
# DXF 파일 처리
result = self.dxf_processor.process_dxf_file(self.current_file_path)
if result['success']:
# 분석 결과 포맷팅
self.analysis_results = {'dxf': result}
# 결과 표시
self.display_dxf_analysis_results(result)
# 완료 상태로 업데이트
if self.analysis_start_time:
duration = time.time() - self.analysis_start_time
duration_str = DateTimeUtils.format_duration(duration)
self.update_progress_ui(False, f"✅ DXF 분석 완료! (소요시간: {duration_str})", 1.0)
else:
self.update_progress_ui(False, "✅ DXF 분석 완료!", 1.0)
else:
error_msg = result.get('error', '알 수 없는 오류')
self.update_progress_ui(False, f"❌ DXF 분석 실패: {error_msg}")
self.show_error_dialog("DXF 분석 오류", f"DXF 파일 분석에 실패했습니다:\n{error_msg}")
except Exception as e:
logger.error(f"DXF 분석 중 오류: {e}")
self.update_progress_ui(False, f"❌ DXF 분석 오류: {str(e)}")
self.show_error_dialog("DXF 분석 오류", f"DXF 분석 중 오류가 발생했습니다:\n{str(e)}")
def display_dxf_analysis_results(self, dxf_result):
"""DXF 분석 결과 표시"""
def update_results():
if dxf_result and dxf_result['success']:
# 결과 텍스트 구성
result_text = "🎯 DXF 분석 요약\n"
result_text += f"📊 파일: {os.path.basename(dxf_result['file_path'])}\n"
result_text += f"⏰ 완료 시간: {DateTimeUtils.get_timestamp()}\n"
result_text += "=" * 60 + "\n\n"
# 요약 정보
summary = dxf_result.get('summary', {})
result_text += "📋 분석 요약\n"
result_text += "-" * 40 + "\n"
result_text += f"전체 블록 수: {summary.get('total_blocks', 0)}\n"
result_text += f"도곽 블록 발견: {'' if summary.get('title_block_found', False) else '아니오'}\n"
result_text += f"속성 수: {summary.get('attributes_count', 0)}\n"
if summary.get('title_block_name'):
result_text += f"도곽 블록명: {summary['title_block_name']}\n"
result_text += "\n"
# 도곽 정보
title_block = dxf_result.get('title_block')
if title_block:
result_text += "🏗️ 도곽 정보\n"
result_text += "-" * 40 + "\n"
fields = {
'drawing_name': '도면명',
'drawing_number': '도면번호',
'construction_field': '건설분야',
'construction_stage': '건설단계',
'scale': '축척',
'project_name': '프로젝트명',
'designer': '설계자',
'date': '날짜',
'revision': '리비전',
'location': '위치'
}
for field, label in fields.items():
value = title_block.get(field)
if value:
result_text += f"{label}: {value}\n"
# 바운딩 박스 정보
bbox = title_block.get('bounding_box')
if bbox:
result_text += f"\n📐 도곽 위치 정보\n"
result_text += f"좌하단: ({bbox['min_x']:.2f}, {bbox['min_y']:.2f})\n"
result_text += f"우상단: ({bbox['max_x']:.2f}, {bbox['max_y']:.2f})\n"
result_text += f"크기: {bbox['max_x'] - bbox['min_x']:.2f} × {bbox['max_y'] - bbox['min_y']:.2f}\n"
# 블록 참조 정보
block_refs = dxf_result.get('block_references', [])
if block_refs:
result_text += f"\n📦 블록 참조 목록 ({len(block_refs)}개)\n"
result_text += "-" * 40 + "\n"
for i, block_ref in enumerate(block_refs[:10]): # 최대 10개까지만 표시
result_text += f"{i+1}. {block_ref.get('name', 'Unknown')}"
if block_ref.get('attributes'):
result_text += f" (속성 {len(block_ref['attributes'])}개)"
result_text += "\n"
if len(block_refs) > 10:
result_text += f"... 외 {len(block_refs) - 10}개 블록\n"
self.results_text.value = result_text.strip()
# 저장 버튼 활성화
self.save_text_button.disabled = False
self.save_json_button.disabled = False
else:
self.results_text.value = "❌ DXF 분석 결과가 없습니다."
self.save_text_button.disabled = True
self.save_json_button.disabled = True
self.page.update()
# 메인 스레드에서 UI 업데이트
self.page.run_thread(update_results)

View File

@@ -0,0 +1,408 @@
"""
Gemini API 연동 모듈
Google Gemini API를 사용하여 이미지 분석을 수행합니다.
"""
import base64
import logging
from google import genai
from google.genai import types
from typing import Optional, Dict, Any
from config import Config
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class GeminiAnalyzer:
"""Gemini API 이미지 분석 클래스"""
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
"""
GeminiAnalyzer 초기화
Args:
api_key: Gemini API 키 (None인 경우 환경변수에서 가져옴)
model: 사용할 모델명 (기본값: 설정에서 가져옴)
"""
self.api_key = api_key or Config.GEMINI_API_KEY
self.model = model or Config.GEMINI_MODEL
self.default_prompt = Config.DEFAULT_PROMPT
if not self.api_key:
raise ValueError("Gemini API 키가 설정되지 않았습니다.")
# Gemini 클라이언트 초기화
try:
self.client = genai.Client(api_key=self.api_key)
logger.info(f"Gemini 클라이언트 초기화 완료 (모델: {self.model})")
except Exception as e:
logger.error(f"Gemini 클라이언트 초기화 실패: {e}")
raise
def analyze_image_from_base64(
self,
base64_data: str,
prompt: Optional[str] = None,
mime_type: str = "image/png",
organization_type: str = "transportation"
) -> Optional[str]:
"""
Base64 이미지 데이터를 분석합니다.
Args:
base64_data: Base64로 인코딩된 이미지 데이터
prompt: 분석 요청 텍스트 (None인 경우 기본값 사용)
mime_type: 이미지 MIME 타입
organization_type: 조직 유형 ("transportation" 또는 "expressway")
Returns:
분석 결과 텍스트 또는 None (실패 시)
"""
try:
analysis_prompt = prompt or self.default_prompt
# 컨텐츠 구성
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
mime_type=mime_type,
data=base64.b64decode(base64_data),
),
types.Part.from_text(text=analysis_prompt),
],
)
]
schema_expressway=genai.types.Schema(
type = genai.types.Type.OBJECT,
required = ["사업명", "시설/공구", "건설분야", "건설단계"],
properties = {
"사업명": genai.types.Schema(
type = genai.types.Type.STRING,
),
"노선이정": genai.types.Schema(
type = genai.types.Type.STRING,
),
"설계사": genai.types.Schema(
type = genai.types.Type.STRING,
),
"시공사": genai.types.Schema(
type = genai.types.Type.STRING,
),
"건설분야": genai.types.Schema(
type = genai.types.Type.STRING,
),
"건설단계": genai.types.Schema(
type = genai.types.Type.STRING,
),
"계정번호": genai.types.Schema(
type = genai.types.Type.STRING,
),
"(계정)날짜": genai.types.Schema(
type = genai.types.Type.STRING,
),
"(개정)내용": genai.types.Schema(
type = genai.types.Type.STRING,
),
"작성자": genai.types.Schema(
type = genai.types.Type.STRING,
),
"검토자": genai.types.Schema(
type = genai.types.Type.STRING,
),
"확인자": genai.types.Schema(
type = genai.types.Type.STRING,
),
"설계공구": genai.types.Schema(
type = genai.types.Type.STRING,
),
"시공공구": genai.types.Schema(
type = genai.types.Type.STRING,
),
"도면번호": genai.types.Schema(
type = genai.types.Type.STRING,
),
"도면축척": genai.types.Schema(
type = genai.types.Type.STRING,
),
"도면명": genai.types.Schema(
type = genai.types.Type.STRING,
),
"편철번호": genai.types.Schema(
type = genai.types.Type.STRING,
),
"적용표준버전": genai.types.Schema(
type = genai.types.Type.STRING,
),
"Note": genai.types.Schema(
type = genai.types.Type.STRING,
),
"Title": genai.types.Schema(
type = genai.types.Type.STRING,
),
"기타정보": genai.types.Schema(
type = genai.types.Type.STRING,
),
},
)
schema_transportation=genai.types.Schema(
type = genai.types.Type.OBJECT,
required = ["사업명", "시설/공구", "건설분야", "건설단계"],
properties = {
"사업명": genai.types.Schema(
type = genai.types.Type.STRING,
),
"시설/공구": genai.types.Schema(
type = genai.types.Type.STRING,
),
"건설분야": genai.types.Schema(
type = genai.types.Type.STRING,
),
"건설단계": genai.types.Schema(
type = genai.types.Type.STRING,
),
"계정차수": genai.types.Schema(
type = genai.types.Type.STRING,
),
"계정일자": genai.types.Schema(
type = genai.types.Type.STRING,
),
"개정내용": genai.types.Schema(
type = genai.types.Type.STRING,
),
"과업책임자": genai.types.Schema(
type = genai.types.Type.STRING,
),
"분야별책임자": genai.types.Schema(
type = genai.types.Type.STRING,
),
"설계자": genai.types.Schema(
type = genai.types.Type.STRING,
),
"위치정보": genai.types.Schema(
type = genai.types.Type.STRING,
),
"축척": genai.types.Schema(
type = genai.types.Type.STRING,
),
"도면번호": genai.types.Schema(
type = genai.types.Type.STRING,
),
"도면명": genai.types.Schema(
type = genai.types.Type.STRING,
),
"편철번호": genai.types.Schema(
type = genai.types.Type.STRING,
),
"적용표준": genai.types.Schema(
type = genai.types.Type.STRING,
),
"Note": genai.types.Schema(
type = genai.types.Type.STRING,
),
"Title": genai.types.Schema(
type = genai.types.Type.STRING,
),
"기타정보": genai.types.Schema(
type = genai.types.Type.STRING,
),
},
)
# 조직 유형에 따른 스키마 선택
if organization_type == "expressway":
selected_schema = schema_expressway
else: # transportation (기본값)
selected_schema = schema_transportation
# 생성 설정
generate_content_config = types.GenerateContentConfig(
temperature=0,
top_p=0.05,
thinking_config = types.ThinkingConfig(
thinking_budget=0,
),
response_mime_type="application/json",
response_schema= selected_schema
)
logger.info("Gemini API 분석 요청 시작...")
# API 호출
response = self.client.models.generate_content(
model=self.model,
contents=contents,
config=generate_content_config,
)
if response and hasattr(response, 'text'):
result = response.text
logger.info(f"분석 완료: {len(result)} 문자")
return result
else:
logger.error("API 응답에서 텍스트를 찾을 수 없습니다.")
return None
except Exception as e:
logger.error(f"이미지 분석 중 오류 발생: {e}")
return None
def analyze_image_stream_from_base64(
self,
base64_data: str,
prompt: Optional[str] = None,
mime_type: str = "image/png",
organization_type: str = "transportation"
):
"""
Base64 이미지 데이터를 스트리밍으로 분석합니다.
Args:
base64_data: Base64로 인코딩된 이미지 데이터
prompt: 분석 요청 텍스트
mime_type: 이미지 MIME 타입
organization_type: 조직 유형 ("transportation" 또는 "expressway")
Yields:
분석 결과 텍스트 청크
"""
try:
analysis_prompt = prompt or self.default_prompt
# 컨텐츠 구성
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
mime_type=mime_type,
data=base64.b64decode(base64_data),
),
types.Part.from_text(text=analysis_prompt),
],
)
]
# 조직 유형에 따른 스키마 선택
if organization_type == "expressway":
selected_schema = schema_expressway
else: # transportation (기본값)
selected_schema = schema_transportation
# 생성 설정
generate_content_config = types.GenerateContentConfig(
temperature=0,
top_p=0.05,
thinking_config = types.ThinkingConfig(
thinking_budget=0,
),
response_mime_type="application/json",
response_schema=selected_schema,
)
logger.info("Gemini API 스트리밍 분석 요청 시작...")
# 스트리밍 API 호출
for chunk in self.client.models.generate_content_stream(
model=self.model,
contents=contents,
config=generate_content_config,
):
if hasattr(chunk, 'text') and chunk.text:
yield chunk.text
except Exception as e:
logger.error(f"스트리밍 이미지 분석 중 오류 발생: {e}")
yield f"오류: {str(e)}"
def analyze_pdf_images(
self,
base64_images: list,
prompt: Optional[str] = None,
mime_type: str = "image/png",
organization_type: str = "transportation"
) -> Dict[int, str]:
"""
여러 PDF 페이지 이미지를 일괄 분석합니다.
Args:
base64_images: Base64 이미지 데이터 리스트
prompt: 분석 요청 텍스트
mime_type: 이미지 MIME 타입
organization_type: 조직 유형 ("transportation" 또는 "expressway")
Returns:
페이지 번호별 분석 결과 딕셔너리
"""
results = {}
for i, base64_data in enumerate(base64_images):
logger.info(f"페이지 {i + 1}/{len(base64_images)} 분석 중...")
result = self.analyze_image_from_base64(
base64_data=base64_data,
prompt=prompt,
mime_type=mime_type,
organization_type=organization_type
)
if result:
results[i] = result
else:
results[i] = f"페이지 {i + 1} 분석 실패"
logger.info(f"{len(results)}개 페이지 분석 완료")
return results
def validate_api_connection(self) -> bool:
"""API 연결 상태를 확인합니다."""
try:
# 간단한 텍스트 생성으로 연결 테스트
test_response = self.client.models.generate_content(
model=self.model,
contents=[types.Content(
role="user",
parts=[types.Part.from_text(text="안녕하세요. 연결 테스트입니다.")]
)],
config=types.GenerateContentConfig(
temperature=0,
max_output_tokens=10,
)
)
if test_response and hasattr(test_response, 'text'):
logger.info("Gemini API 연결 테스트 성공")
return True
else:
logger.error("Gemini API 연결 테스트 실패")
return False
except Exception as e:
logger.error(f"Gemini API 연결 테스트 중 오류: {e}")
return False
def get_model_info(self) -> Dict[str, Any]:
"""현재 사용 중인 모델 정보를 반환합니다."""
return {
"model": self.model,
"api_key_length": len(self.api_key) if self.api_key else 0,
"default_prompt": self.default_prompt
}
# 사용 예시 및 테스트
if __name__ == "__main__":
try:
# 분석기 초기화
analyzer = GeminiAnalyzer()
# 연결 테스트
if analyzer.validate_api_connection():
print("Gemini API 연결 성공!")
print(f"모델 정보: {analyzer.get_model_info()}")
else:
print("Gemini API 연결 실패!")
except Exception as e:
print(f"초기화 실패: {e}")
print("API 키가 올바르게 설정되었는지 확인하세요.")

723
back_src/main_old.py Normal file
View File

@@ -0,0 +1,723 @@
"""
PDF 도면 분석기 - 메인 애플리케이션 (업데이트됨)
Flet 기반의 PDF 업로드 및 Gemini API 이미지 분석 애플리케이션
"""
import flet as ft
import logging
import threading
from typing import Optional
import time
# 프로젝트 모듈 임포트
from config import Config
from pdf_processor import PDFProcessor
from gemini_analyzer import GeminiAnalyzer
from ui_components import UIComponents
from utils import AnalysisResultSaver, DateTimeUtils
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class PDFAnalyzerApp:
"""PDF 분석기 메인 애플리케이션 클래스"""
def __init__(self, page: ft.Page):
self.page = page
self.pdf_processor = PDFProcessor()
self.gemini_analyzer = None
self.current_pdf_path = None
self.current_pdf_info = None
self.analysis_results = {}
self.result_saver = AnalysisResultSaver("results")
self.analysis_start_time = None
# UI 컴포넌트 참조
self.file_picker = None
self.selected_file_text = None
self.upload_button = None
self.progress_bar = None
self.progress_ring = None
self.status_text = None
self.results_text = None
self.results_container = None
self.save_button = None
self.organization_selector = None # 새로 추가
self.page_selector = None
self.analysis_mode = None
self.custom_prompt = None
self.pdf_preview_container = None
self.page_nav_text = None
self.prev_button = None
self.next_button = None
# 초기화
self.setup_page()
self.init_gemini_analyzer()
def setup_page(self):
"""페이지 기본 설정"""
self.page.title = Config.APP_TITLE
self.page.theme_mode = ft.ThemeMode.LIGHT
self.page.padding = 0
self.page.bgcolor = ft.Colors.GREY_100
# 윈도우 크기 설정
self.page.window_width = 1200
self.page.window_height = 800
self.page.window_min_width = 1000
self.page.window_min_height = 700
logger.info("페이지 설정 완료")
def init_gemini_analyzer(self):
"""Gemini 분석기 초기화"""
try:
config_errors = Config.validate_config()
if config_errors:
self.show_error_dialog(
"설정 오류",
"\\n".join(config_errors) + "\\n\\n.env 파일을 확인하세요."
)
return
self.gemini_analyzer = GeminiAnalyzer()
logger.info("Gemini 분석기 초기화 완료")
except Exception as e:
logger.error(f"Gemini 분석기 초기화 실패: {e}")
self.show_error_dialog(
"초기화 오류",
f"Gemini API 초기화에 실패했습니다:\\n{str(e)}"
)
def build_ui(self):
"""UI 구성"""
# 앱바
app_bar = UIComponents.create_app_bar()
self.page.appbar = app_bar
# 파일 업로드 섹션
upload_section = self.create_file_upload_section()
# 분석 설정 섹션
settings_section = self.create_analysis_settings_section()
# 진행률 섹션
progress_section = self.create_progress_section()
# 결과 및 미리보기 섹션
content_row = ft.Row([
ft.Column([
self.create_results_section(),
], expand=2),
ft.Column([
self.create_pdf_preview_section(),
], expand=1),
])
# 메인 레이아웃
main_content = ft.Column([
upload_section,
settings_section,
progress_section,
content_row,
], scroll=ft.ScrollMode.AUTO)
# 페이지에 추가
self.page.add(main_content)
logger.info("UI 구성 완료")
def create_file_upload_section(self) -> ft.Container:
"""파일 업로드 섹션 생성"""
# 파일 선택기
self.file_picker = ft.FilePicker(on_result=self.on_file_selected)
self.page.overlay.append(self.file_picker)
# 선택된 파일 정보
self.selected_file_text = ft.Text(
"선택된 파일이 없습니다",
size=14,
color=ft.Colors.GREY_600
)
# 파일 선택 버튼
select_button = ft.ElevatedButton(
text="PDF 파일 선택",
icon=ft.Icons.UPLOAD_FILE,
on_click=self.on_select_file_click,
style=ft.ButtonStyle(
bgcolor=ft.Colors.BLUE_100,
color=ft.Colors.BLUE_800,
)
)
# 분석 시작 버튼
self.upload_button = ft.ElevatedButton(
text="분석 시작",
icon=ft.Icons.ANALYTICS,
on_click=self.on_analysis_start_click,
disabled=True,
style=ft.ButtonStyle(
bgcolor=ft.Colors.GREEN_100,
color=ft.Colors.GREEN_800,
)
)
return ft.Container(
content=ft.Column([
ft.Text(
"📄 PDF 파일 업로드",
size=18,
weight=ft.FontWeight.BOLD,
color=ft.Colors.BLUE_800
),
ft.Divider(),
ft.Row([
select_button,
self.upload_button,
], alignment=ft.MainAxisAlignment.START),
self.selected_file_text,
]),
padding=20,
margin=10,
bgcolor=ft.Colors.WHITE,
border_radius=10,
border=ft.border.all(1, ft.Colors.GREY_300),
)
def create_analysis_settings_section(self) -> ft.Container:
"""분석 설정 섹션 생성"""
# UI 컴포넌트와 참조를 가져오기
container, organization_selector, page_selector, analysis_mode, custom_prompt = \
UIComponents.create_analysis_settings_section_with_refs()
# 인스턴스 변수에 참조 저장
self.organization_selector = organization_selector
self.page_selector = page_selector
self.analysis_mode = analysis_mode
self.custom_prompt = custom_prompt
# 이벤트 핸들러 설정
self.analysis_mode.on_change = self.on_analysis_mode_change
self.organization_selector.on_change = self.on_organization_change
return container
def create_progress_section(self) -> ft.Container:
"""진행률 섹션 생성"""
# 진행률 바
self.progress_bar = ft.ProgressBar(
width=400,
color=ft.Colors.BLUE_600,
bgcolor=ft.Colors.GREY_300,
visible=False,
)
# 상태 텍스트
self.status_text = ft.Text(
"대기 중...",
size=14,
color=ft.Colors.GREY_600
)
# 진행률 링
self.progress_ring = ft.ProgressRing(
width=50,
height=50,
stroke_width=4,
visible=False,
)
return ft.Container(
content=ft.Column([
ft.Text(
"📊 분석 진행 상황",
size=18,
weight=ft.FontWeight.BOLD,
color=ft.Colors.PURPLE_800
),
ft.Divider(),
ft.Row([
self.progress_ring,
ft.Column([
self.status_text,
self.progress_bar,
], expand=1),
], alignment=ft.MainAxisAlignment.START),
]),
padding=20,
margin=10,
bgcolor=ft.Colors.WHITE,
border_radius=10,
border=ft.border.all(1, ft.Colors.GREY_300),
)
def create_results_section(self) -> ft.Container:
"""결과 섹션 생성"""
# 결과 텍스트
self.results_text = ft.Text(
"분석 결과가 여기에 표시됩니다.",
size=14,
selectable=True,
)
# 결과 컨테이너
self.results_container = ft.Container(
content=ft.Column([
self.results_text,
], scroll=ft.ScrollMode.AUTO),
padding=15,
height=350,
bgcolor=ft.Colors.GREY_50,
border_radius=8,
border=ft.border.all(1, ft.Colors.GREY_300),
)
# 저장 버튼들
save_text_button = ft.ElevatedButton(
text="텍스트 저장",
icon=ft.Icons.SAVE,
disabled=True,
on_click=self.on_save_text_click,
style=ft.ButtonStyle(
bgcolor=ft.Colors.TEAL_100,
color=ft.Colors.TEAL_800,
)
)
save_json_button = ft.ElevatedButton(
text="JSON 저장",
icon=ft.Icons.SAVE_ALT,
disabled=True,
on_click=self.on_save_json_click,
style=ft.ButtonStyle(
bgcolor=ft.Colors.INDIGO_100,
color=ft.Colors.INDIGO_800,
)
)
# 저장 버튼들을 인스턴스 변수로 저장
self.save_text_button = save_text_button
self.save_json_button = save_json_button
return ft.Container(
content=ft.Column([
ft.Row([
ft.Text(
"📋 분석 결과",
size=18,
weight=ft.FontWeight.BOLD,
color=ft.Colors.GREEN_800
),
ft.Row([
save_text_button,
save_json_button,
]),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
ft.Divider(),
self.results_container,
]),
padding=20,
margin=10,
bgcolor=ft.Colors.WHITE,
border_radius=10,
border=ft.border.all(1, ft.Colors.GREY_300),
)
def create_pdf_preview_section(self) -> ft.Container:
"""PDF 미리보기 섹션 생성"""
# 미리보기 컨테이너
self.pdf_preview_container = ft.Container(
content=ft.Column([
ft.Icon(
ft.Icons.PICTURE_AS_PDF,
size=100,
color=ft.Colors.GREY_400
),
ft.Text(
"PDF 미리보기",
size=14,
color=ft.Colors.GREY_600
)
], alignment=ft.MainAxisAlignment.CENTER),
width=300,
height=400,
bgcolor=ft.Colors.GREY_100,
border_radius=8,
border=ft.border.all(1, ft.Colors.GREY_300),
alignment=ft.alignment.center,
)
# 페이지 네비게이션
self.prev_button = ft.IconButton(
icon=ft.Icons.ARROW_BACK,
disabled=True,
)
self.page_nav_text = ft.Text("1 / 1", size=14)
self.next_button = ft.IconButton(
icon=ft.Icons.ARROW_FORWARD,
disabled=True,
)
page_nav = ft.Row([
self.prev_button,
self.page_nav_text,
self.next_button,
], alignment=ft.MainAxisAlignment.CENTER)
return ft.Container(
content=ft.Column([
ft.Text(
"👁️ PDF 미리보기",
size=18,
weight=ft.FontWeight.BOLD,
color=ft.Colors.INDIGO_800
),
ft.Divider(),
self.pdf_preview_container,
page_nav,
], alignment=ft.MainAxisAlignment.START),
padding=20,
margin=10,
bgcolor=ft.Colors.WHITE,
border_radius=10,
border=ft.border.all(1, ft.Colors.GREY_300),
)
# 이벤트 핸들러들
def on_select_file_click(self, e):
"""파일 선택 버튼 클릭 핸들러"""
self.file_picker.pick_files(
allowed_extensions=["pdf"],
allow_multiple=False
)
def on_file_selected(self, e: ft.FilePickerResultEvent):
"""파일 선택 결과 핸들러"""
if e.files:
file = e.files[0]
self.current_pdf_path = file.path
# 파일 검증
if self.pdf_processor.validate_pdf_file(self.current_pdf_path):
# PDF 정보 조회
self.current_pdf_info = self.pdf_processor.get_pdf_info(self.current_pdf_path)
# 파일 크기 정보 추가
file_size_mb = self.current_pdf_info['file_size'] / (1024 * 1024)
file_info = f"📄 {file.name} ({self.current_pdf_info['page_count']}페이지, {file_size_mb:.1f}MB)"
self.selected_file_text.value = file_info
self.selected_file_text.color = ft.Colors.GREEN_600
self.upload_button.disabled = False
# 페이지 네비게이션 업데이트
self.page_nav_text.value = f"1 / {self.current_pdf_info['page_count']}"
logger.info(f"PDF 파일 선택됨: {file.name}")
else:
self.selected_file_text.value = "❌ 유효하지 않은 PDF 파일입니다"
self.selected_file_text.color = ft.Colors.RED_600
self.upload_button.disabled = True
self.current_pdf_path = None
self.current_pdf_info = None
else:
self.selected_file_text.value = "선택된 파일이 없습니다"
self.selected_file_text.color = ft.Colors.GREY_600
self.upload_button.disabled = True
self.current_pdf_path = None
self.current_pdf_info = None
self.page.update()
def on_analysis_mode_change(self, e):
"""분석 모드 변경 핸들러"""
if e.control.value == "custom":
self.custom_prompt.visible = True
else:
self.custom_prompt.visible = False
self.page.update()
def on_organization_change(self, e):
"""조직 선택 변경 핸들러"""
selected_org = e.control.value
logger.info(f"조직 선택 변경: {selected_org}")
# 필요한 경우 추가 작업 수행
self.page.update()
def on_analysis_start_click(self, e):
"""분석 시작 버튼 클릭 핸들러"""
if not self.current_pdf_path or not self.gemini_analyzer:
return
# 분석을 별도 스레드에서 실행
threading.Thread(target=self.run_analysis, daemon=True).start()
def on_save_text_click(self, e):
"""텍스트 저장 버튼 클릭 핸들러"""
self._save_results("text")
def on_save_json_click(self, e):
"""JSON 저장 버튼 클릭 핸들러"""
self._save_results("json")
def _save_results(self, format_type: str):
"""결과 저장 공통 함수"""
if not self.analysis_results or not self.current_pdf_info:
self.show_error_dialog("저장 오류", "저장할 분석 결과가 없습니다.")
return
try:
# 분석 설정 정보 수집
analysis_settings = {
"페이지_선택": self.page_selector.value,
"분석_모드": self.analysis_mode.value,
"사용자_정의_프롬프트": self.custom_prompt.value if self.analysis_mode.value == "custom" else None,
"분석_시간": DateTimeUtils.get_timestamp()
}
if format_type == "text":
# 텍스트 파일로 저장
saved_path = self.result_saver.save_analysis_results(
pdf_filename=self.current_pdf_info['filename'],
analysis_results=self.analysis_results,
pdf_info=self.current_pdf_info,
analysis_settings=analysis_settings
)
if saved_path:
self.show_info_dialog(
"저장 완료",
f"분석 결과가 텍스트 파일로 저장되었습니다:\\n\\n{saved_path}"
)
else:
self.show_error_dialog("저장 실패", "텍스트 파일 저장 중 오류가 발생했습니다.")
elif format_type == "json":
# JSON 파일로 저장
saved_path = self.result_saver.save_analysis_json(
pdf_filename=self.current_pdf_info['filename'],
analysis_results=self.analysis_results,
pdf_info=self.current_pdf_info,
analysis_settings=analysis_settings
)
if saved_path:
self.show_info_dialog(
"저장 완료",
f"분석 결과가 JSON 파일로 저장되었습니다:\\n\\n{saved_path}"
)
else:
self.show_error_dialog("저장 실패", "JSON 파일 저장 중 오류가 발생했습니다.")
except Exception as e:
logger.error(f"결과 저장 중 오류: {e}")
self.show_error_dialog("저장 오류", f"결과 저장 중 오류가 발생했습니다:\\n{str(e)}")
def run_analysis(self):
"""분석 실행 (백그라운드 스레드)"""
try:
# 분석 시작 시간 기록
self.analysis_start_time = time.time()
# UI 상태 업데이트
self.update_progress_ui(True, "PDF 이미지 변환 중...")
# 조직 유형 결정
organization_type = "transportation" # 기본값
if self.organization_selector and self.organization_selector.value:
if self.organization_selector.value == "한국도로공사":
organization_type = "expressway"
else:
organization_type = "transportation"
logger.info(f"선택된 조직 유형: {organization_type}")
# 분석할 페이지 결정
if self.page_selector.value == "첫 번째 페이지":
pages_to_analyze = [0]
else: # 모든 페이지
pages_to_analyze = list(range(self.current_pdf_info['page_count']))
# 분석 프롬프트 결정
if self.analysis_mode.value == "custom":
prompt = self.custom_prompt.value or Config.DEFAULT_PROMPT
elif self.analysis_mode.value == "detailed":
prompt = "이 PDF 이미지를 자세히 분석하여 다음 정보를 제공해주세요: 1) 문서 유형, 2) 주요 내용, 3) 도면/도표 정보, 4) 텍스트 내용, 5) 기타 특징"
else: # basic
prompt = Config.DEFAULT_PROMPT
# 페이지별 분석 수행
total_pages = len(pages_to_analyze)
self.analysis_results = {}
for i, page_num in enumerate(pages_to_analyze):
# 진행률 업데이트
progress = (i + 1) / total_pages
self.update_progress_ui(
True,
f"페이지 {page_num + 1} 분석 중... ({i + 1}/{total_pages})",
progress
)
# PDF 페이지를 base64로 변환
base64_data = self.pdf_processor.pdf_page_to_base64(
self.current_pdf_path,
page_num
)
if base64_data:
# Gemini API로 분석 (조직 유형 전달)
result = self.gemini_analyzer.analyze_image_from_base64(
base64_data=base64_data,
prompt=prompt,
organization_type=organization_type
)
if result:
self.analysis_results[page_num] = result
else:
self.analysis_results[page_num] = f"페이지 {page_num + 1} 분석 실패"
else:
self.analysis_results[page_num] = f"페이지 {page_num + 1} 이미지 변환 실패"
# 결과 표시
self.display_analysis_results()
# 완료 상태로 업데이트
if self.analysis_start_time:
duration = time.time() - self.analysis_start_time
duration_str = DateTimeUtils.format_duration(duration)
self.update_progress_ui(False, f"분석 완료! (소요시간: {duration_str})", 1.0)
else:
self.update_progress_ui(False, "분석 완료!", 1.0)
except Exception as e:
logger.error(f"분석 중 오류 발생: {e}")
self.update_progress_ui(False, f"분석 오류: {str(e)}")
self.show_error_dialog("분석 오류", f"분석 중 오류가 발생했습니다:\\n{str(e)}")
def update_progress_ui(
self,
is_running: bool,
status: str,
progress: Optional[float] = None
):
"""진행률 UI 업데이트"""
def update():
self.progress_ring.visible = is_running
self.status_text.value = status
if progress is not None:
self.progress_bar.value = progress
self.progress_bar.visible = True
else:
self.progress_bar.visible = is_running
self.page.update()
# 메인 스레드에서 UI 업데이트
self.page.run_thread(update)
def display_analysis_results(self):
"""분석 결과 표시"""
def update_results():
if self.analysis_results:
# 결과 텍스트 구성 (요약 정보 포함)
result_text = "📊 분석 요약\\n"
result_text += f"- 분석된 페이지: {len(self.analysis_results)}\\n"
result_text += f"- 분석 완료 시간: {DateTimeUtils.get_timestamp()}\\n\\n"
for page_num, result in self.analysis_results.items():
result_text += f"\\n📋 페이지 {page_num + 1} 분석 결과\\n"
result_text += "=" * 50 + "\\n"
result_text += result
result_text += "\\n\\n"
self.results_text.value = result_text.strip()
# 저장 버튼 활성화
self.save_text_button.disabled = False
self.save_json_button.disabled = False
else:
self.results_text.value = "분석 결과가 없습니다."
self.save_text_button.disabled = True
self.save_json_button.disabled = True
self.page.update()
# 메인 스레드에서 UI 업데이트
self.page.run_thread(update_results)
def show_error_dialog(self, title: str, message: str):
"""오류 다이얼로그 표시"""
dialog = UIComponents.create_error_dialog(title, message)
def close_dialog(e):
dialog.open = False
self.page.update()
dialog.actions[0].on_click = close_dialog
self.page.dialog = dialog
dialog.open = True
self.page.update()
def show_info_dialog(self, title: str, message: str):
"""정보 다이얼로그 표시"""
dialog = UIComponents.create_info_dialog(title, message)
def close_dialog(e):
dialog.open = False
self.page.update()
dialog.actions[0].on_click = close_dialog
self.page.dialog = dialog
dialog.open = True
self.page.update()
def main(page: ft.Page):
"""메인 함수"""
try:
# 애플리케이션 초기화
app = PDFAnalyzerApp(page)
# UI 구성
app.build_ui()
logger.info("애플리케이션 시작 완료")
except Exception as e:
logger.error(f"애플리케이션 시작 실패: {e}")
# 간단한 오류 페이지 표시
page.add(
ft.Container(
content=ft.Column([
ft.Text("애플리케이션 초기화 오류", size=24, weight=ft.FontWeight.BOLD),
ft.Text(f"오류 내용: {str(e)}", size=16),
ft.Text("설정을 확인하고 다시 시도하세요.", size=14),
], alignment=ft.MainAxisAlignment.CENTER),
alignment=ft.alignment.center,
expand=True,
)
)
if __name__ == "__main__":
# 애플리케이션 실행
ft.app(
target=main,
view=ft.AppView.FLET_APP,
upload_dir="uploads",
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
간단한 배치 처리 앱 실행기
getcode.py 스타일의 간단한 PDF 분석을 여러 파일에 적용
실행 방법:
python run_simple_batch.py
"""
import subprocess
import sys
import os
def run_simple_batch_app():
"""간단한 배치 분석 앱 실행"""
try:
# 현재 디렉토리로 이동
os.chdir("D:/MYCLAUDE_PROJECT/fletimageanalysis")
print("🚀 간단한 배치 PDF 분석기 시작 중...")
print("📂 작업 디렉토리:", os.getcwd())
# simple_batch_analyzer_app.py 실행
result = subprocess.run([
sys.executable,
"simple_batch_analyzer_app.py"
], check=True)
return result.returncode == 0
except subprocess.CalledProcessError as e:
print(f"❌ 실행 중 오류 발생: {e}")
return False
except FileNotFoundError:
print("❌ simple_batch_analyzer_app.py 파일을 찾을 수 없습니다.")
return False
except Exception as e:
print(f"❌ 예기치 않은 오류: {e}")
return False
if __name__ == "__main__":
print("=" * 50)
print("📊 간단한 PDF 배치 분석기")
print("🎯 getcode.py 스타일 → 여러 파일 → CSV 출력")
print("=" * 50)
success = run_simple_batch_app()
if success:
print("✅ 애플리케이션이 성공적으로 실행되었습니다!")
else:
print("❌ 애플리케이션 실행에 실패했습니다.")
print("\n🔧 해결 방법:")
print("1. GEMINI_API_KEY 환경변수가 설정되어 있는지 확인")
print("2. requirements.txt 패키지들이 설치되어 있는지 확인")
print("3. D:/MYCLAUDE_PROJECT/fletimageanalysis 폴더에서 실행")

View File

@@ -0,0 +1,429 @@
"""
간단한 다중 파일 PDF 분석 UI
getcode.py 스타일의 간단한 분석을 여러 파일에 적용하는 Flet 애플리케이션
Author: Claude Assistant
Created: 2025-07-14
Version: 1.0.0
Features:
- 다중 PDF 파일 선택
- getcode.py 프롬프트를 그대로 사용한 간단한 분석
- 실시간 진행률 표시
- 자동 CSV 저장
- 결과 요약 표시
"""
import asyncio
import flet as ft
import os
from datetime import datetime
from typing import List, Optional
import threading
import logging
from simple_batch_processor import SimpleBatchProcessor
from config import Config
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class SimpleBatchAnalyzerApp:
"""간단한 배치 분석 애플리케이션"""
def __init__(self, page: ft.Page):
self.page = page
self.selected_files: List[str] = []
self.processor: Optional[SimpleBatchProcessor] = None
self.is_processing = False
# UI 컴포넌트들
self.file_picker = None
self.selected_files_text = None
self.progress_bar = None
self.progress_text = None
self.analyze_button = None
self.results_text = None
self.custom_prompt_field = None
self.setup_page()
self.build_ui()
def setup_page(self):
"""페이지 기본 설정"""
self.page.title = "간단한 다중 PDF 분석기"
self.page.window.width = 900
self.page.window.height = 700
self.page.window.min_width = 800
self.page.window.min_height = 600
self.page.theme_mode = ft.ThemeMode.LIGHT
self.page.padding = 20
# API 키 확인
api_key = Config.GEMINI_API_KEY
if not api_key:
self.show_error_dialog("Gemini API 키가 설정되지 않았습니다. .env 파일을 확인해주세요.")
return
self.processor = SimpleBatchProcessor(api_key)
logger.info("간단한 배치 분석 앱 초기화 완료")
def build_ui(self):
"""UI 구성 요소 생성"""
# 제목
title = ft.Text(
"🔍 간단한 다중 PDF 분석기",
size=24,
weight=ft.FontWeight.BOLD,
color=ft.Colors.BLUE_700
)
subtitle = ft.Text(
"getcode.py 스타일의 간단한 프롬프트로 여러 PDF 파일을 분석하고 결과를 CSV로 저장합니다.",
size=14,
color=ft.Colors.GREY_700
)
# 파일 선택 섹션
self.file_picker = ft.FilePicker(
on_result=self.on_files_selected
)
self.page.overlay.append(self.file_picker)
file_select_button = ft.ElevatedButton(
"📁 PDF 파일 선택",
icon=ft.icons.FOLDER_OPEN,
on_click=self.select_files,
style=ft.ButtonStyle(
color=ft.Colors.WHITE,
bgcolor=ft.Colors.BLUE_600
)
)
self.selected_files_text = ft.Text(
"선택된 파일이 없습니다",
size=12,
color=ft.Colors.GREY_600
)
# 사용자 정의 프롬프트 섹션
self.custom_prompt_field = ft.TextField(
label="사용자 정의 프롬프트 (비워두면 기본 프롬프트 사용)",
hint_text="예: PDF 이미지를 분석하여 도면의 주요 정보를 알려주세요",
multiline=True,
min_lines=2,
max_lines=4,
width=850
)
default_prompt_text = ft.Text(
"🔸 기본 프롬프트: \"pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.\"",
size=12,
color=ft.Colors.GREY_600,
italic=True
)
# 분석 시작 섹션
self.analyze_button = ft.ElevatedButton(
"🚀 분석 시작",
icon=ft.icons.PLAY_ARROW,
on_click=self.start_analysis,
disabled=True,
style=ft.ButtonStyle(
color=ft.Colors.WHITE,
bgcolor=ft.Colors.GREEN_600
)
)
# 진행률 섹션
self.progress_bar = ft.ProgressBar(
width=850,
visible=False,
color=ft.Colors.BLUE_600,
bgcolor=ft.Colors.BLUE_100
)
self.progress_text = ft.Text(
"",
size=12,
color=ft.Colors.BLUE_700,
visible=False
)
# 결과 섹션
self.results_text = ft.Text(
"",
size=12,
color=ft.Colors.BLACK,
selectable=True
)
# 레이아웃 구성
content = ft.Column([
# 헤더
ft.Container(
content=ft.Column([title, subtitle]),
margin=ft.margin.only(bottom=20)
),
# 파일 선택
ft.Container(
content=ft.Column([
ft.Text("📁 파일 선택", size=16, weight=ft.FontWeight.BOLD),
file_select_button,
self.selected_files_text
]),
bgcolor=ft.colors.GREY_50,
padding=15,
border_radius=10,
margin=ft.margin.only(bottom=15)
),
# 프롬프트 설정
ft.Container(
content=ft.Column([
ft.Text("✏️ 프롬프트 설정", size=16, weight=ft.FontWeight.BOLD),
self.custom_prompt_field,
default_prompt_text
]),
bgcolor=ft.colors.GREY_50,
padding=15,
border_radius=10,
margin=ft.margin.only(bottom=15)
),
# 분석 시작
ft.Container(
content=ft.Column([
ft.Text("🔄 분석 실행", size=16, weight=ft.FontWeight.BOLD),
self.analyze_button,
self.progress_bar,
self.progress_text
]),
bgcolor=ft.colors.GREY_50,
padding=15,
border_radius=10,
margin=ft.margin.only(bottom=15)
),
# 결과 표시
ft.Container(
content=ft.Column([
ft.Text("📊 분석 결과", size=16, weight=ft.FontWeight.BOLD),
self.results_text
]),
bgcolor=ft.colors.GREY_50,
padding=15,
border_radius=10
)
])
# 스크롤 가능한 컨테이너로 감싸기
scrollable_content = ft.Container(
content=content,
alignment=ft.alignment.top_center
)
self.page.add(scrollable_content)
self.page.update()
def select_files(self, e):
"""파일 선택 대화상자 열기"""
self.file_picker.pick_files(
allow_multiple=True,
allowed_extensions=["pdf"],
dialog_title="분석할 PDF 파일들을 선택하세요"
)
def on_files_selected(self, e: ft.FilePickerResultEvent):
"""파일 선택 완료 후 처리"""
if e.files:
self.selected_files = [file.path for file in e.files]
file_count = len(self.selected_files)
if file_count == 1:
self.selected_files_text.value = f"{file_count}개 파일 선택됨: {os.path.basename(self.selected_files[0])}"
else:
self.selected_files_text.value = f"{file_count}개 파일 선택됨"
self.selected_files_text.color = ft.colors.GREEN_700
self.analyze_button.disabled = False
logger.info(f"{file_count}개 PDF 파일 선택완료")
else:
self.selected_files = []
self.selected_files_text.value = "선택된 파일이 없습니다"
self.selected_files_text.color = ft.colors.GREY_600
self.analyze_button.disabled = True
self.page.update()
def start_analysis(self, e):
"""분석 시작"""
if self.is_processing or not self.selected_files:
return
self.is_processing = True
self.analyze_button.disabled = True
self.progress_bar.visible = True
self.progress_text.visible = True
self.progress_bar.value = 0
self.progress_text.value = "분석 준비 중..."
self.results_text.value = ""
self.page.update()
# 백그라운드에서 비동기 처리 실행
threading.Thread(target=self.run_analysis_async, daemon=True).start()
def run_analysis_async(self):
"""비동기 분석 실행"""
try:
# 새 이벤트 루프 생성 (백그라운드 스레드에서)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# 분석 실행
loop.run_until_complete(self.process_files())
except Exception as e:
logger.error(f"분석 실행 중 오류: {e}")
self.update_ui_on_error(str(e))
finally:
loop.close()
async def process_files(self):
"""파일 처리 실행"""
try:
# 사용자 정의 프롬프트 확인
custom_prompt = self.custom_prompt_field.value.strip()
if not custom_prompt:
custom_prompt = None
# 진행률 콜백 함수
def progress_callback(current: int, total: int, status: str):
progress_value = current / total
self.update_progress(progress_value, f"{current}/{total} - {status}")
# 배치 처리 실행
results = await self.processor.process_multiple_pdf_files(
pdf_file_paths=self.selected_files,
custom_prompt=custom_prompt,
max_concurrent_files=2, # 안정성을 위해 낮게 설정
progress_callback=progress_callback
)
# 결과 요약
summary = self.processor.get_processing_summary()
self.update_ui_on_completion(summary)
except Exception as e:
logger.error(f"파일 처리 중 오류: {e}")
self.update_ui_on_error(str(e))
def update_progress(self, value: float, text: str):
"""진행률 업데이트 (스레드 안전)"""
def update():
self.progress_bar.value = value
self.progress_text.value = text
self.page.update()
self.page.run_thread_safe(update)
def update_ui_on_completion(self, summary: dict):
"""분석 완료 시 UI 업데이트"""
def update():
self.progress_bar.visible = False
self.progress_text.visible = False
self.analyze_button.disabled = False
self.is_processing = False
# 결과 요약 텍스트 생성
result_text = "🎉 분석 완료!\n\n"
result_text += f"📊 처리 요약:\n"
result_text += f"• 전체 파일: {summary.get('total_files', 0)}\n"
result_text += f"• 성공: {summary.get('success_files', 0)}\n"
result_text += f"• 실패: {summary.get('failed_files', 0)}\n"
result_text += f"• 성공률: {summary.get('success_rate', 0)}%\n"
result_text += f"• 전체 처리 시간: {summary.get('total_processing_time', 0)}\n"
result_text += f"• 평균 처리 시간: {summary.get('avg_processing_time', 0)}초/파일\n"
result_text += f"• 전체 파일 크기: {summary.get('total_file_size_mb', 0)}MB\n\n"
result_text += "💾 결과가 CSV 파일로 자동 저장되었습니다.\n"
result_text += "파일 위치: D:/MYCLAUDE_PROJECT/fletimageanalysis/results/"
self.results_text.value = result_text
self.results_text.color = ft.colors.GREEN_700
self.page.update()
# 완료 알림
self.show_success_dialog("분석이 완료되었습니다!", result_text)
self.page.run_thread_safe(update)
def update_ui_on_error(self, error_message: str):
"""오류 발생 시 UI 업데이트"""
def update():
self.progress_bar.visible = False
self.progress_text.visible = False
self.analyze_button.disabled = False
self.is_processing = False
self.results_text.value = f"❌ 분석 중 오류가 발생했습니다:\n{error_message}"
self.results_text.color = ft.colors.RED_700
self.page.update()
self.show_error_dialog("분석 오류", error_message)
self.page.run_thread_safe(update)
def show_success_dialog(self, title: str, message: str):
"""성공 다이얼로그 표시"""
def show():
dialog = ft.AlertDialog(
title=ft.Text(title),
content=ft.Text(message, selectable=True),
actions=[
ft.TextButton("확인", on_click=lambda e: self.close_dialog())
]
)
self.page.overlay.append(dialog)
dialog.open = True
self.page.update()
self.page.run_thread_safe(show)
def show_error_dialog(self, title: str, message: str = ""):
"""오류 다이얼로그 표시"""
def show():
dialog = ft.AlertDialog(
title=ft.Text(title, color=ft.colors.RED_700),
content=ft.Text(message if message else title, selectable=True),
actions=[
ft.TextButton("확인", on_click=lambda e: self.close_dialog())
]
)
self.page.overlay.append(dialog)
dialog.open = True
self.page.update()
self.page.run_thread_safe(show)
def close_dialog(self):
"""다이얼로그 닫기"""
if self.page.overlay:
for overlay in self.page.overlay:
if isinstance(overlay, ft.AlertDialog):
overlay.open = False
self.page.update()
async def main(page: ft.Page):
"""메인 함수"""
app = SimpleBatchAnalyzerApp(page)
if __name__ == "__main__":
# Flet 애플리케이션 실행
ft.app(target=main, view=ft.AppView.FLET_APP)

View File

@@ -0,0 +1,378 @@
"""
간단한 다중 파일 배치 처리 모듈
getcode.py 스타일의 간단한 분석을 여러 파일에 적용하고 결과를 CSV로 저장합니다.
Author: Claude Assistant
Created: 2025-07-14
Version: 1.0.0
"""
import asyncio
import os
import pandas as pd
import base64
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Optional, Callable
from dataclasses import dataclass
import logging
from simple_gemini_analyzer import SimpleGeminiAnalyzer
from pdf_processor import PDFProcessor
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@dataclass
class SimpleBatchResult:
"""간단한 배치 처리 결과"""
file_path: str
file_name: str
file_size_mb: float
processing_time_seconds: float
success: bool
# 분석 결과
analysis_result: Optional[str] = None
analysis_timestamp: Optional[str] = None
prompt_used: Optional[str] = None
model_used: Optional[str] = None
error_message: Optional[str] = None
# 메타데이터
processed_at: Optional[str] = None
class SimpleBatchProcessor:
"""
간단한 다중 파일 배치 처리기
getcode.py 스타일의 분석을 여러 PDF 파일에 적용합니다.
"""
def __init__(self, gemini_api_key: str):
"""
배치 처리기 초기화
Args:
gemini_api_key: Gemini API 키
"""
self.gemini_api_key = gemini_api_key
self.analyzer = SimpleGeminiAnalyzer(gemini_api_key)
self.pdf_processor = PDFProcessor()
self.results: List[SimpleBatchResult] = []
self.current_progress = 0
self.total_files = 0
logger.info("간단한 배치 처리기 초기화 완료")
async def process_multiple_pdf_files(
self,
pdf_file_paths: List[str],
output_csv_path: Optional[str] = None,
custom_prompt: Optional[str] = None,
max_concurrent_files: int = 3,
progress_callback: Optional[Callable[[int, int, str], None]] = None
) -> List[SimpleBatchResult]:
"""
여러 PDF 파일을 배치로 처리하고 결과를 CSV로 저장
Args:
pdf_file_paths: 처리할 PDF 파일 경로 리스트
output_csv_path: 출력 CSV 파일 경로 (None인 경우 자동 생성)
custom_prompt: 사용자 정의 프롬프트 (None인 경우 기본 프롬프트 사용)
max_concurrent_files: 동시 처리할 최대 파일 수
progress_callback: 진행률 콜백 함수 (current, total, status)
Returns:
처리 결과 리스트
"""
self.results = []
self.total_files = len(pdf_file_paths)
self.current_progress = 0
logger.info(f"간단한 배치 처리 시작: {self.total_files}개 PDF 파일")
if not pdf_file_paths:
logger.warning("처리할 파일이 없습니다.")
return []
# 동시 처리 제한을 위한 세마포어
semaphore = asyncio.Semaphore(max_concurrent_files)
# 각 파일에 대한 처리 태스크 생성
tasks = []
for i, file_path in enumerate(pdf_file_paths):
task = self._process_single_pdf_with_semaphore(
semaphore, file_path, custom_prompt, progress_callback, i + 1
)
tasks.append(task)
# 모든 파일 처리 완료까지 대기
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"배치 처리 완료: {len(self.results)}개 결과")
# CSV 저장
if output_csv_path or self.results:
csv_path = output_csv_path or self._generate_default_csv_path()
await self.save_results_to_csv(csv_path)
return self.results
async def _process_single_pdf_with_semaphore(
self,
semaphore: asyncio.Semaphore,
file_path: str,
custom_prompt: Optional[str],
progress_callback: Optional[Callable[[int, int, str], None]],
file_number: int
) -> None:
"""세마포어를 사용하여 단일 PDF 파일 처리"""
async with semaphore:
result = await self._process_single_pdf_file(file_path, custom_prompt)
self.results.append(result)
self.current_progress += 1
if progress_callback:
status = f"처리 완료: {result.file_name}"
if not result.success:
status = f"처리 실패: {result.file_name}"
progress_callback(self.current_progress, self.total_files, status)
async def _process_single_pdf_file(
self,
file_path: str,
custom_prompt: Optional[str] = None
) -> SimpleBatchResult:
"""
단일 PDF 파일 처리
Args:
file_path: PDF 파일 경로
custom_prompt: 사용자 정의 프롬프트
Returns:
처리 결과
"""
start_time = asyncio.get_event_loop().time()
file_name = os.path.basename(file_path)
try:
# 파일 정보 수집
file_size = os.path.getsize(file_path)
file_size_mb = round(file_size / (1024 * 1024), 2)
logger.info(f"PDF 파일 처리 시작: {file_name} ({file_size_mb}MB)")
# PDF를 이미지로 변환 (첫 번째 페이지만)
images = self.pdf_processor.convert_to_images(file_path, max_pages=1)
if not images:
raise ValueError("PDF를 이미지로 변환할 수 없습니다")
# 첫 번째 페이지 이미지를 바이트로 변환
first_page_image = images[0]
image_bytes = self.pdf_processor.image_to_bytes(first_page_image)
# Gemini API로 분석 (비동기 처리)
loop = asyncio.get_event_loop()
analysis_result = await loop.run_in_executor(
None,
self.analyzer.analyze_image_from_bytes,
image_bytes,
custom_prompt,
"image/png"
)
if analysis_result and analysis_result['success']:
result = SimpleBatchResult(
file_path=file_path,
file_name=file_name,
file_size_mb=file_size_mb,
processing_time_seconds=0, # 나중에 계산
success=True,
analysis_result=analysis_result['analysis_result'],
analysis_timestamp=analysis_result['timestamp'],
prompt_used=analysis_result['prompt_used'],
model_used=analysis_result['model'],
error_message=None,
processed_at=datetime.now().isoformat()
)
logger.info(f"분석 성공: {file_name}")
else:
error_msg = analysis_result['error_message'] if analysis_result else "알 수 없는 오류"
result = SimpleBatchResult(
file_path=file_path,
file_name=file_name,
file_size_mb=file_size_mb,
processing_time_seconds=0,
success=False,
analysis_result=None,
error_message=error_msg,
processed_at=datetime.now().isoformat()
)
logger.error(f"분석 실패: {file_name} - {error_msg}")
except Exception as e:
error_msg = f"파일 처리 오류: {str(e)}"
logger.error(f"파일 처리 오류 ({file_name}): {error_msg}")
result = SimpleBatchResult(
file_path=file_path,
file_name=file_name,
file_size_mb=0,
processing_time_seconds=0,
success=False,
error_message=error_msg,
processed_at=datetime.now().isoformat()
)
finally:
# 처리 시간 계산
end_time = asyncio.get_event_loop().time()
result.processing_time_seconds = round(end_time - start_time, 2)
return result
async def save_results_to_csv(self, csv_path: str) -> None:
"""
처리 결과를 CSV 파일로 저장
Args:
csv_path: 출력 CSV 파일 경로
"""
try:
if not self.results:
logger.warning("저장할 결과가 없습니다.")
return
# 결과를 DataFrame으로 변환
data_rows = []
for result in self.results:
row = {
'file_name': result.file_name,
'file_path': result.file_path,
'file_size_mb': result.file_size_mb,
'processing_time_seconds': result.processing_time_seconds,
'success': result.success,
'analysis_result': result.analysis_result or '',
'analysis_timestamp': result.analysis_timestamp or '',
'prompt_used': result.prompt_used or '',
'model_used': result.model_used or '',
'error_message': result.error_message or '',
'processed_at': result.processed_at or ''
}
data_rows.append(row)
# DataFrame 생성
df = pd.DataFrame(data_rows)
# 컬럼 순서 정렬
column_order = [
'file_name', 'success', 'file_size_mb', 'processing_time_seconds',
'analysis_result', 'prompt_used', 'model_used', 'analysis_timestamp',
'error_message', 'processed_at', 'file_path'
]
df = df[column_order]
# 출력 디렉토리 생성
os.makedirs(os.path.dirname(csv_path), exist_ok=True)
# UTF-8 BOM으로 저장 (한글 호환성)
df.to_csv(csv_path, index=False, encoding='utf-8-sig')
logger.info(f"CSV 저장 완료: {csv_path}")
logger.info(f"{len(data_rows)}개 파일 결과 저장")
# 처리 요약 로그
success_count = sum(1 for r in self.results if r.success)
failure_count = len(self.results) - success_count
logger.info(f"처리 요약 - 성공: {success_count}개, 실패: {failure_count}")
except Exception as e:
logger.error(f"CSV 저장 오류: {str(e)}")
raise
def _generate_default_csv_path(self) -> str:
"""기본 CSV 파일 경로 생성"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
results_dir = "D:/MYCLAUDE_PROJECT/fletimageanalysis/results"
os.makedirs(results_dir, exist_ok=True)
return os.path.join(results_dir, f"simple_batch_analysis_{timestamp}.csv")
def get_processing_summary(self) -> Dict[str, Any]:
"""처리 결과 요약 정보 반환"""
if not self.results:
return {}
total_files = len(self.results)
success_files = sum(1 for r in self.results if r.success)
failed_files = total_files - success_files
total_processing_time = sum(r.processing_time_seconds for r in self.results)
avg_processing_time = total_processing_time / total_files if total_files > 0 else 0
total_file_size = sum(r.file_size_mb for r in self.results)
return {
'total_files': total_files,
'success_files': success_files,
'failed_files': failed_files,
'total_processing_time': round(total_processing_time, 2),
'avg_processing_time': round(avg_processing_time, 2),
'total_file_size_mb': round(total_file_size, 2),
'success_rate': round((success_files / total_files) * 100, 1) if total_files > 0 else 0
}
# 사용 예시
async def main():
"""사용 예시 함수"""
# API 키 설정 (실제 사용 시에는 .env 파일이나 환경변수 사용)
api_key = os.environ.get("GEMINI_API_KEY")
if not api_key:
print("❌ GEMINI_API_KEY 환경변수를 설정해주세요.")
return
# 배치 처리기 초기화
processor = SimpleBatchProcessor(api_key)
# 진행률 콜백 함수
def progress_callback(current: int, total: int, status: str):
percentage = (current / total) * 100
print(f"진행률: {current}/{total} ({percentage:.1f}%) - {status}")
# 샘플 PDF 파일 경로 (실제 사용 시에는 실제 파일 경로로 교체)
pdf_files = [
"D:/MYCLAUDE_PROJECT/fletimageanalysis/testsample/sample1.pdf",
"D:/MYCLAUDE_PROJECT/fletimageanalysis/testsample/sample2.pdf",
# 더 많은 파일 추가 가능
]
# 실제 존재하는 PDF 파일만 필터링
existing_files = [f for f in pdf_files if os.path.exists(f)]
if not existing_files:
print("❌ 처리할 PDF 파일이 없습니다.")
return
# 배치 처리 실행
results = await processor.process_multiple_pdf_files(
pdf_file_paths=existing_files,
custom_prompt=None, # 기본 프롬프트 사용
max_concurrent_files=2,
progress_callback=progress_callback
)
# 처리 요약 출력
summary = processor.get_processing_summary()
print("\n=== 처리 요약 ===")
for key, value in summary.items():
print(f"{key}: {value}")
if __name__ == "__main__":
# 비동기 메인 함수 실행
asyncio.run(main())

View File

@@ -0,0 +1,235 @@
# To run this code you need to install the following dependencies:
# pip install google-genai
"""
간단한 Gemini 이미지 분석기
getcode.py의 프롬프트를 그대로 사용하여 PDF 이미지를 분석합니다.
Author: Claude Assistant
Created: 2025-07-14
Version: 1.0.0
Based on: getcode.py (original user code)
"""
import base64
import os
import logging
from google import genai
from google.genai import types
from typing import Optional, Dict, Any
from datetime import datetime
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class SimpleGeminiAnalyzer:
"""
getcode.py 스타일의 간단한 Gemini 이미지 분석기
구조화된 JSON 스키마 대신 자연어 텍스트 분석을 수행합니다.
"""
def __init__(self, api_key: Optional[str] = None):
"""
간단한 Gemini 분석기 초기화
Args:
api_key: Gemini API 키 (None인 경우 환경변수에서 로드)
"""
self.api_key = api_key or os.environ.get("GEMINI_API_KEY")
self.model = "gemini-2.5-flash" # getcode.py와 동일한 모델
# getcode.py와 동일한 프롬프트 사용
self.default_prompt = "pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘."
if not self.api_key:
raise ValueError("Gemini API 키가 설정되지 않았습니다. GEMINI_API_KEY 환경변수를 설정하거나 api_key 매개변수를 제공하세요.")
try:
self.client = genai.Client(api_key=self.api_key)
logger.info(f"Simple Gemini 클라이언트 초기화 완료 (모델: {self.model})")
except Exception as e:
logger.error(f"Gemini 클라이언트 초기화 실패: {e}")
raise
def analyze_pdf_image(
self,
base64_data: str,
custom_prompt: Optional[str] = None,
mime_type: str = "application/pdf"
) -> Optional[Dict[str, Any]]:
"""
Base64로 인코딩된 PDF 데이터를 분석합니다.
getcode.py와 동일한 방식으로 동작합니다.
Args:
base64_data: Base64로 인코딩된 PDF 데이터
custom_prompt: 사용자 정의 프롬프트 (None인 경우 기본 프롬프트 사용)
mime_type: 파일 MIME 타입
Returns:
분석 결과 딕셔너리 또는 None (실패 시)
{
'analysis_result': str, # 분석 결과 텍스트
'success': bool, # 성공 여부
'timestamp': str, # 분석 시각
'prompt_used': str, # 사용된 프롬프트
'model': str, # 사용된 모델
'error_message': str # 오류 메시지 (실패 시)
}
"""
try:
prompt = custom_prompt or self.default_prompt
logger.info(f"이미지 분석 시작 - 프롬프트: {prompt[:50]}...")
# getcode.py와 동일한 구조로 컨텐츠 생성
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
mime_type=mime_type,
data=base64.b64decode(base64_data),
),
types.Part.from_text(text=prompt),
],
),
]
# getcode.py와 동일한 설정 사용
generate_content_config = types.GenerateContentConfig(
temperature=0,
top_p=0.05,
thinking_config=types.ThinkingConfig(
thinking_budget=0,
),
response_mime_type="text/plain", # JSON이 아닌 일반 텍스트
)
# 스트리밍이 아닌 일반 응답으로 수정 (CSV 저장을 위해)
response = self.client.models.generate_content(
model=self.model,
contents=contents,
config=generate_content_config,
)
if response and hasattr(response, 'text') and response.text:
result = {
'analysis_result': response.text.strip(),
'success': True,
'timestamp': datetime.now().isoformat(),
'prompt_used': prompt,
'model': self.model,
'error_message': None
}
logger.info(f"분석 완료: {len(response.text)} 문자")
return result
else:
logger.error("API 응답에서 텍스트를 찾을 수 없습니다.")
return {
'analysis_result': None,
'success': False,
'timestamp': datetime.now().isoformat(),
'prompt_used': prompt,
'model': self.model,
'error_message': "API 응답에서 텍스트를 찾을 수 없습니다."
}
except Exception as e:
error_msg = f"이미지 분석 중 오류 발생: {str(e)}"
logger.error(error_msg)
return {
'analysis_result': None,
'success': False,
'timestamp': datetime.now().isoformat(),
'prompt_used': custom_prompt or self.default_prompt,
'model': self.model,
'error_message': error_msg
}
def analyze_image_from_bytes(
self,
image_bytes: bytes,
custom_prompt: Optional[str] = None,
mime_type: str = "image/png"
) -> Optional[Dict[str, Any]]:
"""
바이트 형태의 이미지를 직접 분석합니다.
Args:
image_bytes: 이미지 바이트 데이터
custom_prompt: 사용자 정의 프롬프트
mime_type: 이미지 MIME 타입
Returns:
분석 결과 딕셔너리
"""
try:
# 바이트를 base64로 인코딩
base64_data = base64.b64encode(image_bytes).decode('utf-8')
return self.analyze_pdf_image(base64_data, custom_prompt, mime_type)
except Exception as e:
error_msg = f"이미지 바이트 분석 중 오류: {str(e)}"
logger.error(error_msg)
return {
'analysis_result': None,
'success': False,
'timestamp': datetime.now().isoformat(),
'prompt_used': custom_prompt or self.default_prompt,
'model': self.model,
'error_message': error_msg
}
def validate_api_connection(self) -> bool:
"""API 연결 상태를 확인합니다."""
try:
test_content = [
types.Content(
role="user",
parts=[types.Part.from_text(text="안녕하세요")]
)
]
config = types.GenerateContentConfig(
temperature=0,
response_mime_type="text/plain"
)
response = self.client.models.generate_content(
model=self.model,
contents=test_content,
config=config
)
if response and hasattr(response, 'text'):
logger.info("Simple Gemini API 연결 테스트 성공")
return True
else:
logger.error("Simple Gemini API 연결 테스트 실패")
return False
except Exception as e:
logger.error(f"Simple Gemini API 연결 테스트 중 오류: {e}")
return False
# 사용 예시
if __name__ == "__main__":
# 테스트 코드
analyzer = SimpleGeminiAnalyzer()
# API 연결 테스트
if analyzer.validate_api_connection():
print("✅ API 연결 성공")
# 샘플 이미지 분석 (실제 사용 시에는 PDF 파일에서 추출한 이미지 사용)
sample_text = "테스트용 간단한 텍스트 분석"
# 실제 사용 예시:
# with open("sample.pdf", "rb") as f:
# pdf_bytes = f.read()
# base64_data = base64.b64encode(pdf_bytes).decode('utf-8')
# result = analyzer.analyze_pdf_image(base64_data)
# print("분석 결과:", result)
else:
print("❌ API 연결 실패")

View File

@@ -0,0 +1,633 @@
# -*- coding: utf-8 -*-
"""
DXF 파일 처리 모듈
ezdxf 라이브러리를 사용하여 DXF 파일에서 도곽 정보 및 Block Reference/Attribute Reference를 추출
"""
import os
import json
import logging
from typing import Dict, List, Optional, Tuple, Union, Any
from dataclasses import dataclass, asdict, field
try:
import ezdxf
from ezdxf.document import Drawing
from ezdxf.entities import Insert, Attrib, AttDef, Text, MText
from ezdxf.layouts import BlockLayout, Modelspace
EZDXF_AVAILABLE = True
except ImportError:
EZDXF_AVAILABLE = False
logging.warning("ezdxf 라이브러리가 설치되지 않았습니다. DXF 기능이 비활성화됩니다.")
from config import Config
@dataclass
class BoundingBox:
"""바운딩 박스 정보를 담는 데이터 클래스"""
min_x: float
min_y: float
max_x: float
max_y: float
@property
def width(self) -> float:
return self.max_x - self.min_x
@property
def height(self) -> float:
return self.max_y - self.min_y
@property
def center(self) -> Tuple[float, float]:
return ((self.min_x + self.max_x) / 2, (self.min_y + self.max_y) / 2)
@dataclass
class AttributeInfo:
"""속성 정보를 담는 데이터 클래스 - 모든 DXF 속성 포함"""
tag: str
text: str
position: Tuple[float, float, float] # insert point (x, y, z)
height: float
width: float
rotation: float
layer: str
bounding_box: Optional[BoundingBox] = None
# 추가 DXF 속성들
prompt: Optional[str] = None # 프롬프트 문자열 (ATTDEF에서 가져옴)
style: Optional[str] = None # 텍스트 스타일
invisible: bool = False # 보이지 않는 속성
const: bool = False # 상수 속성
verify: bool = False # 검증 필요
preset: bool = False # 프롬프트 없이 삽입
align_point: Optional[Tuple[float, float, float]] = None # 정렬점
halign: int = 0 # 수평 정렬 (0=LEFT, 2=RIGHT, etc.)
valign: int = 0 # 수직 정렬 (0=BASELINE, 1=BOTTOM, etc.)
text_generation_flag: int = 0 # 텍스트 생성 플래그
oblique_angle: float = 0.0 # 기울기 각도
width_factor: float = 1.0 # 폭 비율
color: Optional[int] = None # 색상 코드
linetype: Optional[str] = None # 선 타입
lineweight: Optional[int] = None # 선 굵기
# 좌표 정보
insert_x: float = 0.0 # X 좌표
insert_y: float = 0.0 # Y 좌표
insert_z: float = 0.0 # Z 좌표
# 계산된 정보
estimated_width: float = 0.0 # 추정 텍스트 폭
entity_handle: Optional[str] = None # DXF 엔티티 핸들
@dataclass
class BlockInfo:
"""블록 정보를 담는 데이터 클래스"""
name: str
position: Tuple[float, float, float]
scale: Tuple[float, float, float]
rotation: float
layer: str
attributes: List[AttributeInfo]
bounding_box: Optional[BoundingBox] = None
@dataclass
class TitleBlockInfo:
"""도곽 정보를 담는 데이터 클래스"""
drawing_name: Optional[str] = None # 도면명
drawing_number: Optional[str] = None # 도면번호
construction_field: Optional[str] = None # 건설분야
construction_stage: Optional[str] = None # 건설단계
scale: Optional[str] = None # 축척
project_name: Optional[str] = None # 프로젝트명
designer: Optional[str] = None # 설계자
date: Optional[str] = None # 날짜
revision: Optional[str] = None # 리비전
location: Optional[str] = None # 위치
bounding_box: Optional[BoundingBox] = None # 도곽 전체 바운딩 박스
block_name: Optional[str] = None # 도곽 블록 이름
# 모든 attributes 정보 저장
all_attributes: List[AttributeInfo] = field(default_factory=list) # 도곽의 모든 속성 정보 리스트
attributes_count: int = 0 # 속성 개수
# 추가 메타데이터
block_position: Optional[Tuple[float, float, float]] = None # 블록 위치
block_scale: Optional[Tuple[float, float, float]] = None # 블록 스케일
block_rotation: float = 0.0 # 블록 회전각
block_layer: Optional[str] = None # 블록 레이어
def __post_init__(self):
"""초기화 후 처리"""
self.attributes_count = len(self.all_attributes)
class DXFProcessor:
"""DXF 파일 처리 클래스"""
# 도곽 식별을 위한 키워드 정의
TITLE_BLOCK_KEYWORDS = {
'건설분야': ['construction_field', 'field', '분야', '공사', 'category'],
'건설단계': ['construction_stage', 'stage', '단계', 'phase'],
'도면명': ['drawing_name', 'title', '제목', 'name', ''],
'축척': ['scale', '축척', 'ratio', '비율'],
'도면번호': ['drawing_number', 'number', '번호', 'no', 'dwg'],
'설계자': ['designer', '설계', 'design', 'drawn'],
'프로젝트': ['project', '사업', '공사명'],
'날짜': ['date', '일자', '작성일'],
'리비전': ['revision', 'rev', '개정'],
'위치': ['location', '위치', '지역']
}
def __init__(self):
"""DXF 처리기 초기화"""
self.logger = logging.getLogger(__name__)
if not EZDXF_AVAILABLE:
raise ImportError("ezdxf 라이브러리가 필요합니다. 'pip install ezdxf'로 설치하세요.")
def validate_dxf_file(self, file_path: str) -> bool:
"""DXF 파일 유효성 검사"""
try:
if not os.path.exists(file_path):
self.logger.error(f"파일이 존재하지 않습니다: {file_path}")
return False
if not file_path.lower().endswith('.dxf'):
self.logger.error(f"DXF 파일이 아닙니다: {file_path}")
return False
# ezdxf로 파일 읽기 시도
doc = ezdxf.readfile(file_path)
if doc is None:
return False
self.logger.info(f"DXF 파일 유효성 검사 성공: {file_path}")
return True
except ezdxf.DXFStructureError as e:
self.logger.error(f"DXF 구조 오류: {e}")
return False
except Exception as e:
self.logger.error(f"DXF 파일 검증 중 오류: {e}")
return False
def load_dxf_document(self, file_path: str) -> Optional[Drawing]:
"""DXF 문서 로드"""
try:
doc = ezdxf.readfile(file_path)
self.logger.info(f"DXF 문서 로드 성공: {file_path}")
return doc
except Exception as e:
self.logger.error(f"DXF 문서 로드 실패: {e}")
return None
def calculate_text_bounding_box(self, entity: Union[Text, MText, Attrib]) -> Optional[BoundingBox]:
"""텍스트 엔티티의 바운딩 박스 계산"""
try:
if hasattr(entity, 'dxf'):
# 텍스트 위치 가져오기
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
height = getattr(entity.dxf, 'height', 1.0)
# 텍스트 내용 길이 추정 (폰트에 따라 다르지만 대략적으로)
text_content = ""
if hasattr(entity.dxf, 'text'):
text_content = entity.dxf.text
elif hasattr(entity, 'plain_text'):
text_content = entity.plain_text()
# 텍스트 너비 추정 (높이의 0.6배 * 글자 수)
estimated_width = len(text_content) * height * 0.6
# 회전 고려 (기본값)
rotation = getattr(entity.dxf, 'rotation', 0)
# 바운딩 박스 계산
x, y = insert_point[0], insert_point[1]
return BoundingBox(
min_x=x,
min_y=y,
max_x=x + estimated_width,
max_y=y + height
)
except Exception as e:
self.logger.warning(f"텍스트 바운딩 박스 계산 실패: {e}")
return None
def extract_block_references(self, doc: Drawing) -> List[BlockInfo]:
"""문서에서 모든 Block Reference 추출"""
block_refs = []
try:
# 모델스페이스에서 INSERT 엔티티 찾기
msp = doc.modelspace()
for insert in msp.query('INSERT'):
block_info = self._process_block_reference(doc, insert)
if block_info:
block_refs.append(block_info)
# 페이퍼스페이스도 확인
for layout_name in doc.layout_names_in_taborder():
if layout_name.startswith('*'): # 모델스페이스 제외
continue
try:
layout = doc.paperspace(layout_name)
for insert in layout.query('INSERT'):
block_info = self._process_block_reference(doc, insert)
if block_info:
block_refs.append(block_info)
except Exception as e:
self.logger.warning(f"레이아웃 {layout_name} 처리 중 오류: {e}")
self.logger.info(f"{len(block_refs)}개의 블록 참조를 찾았습니다.")
return block_refs
except Exception as e:
self.logger.error(f"블록 참조 추출 중 오류: {e}")
return []
def _process_block_reference(self, doc: Drawing, insert: Insert) -> Optional[BlockInfo]:
"""개별 Block Reference 처리 - ATTDEF 정보도 함께 수집"""
try:
# 블록 정보 추출
block_name = insert.dxf.name
position = (insert.dxf.insert.x, insert.dxf.insert.y, insert.dxf.insert.z)
scale = (
getattr(insert.dxf, 'xscale', 1.0),
getattr(insert.dxf, 'yscale', 1.0),
getattr(insert.dxf, 'zscale', 1.0)
)
rotation = getattr(insert.dxf, 'rotation', 0.0)
layer = getattr(insert.dxf, 'layer', '0')
# ATTDEF 정보 수집 (프롬프트 정보 포함)
attdef_info = {}
try:
block_layout = doc.blocks.get(block_name)
if block_layout:
for attdef in block_layout.query('ATTDEF'):
tag = getattr(attdef.dxf, 'tag', '')
prompt = getattr(attdef.dxf, 'prompt', '')
if tag:
attdef_info[tag] = {
'prompt': prompt,
'default_text': getattr(attdef.dxf, 'text', ''),
'position': (attdef.dxf.insert.x, attdef.dxf.insert.y, attdef.dxf.insert.z),
'height': getattr(attdef.dxf, 'height', 1.0),
'style': getattr(attdef.dxf, 'style', 'Standard'),
'invisible': getattr(attdef.dxf, 'invisible', False),
'const': getattr(attdef.dxf, 'const', False),
'verify': getattr(attdef.dxf, 'verify', False),
'preset': getattr(attdef.dxf, 'preset', False)
}
except Exception as e:
self.logger.debug(f"ATTDEF 정보 수집 실패: {e}")
# ATTRIB 속성 추출 및 ATTDEF 정보와 결합
attributes = []
for attrib in insert.attribs:
attr_info = self._extract_attribute_info(attrib)
if attr_info and attr_info.tag in attdef_info:
# ATTDEF에서 프롬프트 정보 추가
attr_info.prompt = attdef_info[attr_info.tag]['prompt']
if attr_info:
attributes.append(attr_info)
return BlockInfo(
name=block_name,
position=position,
scale=scale,
rotation=rotation,
layer=layer,
attributes=attributes
)
except Exception as e:
self.logger.warning(f"블록 참조 처리 중 오류: {e}")
return None
def _extract_attribute_info(self, attrib: Attrib) -> Optional[AttributeInfo]:
"""Attribute Reference에서 모든 정보 추출"""
try:
# 기본 속성
tag = getattr(attrib.dxf, 'tag', '')
text = getattr(attrib.dxf, 'text', '')
# 위치 정보
insert_point = getattr(attrib.dxf, 'insert', (0, 0, 0))
position = (insert_point.x if hasattr(insert_point, 'x') else insert_point[0],
insert_point.y if hasattr(insert_point, 'y') else insert_point[1],
insert_point.z if hasattr(insert_point, 'z') else insert_point[2])
# 텍스트 속성
height = getattr(attrib.dxf, 'height', 1.0)
width = getattr(attrib.dxf, 'width', 1.0)
rotation = getattr(attrib.dxf, 'rotation', 0.0)
# 레이어 및 스타일
layer = getattr(attrib.dxf, 'layer', '0')
style = getattr(attrib.dxf, 'style', 'Standard')
# 속성 플래그
invisible = getattr(attrib.dxf, 'invisible', False)
const = getattr(attrib.dxf, 'const', False)
verify = getattr(attrib.dxf, 'verify', False)
preset = getattr(attrib.dxf, 'preset', False)
# 정렬 정보
align_point_data = getattr(attrib.dxf, 'align_point', None)
align_point = None
if align_point_data:
align_point = (align_point_data.x if hasattr(align_point_data, 'x') else align_point_data[0],
align_point_data.y if hasattr(align_point_data, 'y') else align_point_data[1],
align_point_data.z if hasattr(align_point_data, 'z') else align_point_data[2])
halign = getattr(attrib.dxf, 'halign', 0)
valign = getattr(attrib.dxf, 'valign', 0)
# 텍스트 형식
text_generation_flag = getattr(attrib.dxf, 'text_generation_flag', 0)
oblique_angle = getattr(attrib.dxf, 'oblique_angle', 0.0)
width_factor = getattr(attrib.dxf, 'width_factor', 1.0)
# 시각적 속성
color = getattr(attrib.dxf, 'color', None)
linetype = getattr(attrib.dxf, 'linetype', None)
lineweight = getattr(attrib.dxf, 'lineweight', None)
# 엔티티 핸들
entity_handle = getattr(attrib.dxf, 'handle', None)
# 텍스트 폭 추정 (높이의 0.6배 * 글자 수)
estimated_width = len(text) * height * 0.6 * width_factor
# 바운딩 박스 계산
bounding_box = self.calculate_text_bounding_box(attrib)
# 프롬프트 정보는 ATTDEF에서 가져와야 함 (필요시 별도 처리)
prompt = None
return AttributeInfo(
tag=tag,
text=text,
position=position,
height=height,
width=width,
rotation=rotation,
layer=layer,
bounding_box=bounding_box,
prompt=prompt,
style=style,
invisible=invisible,
const=const,
verify=verify,
preset=preset,
align_point=align_point,
halign=halign,
valign=valign,
text_generation_flag=text_generation_flag,
oblique_angle=oblique_angle,
width_factor=width_factor,
color=color,
linetype=linetype,
lineweight=lineweight,
insert_x=position[0],
insert_y=position[1],
insert_z=position[2],
estimated_width=estimated_width,
entity_handle=entity_handle
)
except Exception as e:
self.logger.warning(f"속성 정보 추출 중 오류: {e}")
return None
def identify_title_block(self, block_refs: List[BlockInfo]) -> Optional[TitleBlockInfo]:
"""블록 참조들 중에서 도곽을 식별하고 정보 추출"""
title_block_candidates = []
for block_ref in block_refs:
# 도곽 키워드를 포함한 속성이 있는지 확인
keyword_matches = 0
for attr in block_ref.attributes:
for keyword_group in self.TITLE_BLOCK_KEYWORDS.keys():
if self._contains_keyword(attr.tag, keyword_group) or \
self._contains_keyword(attr.text, keyword_group):
keyword_matches += 1
break
# 충분한 키워드가 매칭되면 도곽 후보로 추가
if keyword_matches >= 2: # 최소 2개 이상의 키워드 매칭
title_block_candidates.append((block_ref, keyword_matches))
if not title_block_candidates:
self.logger.warning("도곽 블록을 찾을 수 없습니다.")
return None
# 가장 많은 키워드를 포함한 블록을 도곽으로 선택
title_block_candidates.sort(key=lambda x: x[1], reverse=True)
best_candidate = title_block_candidates[0][0]
self.logger.info(f"도곽 블록 발견: {best_candidate.name} (키워드 매칭: {title_block_candidates[0][1]})")
return self._extract_title_block_info(best_candidate)
def _contains_keyword(self, text: str, keyword_group: str) -> bool:
"""텍스트에 특정 키워드 그룹의 단어가 포함되어 있는지 확인"""
if not text:
return False
text_lower = text.lower()
keywords = self.TITLE_BLOCK_KEYWORDS.get(keyword_group, [])
return any(keyword.lower() in text_lower for keyword in keywords)
def _extract_title_block_info(self, block_ref: BlockInfo) -> TitleBlockInfo:
"""도곽 블록에서 상세 정보 추출 - 모든 attributes 정보 포함"""
# TitleBlockInfo 객체 생성
title_block = TitleBlockInfo(
block_name=block_ref.name,
all_attributes=block_ref.attributes.copy(), # 모든 attributes 정보 저장
block_position=block_ref.position,
block_scale=block_ref.scale,
block_rotation=block_ref.rotation,
block_layer=block_ref.layer
)
# 속성들을 분석하여 도곽 정보 매핑
for attr in block_ref.attributes:
tag_lower = attr.tag.lower()
text_value = attr.text.strip()
if not text_value:
continue
# 각 키워드 그룹별로 매칭 시도
if self._contains_keyword(attr.tag, '도면명') or self._contains_keyword(attr.text, '도면명'):
title_block.drawing_name = text_value
elif self._contains_keyword(attr.tag, '도면번호') or self._contains_keyword(attr.text, '도면번호'):
title_block.drawing_number = text_value
elif self._contains_keyword(attr.tag, '건설분야') or self._contains_keyword(attr.text, '건설분야'):
title_block.construction_field = text_value
elif self._contains_keyword(attr.tag, '건설단계') or self._contains_keyword(attr.text, '건설단계'):
title_block.construction_stage = text_value
elif self._contains_keyword(attr.tag, '축척') or self._contains_keyword(attr.text, '축척'):
title_block.scale = text_value
elif self._contains_keyword(attr.tag, '설계자') or self._contains_keyword(attr.text, '설계자'):
title_block.designer = text_value
elif self._contains_keyword(attr.tag, '프로젝트') or self._contains_keyword(attr.text, '프로젝트'):
title_block.project_name = text_value
elif self._contains_keyword(attr.tag, '날짜') or self._contains_keyword(attr.text, '날짜'):
title_block.date = text_value
elif self._contains_keyword(attr.tag, '리비전') or self._contains_keyword(attr.text, '리비전'):
title_block.revision = text_value
elif self._contains_keyword(attr.tag, '위치') or self._contains_keyword(attr.text, '위치'):
title_block.location = text_value
# 도곽 전체 바운딩 박스 계산
title_block.bounding_box = self._calculate_title_block_bounding_box(block_ref)
# 속성 개수 업데이트
title_block.attributes_count = len(title_block.all_attributes)
# 디버깅 로그 - 모든 attributes 정보 출력
self.logger.info(f"도곽 '{block_ref.name}'에서 {title_block.attributes_count}개의 속성 추출:")
for i, attr in enumerate(title_block.all_attributes):
self.logger.debug(f" [{i+1}] Tag: '{attr.tag}', Text: '{attr.text}', "
f"Position: ({attr.insert_x:.2f}, {attr.insert_y:.2f}, {attr.insert_z:.2f}), "
f"Height: {attr.height:.2f}, Prompt: '{attr.prompt or 'N/A'}'")
return title_block
def _calculate_title_block_bounding_box(self, block_ref: BlockInfo) -> Optional[BoundingBox]:
"""도곽의 전체 바운딩 박스 계산"""
try:
valid_boxes = [attr.bounding_box for attr in block_ref.attributes
if attr.bounding_box is not None]
if not valid_boxes:
self.logger.warning("유효한 바운딩 박스가 없습니다.")
return None
# 모든 바운딩 박스를 포함하는 최외곽 박스 계산
min_x = min(box.min_x for box in valid_boxes)
min_y = min(box.min_y for box in valid_boxes)
max_x = max(box.max_x for box in valid_boxes)
max_y = max(box.max_y for box in valid_boxes)
return BoundingBox(min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y)
except Exception as e:
self.logger.warning(f"도곽 바운딩 박스 계산 실패: {e}")
return None
def process_dxf_file(self, file_path: str) -> Dict[str, Any]:
"""DXF 파일 전체 처리"""
result = {
'success': False,
'error': None,
'file_path': file_path,
'title_block': None,
'block_references': [],
'summary': {}
}
try:
# 파일 유효성 검사
if not self.validate_dxf_file(file_path):
result['error'] = "유효하지 않은 DXF 파일입니다."
return result
# DXF 문서 로드
doc = self.load_dxf_document(file_path)
if not doc:
result['error'] = "DXF 문서를 로드할 수 없습니다."
return result
# Block Reference 추출
block_refs = self.extract_block_references(doc)
result['block_references'] = [asdict(block_ref) for block_ref in block_refs]
# 도곽 정보 추출
title_block = self.identify_title_block(block_refs)
if title_block:
result['title_block'] = asdict(title_block)
# 요약 정보
result['summary'] = {
'total_blocks': len(block_refs),
'title_block_found': title_block is not None,
'title_block_name': title_block.block_name if title_block else None,
'attributes_count': sum(len(br.attributes) for br in block_refs)
}
result['success'] = True
self.logger.info(f"DXF 파일 처리 완료: {file_path}")
except Exception as e:
self.logger.error(f"DXF 파일 처리 중 오류: {e}")
result['error'] = str(e)
return result
def save_analysis_result(self, result: Dict[str, Any], output_file: str) -> bool:
"""분석 결과를 JSON 파일로 저장"""
try:
os.makedirs(Config.RESULTS_FOLDER, exist_ok=True)
output_path = os.path.join(Config.RESULTS_FOLDER, output_file)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
self.logger.info(f"분석 결과 저장 완료: {output_path}")
return True
except Exception as e:
self.logger.error(f"분석 결과 저장 실패: {e}")
return False
def main():
"""테스트용 메인 함수"""
logging.basicConfig(level=logging.INFO)
if not EZDXF_AVAILABLE:
print("ezdxf 라이브러리가 설치되지 않았습니다.")
return
processor = DXFProcessor()
# 테스트 파일 경로 (실제 파일 경로로 변경 필요)
test_file = "test_drawing.dxf"
if os.path.exists(test_file):
result = processor.process_dxf_file(test_file)
if result['success']:
print("DXF 파일 처리 성공!")
print(f"블록 수: {result['summary']['total_blocks']}")
print(f"도곽 발견: {result['summary']['title_block_found']}")
if result['title_block']:
print("\n도곽 정보:")
title_block = result['title_block']
for key, value in title_block.items():
if value and key != 'bounding_box':
print(f" {key}: {value}")
else:
print(f"처리 실패: {result['error']}")
else:
print(f"테스트 파일을 찾을 수 없습니다: {test_file}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DXF 처리 모듈 테스트 스크립트
수정된 _extract_title_block_info 함수와 관련 기능들을 테스트
"""
import sys
import os
sys.path.append(os.path.dirname(__file__))
try:
from dxf_processor import DXFProcessor, AttributeInfo, TitleBlockInfo, BoundingBox
print("[SUCCESS] DXF 처리 모듈 import 성공")
# DXFProcessor 인스턴스 생성
processor = DXFProcessor()
print("[SUCCESS] DXFProcessor 인스턴스 생성 성공")
# AttributeInfo 데이터 클래스 테스트
test_attr = AttributeInfo(
tag="TEST_TAG",
text="테스트 텍스트",
position=(100.0, 200.0, 0.0),
height=5.0,
width=50.0,
rotation=0.0,
layer="0",
prompt="테스트 프롬프트",
style="Standard",
invisible=False,
const=False,
verify=False,
preset=False,
insert_x=100.0,
insert_y=200.0,
insert_z=0.0,
estimated_width=75.0,
entity_handle="ABC123"
)
print("[SUCCESS] AttributeInfo 데이터 클래스 테스트 성공")
print(f" Tag: {test_attr.tag}")
print(f" Text: {test_attr.text}")
print(f" Position: {test_attr.position}")
print(f" Prompt: {test_attr.prompt}")
print(f" Handle: {test_attr.entity_handle}")
# TitleBlockInfo 데이터 클래스 테스트
test_title_block = TitleBlockInfo(
block_name="TEST_TITLE_BLOCK",
drawing_name="테스트 도면",
drawing_number="TEST-001",
all_attributes=[test_attr]
)
print("[SUCCESS] TitleBlockInfo 데이터 클래스 테스트 성공")
print(f" Block Name: {test_title_block.block_name}")
print(f" Drawing Name: {test_title_block.drawing_name}")
print(f" Drawing Number: {test_title_block.drawing_number}")
print(f" Attributes Count: {test_title_block.attributes_count}")
# BoundingBox 테스트
test_bbox = BoundingBox(min_x=0.0, min_y=0.0, max_x=100.0, max_y=50.0)
print("[SUCCESS] BoundingBox 데이터 클래스 테스트 성공")
print(f" Width: {test_bbox.width}")
print(f" Height: {test_bbox.height}")
print(f" Center: {test_bbox.center}")
print("\n[COMPLETE] 모든 테스트 통과! DXF 속성 추출 기능 개선이 성공적으로 완료되었습니다.")
except ImportError as e:
print(f"[ERROR] Import 오류: {e}")
except Exception as e:
print(f"[ERROR] 테스트 실행 중 오류: {e}")
if __name__ == "__main__":
print("\nDXF 처리 모듈 테스트 완료")

114
back_src/test_imports.py Normal file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
간단한 임포트 테스트
DXF 지원 통합 후 모든 모듈이 정상적으로 임포트되는지 확인
"""
import sys
import os
# 현재 경로 추가
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
def test_imports():
"""모든 주요 모듈 임포트 테스트"""
try:
print("🔍 모듈 임포트 테스트 시작...")
# 기본 라이브러리
import flet as ft
print("✅ Flet 임포트 성공")
# 프로젝트 모듈들
from config import Config
print("✅ Config 임포트 성공")
from pdf_processor import PDFProcessor
print("✅ PDFProcessor 임포트 성공")
from dxf_processor import DXFProcessor
print("✅ DXFProcessor 임포트 성공")
from gemini_analyzer import GeminiAnalyzer
print("✅ GeminiAnalyzer 임포트 성공")
from ui_components import UIComponents
print("✅ UIComponents 임포트 성공")
from utils import AnalysisResultSaver, DateTimeUtils
print("✅ Utils 임포트 성공")
# 메인 애플리케이션
from main import DocumentAnalyzerApp
print("✅ DocumentAnalyzerApp 임포트 성공")
print("\n🎉 모든 모듈 임포트 성공!")
print(f"📦 Flet 버전: {ft.__version__}")
# DXF 관련 라이브러리
try:
import ezdxf
print(f"📐 ezdxf 버전: {ezdxf.version}")
except ImportError:
print("⚠️ ezdxf 라이브러리가 설치되지 않았습니다")
try:
import numpy
print(f"🔢 numpy 버전: {numpy.__version__}")
except ImportError:
print("⚠️ numpy 라이브러리가 설치되지 않았습니다")
return True
except Exception as e:
print(f"❌ 임포트 오류: {e}")
return False
def test_basic_functionality():
"""기본 기능 테스트"""
try:
print("\n🔧 기본 기능 테스트 시작...")
# Config 테스트
config_errors = Config.validate_config()
if config_errors:
print(f"⚠️ 설정 오류: {config_errors}")
else:
print("✅ Config 검증 성공")
# PDF Processor 테스트
pdf_processor = PDFProcessor()
print("✅ PDFProcessor 인스턴스 생성 성공")
# DXF Processor 테스트
dxf_processor = DXFProcessor()
print("✅ DXFProcessor 인스턴스 생성 성공")
# DateTimeUtils 테스트
from utils import DateTimeUtils
timestamp = DateTimeUtils.get_timestamp()
print(f"✅ 현재 시간: {timestamp}")
print("\n🎉 기본 기능 테스트 성공!")
return True
except Exception as e:
print(f"❌ 기능 테스트 오류: {e}")
return False
if __name__ == "__main__":
print("=" * 60)
print("📋 PDF/DXF 분석기 통합 테스트")
print("=" * 60)
import_success = test_imports()
functionality_success = test_basic_functionality()
print("\n" + "=" * 60)
if import_success and functionality_success:
print("🎉 모든 테스트 통과! 애플리케이션 준비 완료")
print("💡 main.py를 실행하여 애플리케이션을 시작할 수 있습니다")
else:
print("❌ 일부 테스트 실패. 설정을 확인하세요")
print("=" * 60)

314
back_src/test_project.py Normal file
View File

@@ -0,0 +1,314 @@
"""
테스트 스크립트
프로젝트의 핵심 기능들을 테스트합니다.
"""
import sys
import os
import logging
from pathlib import Path
# 프로젝트 루트를 Python 경로에 추가
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def test_config():
"""설정 모듈 테스트"""
print("=" * 50)
print("설정 모듈 테스트")
print("=" * 50)
try:
from config import Config
print(f"✅ 앱 제목: {Config.APP_TITLE}")
print(f"✅ 앱 버전: {Config.APP_VERSION}")
print(f"✅ 업로드 폴더: {Config.UPLOAD_FOLDER}")
print(f"✅ 최대 파일 크기: {Config.MAX_FILE_SIZE_MB}MB")
print(f"✅ 허용 확장자: {Config.ALLOWED_EXTENSIONS}")
print(f"✅ Gemini 모델: {Config.GEMINI_MODEL}")
# 설정 검증
errors = Config.validate_config()
if errors:
print("❌ 설정 오류:")
for error in errors:
print(f" - {error}")
return False
else:
print("✅ 모든 설정이 올바릅니다.")
return True
except Exception as e:
print(f"❌ 설정 모듈 테스트 실패: {e}")
return False
def test_pdf_processor():
"""PDF 처리 모듈 테스트"""
print("\\n" + "=" * 50)
print("PDF 처리 모듈 테스트")
print("=" * 50)
try:
from pdf_processor import PDFProcessor
processor = PDFProcessor()
print("✅ PDF 처리기 초기화 성공")
# 테스트용 임시 PDF 생성 (실제로는 존재하지 않음)
test_pdf = "test_sample.pdf"
# 존재하지 않는 파일 테스트
result = processor.validate_pdf_file(test_pdf)
if not result:
print("✅ 존재하지 않는 파일 검증: 정상 동작")
else:
print("❌ 존재하지 않는 파일 검증: 비정상 동작")
# 확장자 검증 테스트
if not processor.validate_pdf_file("test.txt"):
print("✅ 잘못된 확장자 검증: 정상 동작")
else:
print("❌ 잘못된 확장자 검증: 비정상 동작")
# base64 변환 테스트 (PIL Image 사용)
try:
from PIL import Image
import io
# 작은 테스트 이미지 생성
test_image = Image.new('RGB', (100, 100), color='red')
base64_result = processor.image_to_base64(test_image)
if base64_result and len(base64_result) > 0:
print("✅ Base64 변환: 정상 동작")
else:
print("❌ Base64 변환: 실패")
except Exception as e:
print(f"❌ Base64 변환 테스트 실패: {e}")
print("✅ PDF 처리 모듈 기본 테스트 완료")
return True
except Exception as e:
print(f"❌ PDF 처리 모듈 테스트 실패: {e}")
return False
def test_gemini_analyzer():
"""Gemini 분석기 테스트"""
print("\\n" + "=" * 50)
print("Gemini 분석기 테스트")
print("=" * 50)
try:
from config import Config
# API 키 확인
if not Config.GEMINI_API_KEY:
print("❌ Gemini API 키가 설정되지 않았습니다.")
print(" .env 파일에 GEMINI_API_KEY를 설정하세요.")
return False
from gemini_analyzer import GeminiAnalyzer
analyzer = GeminiAnalyzer()
print("✅ Gemini 분석기 초기화 성공")
# API 연결 테스트
if analyzer.validate_api_connection():
print("✅ Gemini API 연결 테스트 성공")
else:
print("❌ Gemini API 연결 테스트 실패")
return False
# 모델 정보 확인
model_info = analyzer.get_model_info()
print(f"✅ 사용 모델: {model_info['model']}")
print(f"✅ API 키 길이: {model_info['api_key_length']}")
return True
except Exception as e:
print(f"❌ Gemini 분석기 테스트 실패: {e}")
return False
def test_utils():
"""유틸리티 모듈 테스트"""
print("\\n" + "=" * 50)
print("유틸리티 모듈 테스트")
print("=" * 50)
try:
from utils import FileUtils, DateTimeUtils, TextUtils, ValidationUtils
# 파일 유틸리티 테스트
safe_name = FileUtils.get_safe_filename("test<file>name?.pdf")
print(f"✅ 안전한 파일명 생성: '{safe_name}'")
# 날짜/시간 유틸리티 테스트
timestamp = DateTimeUtils.get_timestamp()
filename_timestamp = DateTimeUtils.get_filename_timestamp()
print(f"✅ 타임스탬프: {timestamp}")
print(f"✅ 파일명 타임스탬프: {filename_timestamp}")
# 텍스트 유틸리티 테스트
long_text = "이것은 긴 텍스트입니다. " * 10
truncated = TextUtils.truncate_text(long_text, 50)
print(f"✅ 텍스트 축약: '{truncated}'")
# 검증 유틸리티 테스트
is_valid_pdf = ValidationUtils.is_valid_pdf_extension("test.pdf")
is_invalid_pdf = ValidationUtils.is_valid_pdf_extension("test.txt")
print(f"✅ PDF 확장자 검증: {is_valid_pdf} / {not is_invalid_pdf}")
return True
except Exception as e:
print(f"❌ 유틸리티 모듈 테스트 실패: {e}")
return False
def test_file_structure():
"""파일 구조 테스트"""
print("\\n" + "=" * 50)
print("파일 구조 테스트")
print("=" * 50)
required_files = [
"main.py",
"config.py",
"pdf_processor.py",
"gemini_analyzer.py",
"ui_components.py",
"utils.py",
"requirements.txt",
".env.example",
"README.md",
"project_plan.md"
]
required_dirs = [
"uploads",
"assets",
"docs"
]
missing_files = []
missing_dirs = []
# 파일 확인
for file in required_files:
if not (project_root / file).exists():
missing_files.append(file)
else:
print(f"{file}")
# 디렉토리 확인
for dir_name in required_dirs:
if not (project_root / dir_name).exists():
missing_dirs.append(dir_name)
else:
print(f"{dir_name}/")
if missing_files:
print("❌ 누락된 파일:")
for file in missing_files:
print(f" - {file}")
if missing_dirs:
print("❌ 누락된 디렉토리:")
for dir_name in missing_dirs:
print(f" - {dir_name}/")
return len(missing_files) == 0 and len(missing_dirs) == 0
def test_dependencies():
"""의존성 테스트"""
print("\\n" + "=" * 50)
print("의존성 테스트")
print("=" * 50)
required_packages = [
"flet",
"google.genai",
"fitz", # PyMuPDF
"PIL", # Pillow
"dotenv" # python-dotenv
]
missing_packages = []
for package in required_packages:
try:
if package == "fitz":
import fitz
elif package == "PIL":
import PIL
elif package == "dotenv":
import dotenv
elif package == "google.genai":
import google.genai
else:
__import__(package)
print(f"{package}")
except ImportError:
missing_packages.append(package)
print(f"{package} - 설치되지 않음")
if missing_packages:
print("\\n설치가 필요한 패키지:")
print("pip install " + " ".join(missing_packages))
return False
else:
print("\\n✅ 모든 의존성이 설치되어 있습니다.")
return True
def main():
"""메인 테스트 함수"""
print("PDF 도면 분석기 - 테스트 스크립트")
print("=" * 80)
tests = [
("파일 구조", test_file_structure),
("의존성", test_dependencies),
("설정 모듈", test_config),
("PDF 처리 모듈", test_pdf_processor),
("유틸리티 모듈", test_utils),
("Gemini 분석기", test_gemini_analyzer),
]
passed = 0
total = len(tests)
for test_name, test_func in tests:
try:
if test_func():
passed += 1
print(f"\\n✅ {test_name} 테스트 통과")
else:
print(f"\\n❌ {test_name} 테스트 실패")
except Exception as e:
print(f"\\n❌ {test_name} 테스트 오류: {e}")
print("\\n" + "=" * 80)
print(f"테스트 결과: {passed}/{total} 통과")
if passed == total:
print("🎉 모든 테스트가 통과했습니다!")
print("애플리케이션 실행 준비가 완료되었습니다.")
print("\\n실행 방법:")
print("python main.py")
else:
print("⚠️ 일부 테스트가 실패했습니다.")
print("실패한 테스트를 확인하고 문제를 해결하세요.")
print("=" * 80)
if __name__ == "__main__":
main()

5
back_src/test_run.py Normal file
View File

@@ -0,0 +1,5 @@
try:
from dxf_processor import DXFProcessor
print("Successfully imported DXFProcessor")
except Exception as e:
print(e)

View File

@@ -0,0 +1,548 @@
# -*- coding: utf-8 -*-
"""
포괄적 텍스트 추출 모듈
DXF 파일에서 도곽 블록 외의 모든 텍스트 엔티티를 추출하여 표시 및 저장
- 모델스페이스의 독립적인 TEXT/MTEXT 엔티티
- 페이퍼스페이스의 독립적인 TEXT/MTEXT 엔티티
- 모든 블록 내부의 TEXT/MTEXT 엔티티
- 블록 속성(ATTRIB) 중 도곽이 아닌 것들
"""
import os
import csv
import json
import logging
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, asdict, field
from datetime import datetime
try:
import ezdxf
from ezdxf.document import Drawing
from ezdxf.entities import Insert, Attrib, AttDef, Text, MText
from ezdxf.layouts import BlockLayout, Modelspace, Paperspace
from ezdxf import bbox
EZDXF_AVAILABLE = True
except ImportError:
EZDXF_AVAILABLE = False
logging.warning("ezdxf 라이브러리가 설치되지 않았습니다.")
from config import Config
@dataclass
class ComprehensiveTextEntity:
"""포괄적인 텍스트 엔티티 정보"""
entity_type: str # TEXT, MTEXT, ATTRIB
text: str
position_x: float
position_y: float
position_z: float
height: float
rotation: float
layer: str
color: Optional[int] = None
style: Optional[str] = None
entity_handle: Optional[str] = None
# 위치 정보
location_type: str = "Unknown" # ModelSpace, PaperSpace, Block
parent_block: Optional[str] = None
layout_name: Optional[str] = None
# 블록 속성 정보 (ATTRIB인 경우)
attribute_tag: Optional[str] = None
is_title_block_attribute: bool = False
# 바운딩 박스
bbox_min_x: Optional[float] = None
bbox_min_y: Optional[float] = None
bbox_max_x: Optional[float] = None
bbox_max_y: Optional[float] = None
# 추가 속성
width_factor: float = 1.0
oblique_angle: float = 0.0
text_generation_flag: int = 0
@dataclass
class ComprehensiveExtractionResult:
"""포괄적인 텍스트 추출 결과"""
all_text_entities: List[ComprehensiveTextEntity] = field(default_factory=list)
modelspace_texts: List[ComprehensiveTextEntity] = field(default_factory=list)
paperspace_texts: List[ComprehensiveTextEntity] = field(default_factory=list)
block_texts: List[ComprehensiveTextEntity] = field(default_factory=list)
non_title_block_attributes: List[ComprehensiveTextEntity] = field(default_factory=list)
# 통계 정보
total_count: int = 0
by_type_count: Dict[str, int] = field(default_factory=dict)
by_location_count: Dict[str, int] = field(default_factory=dict)
by_layer_count: Dict[str, int] = field(default_factory=dict)
class ComprehensiveTextExtractor:
"""포괄적 텍스트 추출기"""
# 도곽 블록 식별을 위한 키워드
TITLE_BLOCK_KEYWORDS = {
'건설분야', '건설단계', '도면명', '축척', '도면번호', '설계자',
'프로젝트', '날짜', '리비전', '위치', 'title', 'scale', 'drawing',
'project', 'designer', 'date', 'revision', 'dwg', 'construction'
}
def __init__(self):
"""텍스트 추출기 초기화"""
self.logger = logging.getLogger(__name__)
if not EZDXF_AVAILABLE:
raise ImportError("ezdxf 라이브러리가 필요합니다.")
def extract_all_texts_comprehensive(self, file_path: str) -> ComprehensiveExtractionResult:
"""DXF 파일에서 모든 텍스트를 포괄적으로 추출"""
try:
self.logger.info(f"포괄적 텍스트 추출 시작: {file_path}")
# DXF 문서 로드
doc = ezdxf.readfile(file_path)
result = ComprehensiveExtractionResult()
# 1. 모델스페이스에서 독립적인 텍스트 추출
self._extract_layout_texts(doc.modelspace(), "ModelSpace", "Model", result)
# 2. 모든 페이퍼스페이스에서 텍스트 추출
for layout_name in doc.layout_names_in_taborder():
if layout_name != "Model": # 모델스페이스 제외
try:
layout = doc.paperspace(layout_name)
self._extract_layout_texts(layout, "PaperSpace", layout_name, result)
except Exception as e:
self.logger.warning(f"페이퍼스페이스 {layout_name} 처리 실패: {e}")
# 3. 모든 블록 정의에서 텍스트 추출
for block_layout in doc.blocks:
if not block_layout.name.startswith('*'): # 시스템 블록 제외
self._extract_block_texts(block_layout, result)
# 4. 모든 블록 참조의 속성 추출 (도곽 제외)
self._extract_block_attributes(doc, result)
# 5. 결과 분류 및 통계 생성
self._classify_and_analyze(result)
self.logger.info(f"포괄적 텍스트 추출 완료: 총 {result.total_count}")
return result
except Exception as e:
self.logger.error(f"포괄적 텍스트 추출 실패: {e}")
raise
def _extract_layout_texts(self, layout, location_type: str, layout_name: str, result: ComprehensiveExtractionResult):
"""레이아웃(모델스페이스/페이퍼스페이스)에서 텍스트 추출"""
try:
# TEXT 엔티티 추출
for text_entity in layout.query('TEXT'):
text_info = self._create_text_entity_info(
text_entity, 'TEXT', location_type, layout_name
)
if text_info and text_info.text.strip():
result.all_text_entities.append(text_info)
if location_type == "ModelSpace":
result.modelspace_texts.append(text_info)
else:
result.paperspace_texts.append(text_info)
# MTEXT 엔티티 추출
for mtext_entity in layout.query('MTEXT'):
text_info = self._create_text_entity_info(
mtext_entity, 'MTEXT', location_type, layout_name
)
if text_info and text_info.text.strip():
result.all_text_entities.append(text_info)
if location_type == "ModelSpace":
result.modelspace_texts.append(text_info)
else:
result.paperspace_texts.append(text_info)
# 독립적인 ATTRIB 엔티티 추출 (블록 외부)
for attrib_entity in layout.query('ATTRIB'):
text_info = self._create_text_entity_info(
attrib_entity, 'ATTRIB', location_type, layout_name
)
if text_info and text_info.text.strip():
result.all_text_entities.append(text_info)
if location_type == "ModelSpace":
result.modelspace_texts.append(text_info)
else:
result.paperspace_texts.append(text_info)
except Exception as e:
self.logger.warning(f"레이아웃 {layout_name} 텍스트 추출 실패: {e}")
def _extract_block_texts(self, block_layout: BlockLayout, result: ComprehensiveExtractionResult):
"""블록 정의 내부의 TEXT/MTEXT 엔티티 추출"""
try:
block_name = block_layout.name
# TEXT 엔티티 추출
for text_entity in block_layout.query('TEXT'):
text_info = self._create_text_entity_info(
text_entity, 'TEXT', "Block", None, block_name
)
if text_info and text_info.text.strip():
result.all_text_entities.append(text_info)
result.block_texts.append(text_info)
# MTEXT 엔티티 추출
for mtext_entity in block_layout.query('MTEXT'):
text_info = self._create_text_entity_info(
mtext_entity, 'MTEXT', "Block", None, block_name
)
if text_info and text_info.text.strip():
result.all_text_entities.append(text_info)
result.block_texts.append(text_info)
except Exception as e:
self.logger.warning(f"블록 {block_layout.name} 텍스트 추출 실패: {e}")
def _extract_block_attributes(self, doc: Drawing, result: ComprehensiveExtractionResult):
"""모든 블록 참조의 속성 추출 (도곽 블록 제외)"""
try:
# 모델스페이스의 블록 참조
self._process_layout_block_references(doc.modelspace(), "ModelSpace", "Model", result)
# 페이퍼스페이스의 블록 참조
for layout_name in doc.layout_names_in_taborder():
if layout_name != "Model":
try:
layout = doc.paperspace(layout_name)
self._process_layout_block_references(layout, "PaperSpace", layout_name, result)
except Exception as e:
self.logger.warning(f"페이퍼스페이스 {layout_name} 블록 참조 처리 실패: {e}")
except Exception as e:
self.logger.warning(f"블록 속성 추출 실패: {e}")
def _process_layout_block_references(self, layout, location_type: str, layout_name: str, result: ComprehensiveExtractionResult):
"""레이아웃의 블록 참조 처리"""
for insert in layout.query('INSERT'):
block_name = insert.dxf.name
# 도곽 블록인지 확인
is_title_block = self._is_title_block(insert)
# 블록의 속성들 추출
for attrib in insert.attribs:
text_info = self._create_attrib_entity_info(
attrib, location_type, layout_name, block_name, is_title_block
)
if text_info and text_info.text.strip():
result.all_text_entities.append(text_info)
if not is_title_block:
result.non_title_block_attributes.append(text_info)
def _is_title_block(self, insert: Insert) -> bool:
"""블록이 도곽 블록인지 판단"""
try:
# 블록 이름에서 도곽 키워드 확인
block_name = insert.dxf.name.lower()
if any(keyword in block_name for keyword in ['title', 'border', '도곽', '표제']):
return True
# 속성에서 도곽 키워드 확인
title_block_attrs = 0
for attrib in insert.attribs:
tag = attrib.dxf.tag.lower()
text = attrib.dxf.text.lower()
if any(keyword in tag or keyword in text for keyword in self.TITLE_BLOCK_KEYWORDS):
title_block_attrs += 1
# 2개 이상의 도곽 관련 속성이 있으면 도곽으로 판단
return title_block_attrs >= 2
except Exception:
return False
def _create_text_entity_info(self, entity, entity_type: str, location_type: str,
layout_name: Optional[str], parent_block: Optional[str] = None) -> Optional[ComprehensiveTextEntity]:
"""텍스트 엔티티 정보 생성"""
try:
# 텍스트 내용 추출
if entity_type == 'MTEXT':
text_content = getattr(entity, 'text', '') or getattr(entity.dxf, 'text', '')
else:
text_content = getattr(entity.dxf, 'text', '')
if not text_content.strip():
return None
# 위치 정보
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
if hasattr(insert_point, 'x'):
position = (insert_point.x, insert_point.y, insert_point.z)
else:
position = (insert_point[0], insert_point[1], insert_point[2] if len(insert_point) > 2 else 0)
# 속성 정보
height = getattr(entity.dxf, 'height', 1.0)
if entity_type == 'MTEXT':
height = getattr(entity.dxf, 'char_height', height)
rotation = getattr(entity.dxf, 'rotation', 0.0)
layer = getattr(entity.dxf, 'layer', '0')
color = getattr(entity.dxf, 'color', None)
style = getattr(entity.dxf, 'style', None)
entity_handle = getattr(entity.dxf, 'handle', None)
width_factor = getattr(entity.dxf, 'width_factor', 1.0)
oblique_angle = getattr(entity.dxf, 'oblique_angle', 0.0)
text_generation_flag = getattr(entity.dxf, 'text_generation_flag', 0)
# 바운딩 박스 계산
bbox_info = self._calculate_entity_bbox(entity)
return ComprehensiveTextEntity(
entity_type=entity_type,
text=text_content,
position_x=position[0],
position_y=position[1],
position_z=position[2],
height=height,
rotation=rotation,
layer=layer,
color=color,
style=style,
entity_handle=entity_handle,
location_type=location_type,
parent_block=parent_block,
layout_name=layout_name,
bbox_min_x=bbox_info[0] if bbox_info else None,
bbox_min_y=bbox_info[1] if bbox_info else None,
bbox_max_x=bbox_info[2] if bbox_info else None,
bbox_max_y=bbox_info[3] if bbox_info else None,
width_factor=width_factor,
oblique_angle=oblique_angle,
text_generation_flag=text_generation_flag
)
except Exception as e:
self.logger.warning(f"텍스트 엔티티 정보 생성 실패: {e}")
return None
def _create_attrib_entity_info(self, attrib: Attrib, location_type: str, layout_name: Optional[str],
parent_block: str, is_title_block: bool) -> Optional[ComprehensiveTextEntity]:
"""속성 엔티티 정보 생성"""
try:
text_content = getattr(attrib.dxf, 'text', '')
if not text_content.strip():
return None
# 위치 정보
insert_point = getattr(attrib.dxf, 'insert', (0, 0, 0))
if hasattr(insert_point, 'x'):
position = (insert_point.x, insert_point.y, insert_point.z)
else:
position = (insert_point[0], insert_point[1], insert_point[2] if len(insert_point) > 2 else 0)
# 속성 정보
tag = getattr(attrib.dxf, 'tag', '')
height = getattr(attrib.dxf, 'height', 1.0)
rotation = getattr(attrib.dxf, 'rotation', 0.0)
layer = getattr(attrib.dxf, 'layer', '0')
color = getattr(attrib.dxf, 'color', None)
style = getattr(attrib.dxf, 'style', None)
entity_handle = getattr(attrib.dxf, 'handle', None)
width_factor = getattr(attrib.dxf, 'width_factor', 1.0)
oblique_angle = getattr(attrib.dxf, 'oblique_angle', 0.0)
text_generation_flag = getattr(attrib.dxf, 'text_generation_flag', 0)
# 바운딩 박스 계산
bbox_info = self._calculate_entity_bbox(attrib)
return ComprehensiveTextEntity(
entity_type='ATTRIB',
text=text_content,
position_x=position[0],
position_y=position[1],
position_z=position[2],
height=height,
rotation=rotation,
layer=layer,
color=color,
style=style,
entity_handle=entity_handle,
location_type=location_type,
parent_block=parent_block,
layout_name=layout_name,
attribute_tag=tag,
is_title_block_attribute=is_title_block,
bbox_min_x=bbox_info[0] if bbox_info else None,
bbox_min_y=bbox_info[1] if bbox_info else None,
bbox_max_x=bbox_info[2] if bbox_info else None,
bbox_max_y=bbox_info[3] if bbox_info else None,
width_factor=width_factor,
oblique_angle=oblique_angle,
text_generation_flag=text_generation_flag
)
except Exception as e:
self.logger.warning(f"속성 엔티티 정보 생성 실패: {e}")
return None
def _calculate_entity_bbox(self, entity) -> Optional[Tuple[float, float, float, float]]:
"""엔티티의 바운딩 박스 계산"""
try:
entity_bbox = bbox.extents([entity])
if entity_bbox:
return (entity_bbox.extmin.x, entity_bbox.extmin.y,
entity_bbox.extmax.x, entity_bbox.extmax.y)
except Exception:
# 대안: 추정 계산
try:
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
height = getattr(entity.dxf, 'height', 1.0)
if hasattr(entity, 'text'):
text_content = entity.text
elif hasattr(entity.dxf, 'text'):
text_content = entity.dxf.text
else:
text_content = ""
estimated_width = len(text_content) * height * 0.6
x, y = insert_point[0], insert_point[1]
return (x, y, x + estimated_width, y + height)
except Exception:
pass
return None
def _classify_and_analyze(self, result: ComprehensiveExtractionResult):
"""결과 분류 및 통계 분석"""
result.total_count = len(result.all_text_entities)
# 타입별 개수
for entity in result.all_text_entities:
entity_type = entity.entity_type
result.by_type_count[entity_type] = result.by_type_count.get(entity_type, 0) + 1
# 위치별 개수
for entity in result.all_text_entities:
location = entity.location_type
result.by_location_count[location] = result.by_location_count.get(location, 0) + 1
# 레이어별 개수
for entity in result.all_text_entities:
layer = entity.layer
result.by_layer_count[layer] = result.by_layer_count.get(layer, 0) + 1
def save_to_csv(self, result: ComprehensiveExtractionResult, output_path: str) -> bool:
"""결과를 CSV 파일로 저장"""
try:
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, 'w', newline='', encoding='utf-8-sig') as csvfile:
fieldnames = [
'Entity_Type', 'Text', 'Position_X', 'Position_Y', 'Position_Z',
'Height', 'Rotation', 'Layer', 'Color', 'Style', 'Entity_Handle',
'Location_Type', 'Parent_Block', 'Layout_Name', 'Attribute_Tag',
'Is_Title_Block_Attribute', 'BBox_Min_X', 'BBox_Min_Y',
'BBox_Max_X', 'BBox_Max_Y', 'Width_Factor', 'Oblique_Angle'
]
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for entity in result.all_text_entities:
writer.writerow({
'Entity_Type': entity.entity_type,
'Text': entity.text,
'Position_X': entity.position_x,
'Position_Y': entity.position_y,
'Position_Z': entity.position_z,
'Height': entity.height,
'Rotation': entity.rotation,
'Layer': entity.layer,
'Color': entity.color,
'Style': entity.style,
'Entity_Handle': entity.entity_handle,
'Location_Type': entity.location_type,
'Parent_Block': entity.parent_block,
'Layout_Name': entity.layout_name,
'Attribute_Tag': entity.attribute_tag,
'Is_Title_Block_Attribute': entity.is_title_block_attribute,
'BBox_Min_X': entity.bbox_min_x,
'BBox_Min_Y': entity.bbox_min_y,
'BBox_Max_X': entity.bbox_max_x,
'BBox_Max_Y': entity.bbox_max_y,
'Width_Factor': entity.width_factor,
'Oblique_Angle': entity.oblique_angle
})
self.logger.info(f"CSV 저장 완료: {output_path}")
return True
except Exception as e:
self.logger.error(f"CSV 저장 실패: {e}")
return False
def save_to_json(self, result: ComprehensiveExtractionResult, output_path: str) -> bool:
"""결과를 JSON 파일로 저장"""
try:
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as jsonfile:
json.dump(asdict(result), jsonfile, ensure_ascii=False, indent=2, default=str)
self.logger.info(f"JSON 저장 완료: {output_path}")
return True
except Exception as e:
self.logger.error(f"JSON 저장 실패: {e}")
return False
def main():
"""테스트용 메인 함수"""
logging.basicConfig(level=logging.INFO)
if not EZDXF_AVAILABLE:
print("ezdxf 라이브러리가 설치되지 않았습니다.")
return
extractor = ComprehensiveTextExtractor()
test_file = "test_drawing.dxf"
if os.path.exists(test_file):
try:
result = extractor.extract_all_texts_comprehensive(test_file)
print(f"포괄적 텍스트 추출 결과:")
print(f"총 텍스트 엔티티: {result.total_count}")
print(f"모델스페이스: {len(result.modelspace_texts)}")
print(f"페이퍼스페이스: {len(result.paperspace_texts)}")
print(f"블록 내부: {len(result.block_texts)}")
print(f"비도곽 속성: {len(result.non_title_block_attributes)}")
print("\n타입별 개수:")
for entity_type, count in result.by_type_count.items():
print(f" {entity_type}: {count}")
print("\n위치별 개수:")
for location, count in result.by_location_count.items():
print(f" {location}: {count}")
# CSV 저장 테스트
csv_path = "test_comprehensive_texts.csv"
if extractor.save_to_csv(result, csv_path):
print(f"\nCSV 저장 성공: {csv_path}")
except Exception as e:
print(f"추출 실패: {e}")
else:
print(f"테스트 파일을 찾을 수 없습니다: {test_file}")
if __name__ == "__main__":
main()

74
config.py Normal file
View File

@@ -0,0 +1,74 @@
"""
설정 관리 모듈
환경 변수 및 애플리케이션 설정을 관리합니다.
"""
import os
from dotenv import load_dotenv
from pathlib import Path
# .env 파일 로드
load_dotenv()
class Config:
"""애플리케이션 설정 클래스"""
# 기본 애플리케이션 설정
APP_TITLE = os.getenv("APP_TITLE", "PDF/DXF 도면 분석기")
APP_VERSION = os.getenv("APP_VERSION", "1.1.0")
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
# API 설정
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-pro")
DEFAULT_PROMPT = os.getenv(
"DEFAULT_PROMPT",
"pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.structured_output 이외에 정보도 기타에 넣어줘."
)
# 파일 업로드 설정
MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "50"))
ALLOWED_EXTENSIONS = os.getenv("ALLOWED_EXTENSIONS", "pdf,dxf").split(",")
UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER", "uploads")
# 경로 설정
BASE_DIR = Path(__file__).parent
UPLOAD_DIR = BASE_DIR / UPLOAD_FOLDER
ASSETS_DIR = BASE_DIR / "assets"
RESULTS_FOLDER = BASE_DIR / "results"
@classmethod
def validate_config(cls):
"""설정 유효성 검사"""
errors = []
if not cls.GEMINI_API_KEY:
errors.append("GEMINI_API_KEY가 설정되지 않았습니다.")
if not cls.UPLOAD_DIR.exists():
try:
cls.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
except Exception as e:
errors.append(f"업로드 폴더 생성 실패: {e}")
return errors
@classmethod
def get_file_size_limit_bytes(cls):
"""파일 크기 제한을 바이트로 반환"""
return cls.MAX_FILE_SIZE_MB * 1024 * 1024
@classmethod
def get_gemini_api_key(cls):
"""Gemini API 키 반환"""
return cls.GEMINI_API_KEY
# 설정 검증
if __name__ == "__main__":
config_errors = Config.validate_config()
if config_errors:
print("설정 오류:")
for error in config_errors:
print(f" - {error}")
else:
print("설정이 올바르게 구성되었습니다.")

View File

@@ -0,0 +1,638 @@
"""
Cross-Tabulated CSV 내보내기 모듈 (개선된 통합 버전)
JSON 형태의 분석 결과를 key-value 형태의 cross-tabulated CSV로 저장하는 기능을 제공합니다.
관련 키들(value, x, y)을 하나의 행으로 통합하여 저장합니다.
Author: Claude Assistant
Created: 2025-07-15
Updated: 2025-07-16 (키 통합 개선 버전)
Version: 2.0.0
"""
import pandas as pd
import json
import logging
from datetime import datetime
from typing import List, Dict, Any, Optional, Union, Tuple
import os
import re
from collections import defaultdict
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class CrossTabulatedCSVExporter:
"""Cross-Tabulated CSV 내보내기 클래스 (개선된 통합 버전)"""
def __init__(self):
"""Cross-Tabulated CSV 내보내기 초기화"""
self.coordinate_pattern = re.compile(r'\b(\d+)\s*,\s*(\d+)\b') # x,y 좌표 패턴
self.debug_mode = True # 디버깅 모드 활성화
# 키 그룹핑을 위한 패턴들
self.value_suffixes = ['_value', '_val', '_text', '_content']
self.x_suffixes = ['_x', '_x_coord', '_x_position', '_left']
self.y_suffixes = ['_y', '_y_coord', '_y_position', '_top']
def export_cross_tabulated_csv(
self,
processing_results: List[Any],
output_path: str,
include_coordinates: bool = True,
coordinate_source: str = "auto" # "auto", "text_blocks", "analysis_result", "none"
) -> bool:
"""
처리 결과를 cross-tabulated CSV 형태로 저장 (키 통합 기능 포함)
Args:
processing_results: 다중 파일 처리 결과 리스트
output_path: 출력 CSV 파일 경로
include_coordinates: 좌표 정보 포함 여부
coordinate_source: 좌표 정보 출처 ("auto", "text_blocks", "analysis_result", "none")
Returns:
저장 성공 여부
"""
try:
if self.debug_mode:
logger.info(f"=== Cross-tabulated CSV 저장 시작 (통합 버전) ===")
logger.info(f"입력된 결과 수: {len(processing_results)}")
logger.info(f"출력 경로: {output_path}")
logger.info(f"좌표 포함: {include_coordinates}, 좌표 출처: {coordinate_source}")
# 입력 데이터 검증
if not processing_results:
logger.warning("입력된 처리 결과가 비어있습니다.")
return False
# 각 결과 객체의 구조 분석
for i, result in enumerate(processing_results):
if self.debug_mode:
logger.info(f"결과 {i+1}: {self._analyze_result_structure(result)}")
# 모든 파일의 key-value 쌍을 수집
all_grouped_data = []
for i, result in enumerate(processing_results):
try:
if not hasattr(result, 'success'):
logger.warning(f"결과 {i+1}: 'success' 속성이 없습니다. 스킵합니다.")
continue
if not result.success:
if self.debug_mode:
logger.info(f"결과 {i+1}: 실패한 파일, 스킵합니다 ({getattr(result, 'error_message', 'Unknown error')})")
continue # 실패한 파일은 제외
# 기본 key-value 쌍 추출
file_data = self._extract_key_value_pairs(result, include_coordinates, coordinate_source)
if file_data:
# 관련 키들을 그룹화하여 통합된 데이터 생성
grouped_data = self._group_and_merge_keys(file_data, result)
if grouped_data:
all_grouped_data.extend(grouped_data)
if self.debug_mode:
logger.info(f"결과 {i+1}: {len(file_data)}개 key-value 쌍 → {len(grouped_data)}개 통합 행 생성")
else:
if self.debug_mode:
logger.warning(f"결과 {i+1}: 그룹화 후 데이터가 없습니다")
else:
if self.debug_mode:
logger.warning(f"결과 {i+1}: key-value 쌍을 추출할 수 없습니다")
except Exception as e:
logger.error(f"결과 {i+1} 처리 중 오류: {str(e)}")
continue
if not all_grouped_data:
logger.warning("저장할 데이터가 없습니다. 모든 파일에서 유효한 key-value 쌍을 추출할 수 없었습니다.")
if self.debug_mode:
self._print_debug_summary(processing_results)
return False
# DataFrame 생성
df = pd.DataFrame(all_grouped_data)
# 컬럼 순서 정렬
column_order = ['file_name', 'file_type', 'key', 'value']
if include_coordinates and coordinate_source != "none":
column_order.extend(['x', 'y'])
# 추가 컬럼들을 뒤에 배치
existing_columns = [col for col in column_order if col in df.columns]
additional_columns = [col for col in df.columns if col not in existing_columns]
df = df[existing_columns + additional_columns]
# 출력 디렉토리 생성
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# UTF-8 BOM으로 저장 (한글 호환성)
df.to_csv(output_path, index=False, encoding='utf-8-sig')
logger.info(f"Cross-tabulated CSV 저장 완료: {output_path}")
logger.info(f"{len(all_grouped_data)}개 통합 행 저장")
return True
except Exception as e:
logger.error(f"Cross-tabulated CSV 저장 오류: {str(e)}")
return False
def _group_and_merge_keys(self, raw_data: List[Dict[str, Any]], result: Any) -> List[Dict[str, Any]]:
"""
관련된 키들을 그룹화하고 하나의 행으로 통합
Args:
raw_data: 원시 key-value 쌍 리스트
result: 파일 처리 결과
Returns:
통합된 데이터 리스트
"""
# 파일 기본 정보
file_name = getattr(result, 'file_name', 'Unknown')
file_type = getattr(result, 'file_type', 'Unknown')
# 키별로 데이터 그룹화
key_groups = defaultdict(dict)
for data_row in raw_data:
key = data_row.get('key', '')
value = data_row.get('value', '')
x = data_row.get('x', '')
y = data_row.get('y', '')
# 기본 키 추출 (예: "사업명_value" -> "사업명")
base_key = self._extract_base_key(key)
# 키 타입 결정 (value, x, y 등)
key_type = self._determine_key_type(key)
if self.debug_mode and not key_groups[base_key]:
logger.info(f"새 키 그룹 생성: '{base_key}' (원본: '{key}', 타입: '{key_type}')")
# 그룹에 데이터 추가
if key_type == 'value':
key_groups[base_key]['value'] = value
# value에 좌표가 포함된 경우 사용
if not key_groups[base_key].get('x') and x:
key_groups[base_key]['x'] = x
if not key_groups[base_key].get('y') and y:
key_groups[base_key]['y'] = y
elif key_type == 'x':
key_groups[base_key]['x'] = value # x 값은 value 컬럼에서 가져옴
elif key_type == 'y':
key_groups[base_key]['y'] = value # y 값은 value 컬럼에서 가져옴
else:
# 일반적인 키인 경우 (suffix가 없는 경우)
if not key_groups[base_key].get('value'):
key_groups[base_key]['value'] = value
if x and not key_groups[base_key].get('x'):
key_groups[base_key]['x'] = x
if y and not key_groups[base_key].get('y'):
key_groups[base_key]['y'] = y
# 그룹화된 데이터를 최종 형태로 변환
merged_data = []
for base_key, group_data in key_groups.items():
# 빈 값이나 의미없는 데이터 제외
if not group_data.get('value') or str(group_data.get('value')).strip() == '':
continue
merged_row = {
'file_name': file_name,
'file_type': file_type,
'key': base_key,
'value': str(group_data.get('value', '')),
'x': str(group_data.get('x', '')) if group_data.get('x') else '',
'y': str(group_data.get('y', '')) if group_data.get('y') else '',
}
merged_data.append(merged_row)
if self.debug_mode:
logger.info(f"통합 행 생성: {base_key} = '{merged_row['value']}' ({merged_row['x']}, {merged_row['y']})")
return merged_data
def _extract_base_key(self, key: str) -> str:
"""
키에서 기본 이름 추출 (suffix 제거)
Args:
key: 원본 키 (예: "사업명_value", "사업명_x")
Returns:
기본 키 이름 (예: "사업명")
"""
if not key:
return key
# 모든 가능한 suffix 확인
all_suffixes = self.value_suffixes + self.x_suffixes + self.y_suffixes
for suffix in all_suffixes:
if key.endswith(suffix):
return key[:-len(suffix)]
# suffix가 없는 경우 원본 반환
return key
def _determine_key_type(self, key: str) -> str:
"""
키의 타입 결정 (value, x, y, other)
Args:
key: 키 이름
Returns:
키 타입 ("value", "x", "y", "other")
"""
if not key:
return "other"
key_lower = key.lower()
# value 타입 확인
for suffix in self.value_suffixes:
if key_lower.endswith(suffix.lower()):
return "value"
# x 타입 확인
for suffix in self.x_suffixes:
if key_lower.endswith(suffix.lower()):
return "x"
# y 타입 확인
for suffix in self.y_suffixes:
if key_lower.endswith(suffix.lower()):
return "y"
return "other"
def _analyze_result_structure(self, result: Any) -> str:
"""결과 객체의 구조를 분석하여 문자열로 반환"""
try:
info = []
# 기본 속성들 확인
if hasattr(result, 'file_name'):
info.append(f"file_name='{result.file_name}'")
if hasattr(result, 'file_type'):
info.append(f"file_type='{result.file_type}'")
if hasattr(result, 'success'):
info.append(f"success={result.success}")
# PDF 관련 속성
if hasattr(result, 'pdf_analysis_result'):
pdf_result = result.pdf_analysis_result
if pdf_result:
if isinstance(pdf_result, str):
info.append(f"pdf_analysis_result=str({len(pdf_result)} chars)")
else:
info.append(f"pdf_analysis_result={type(pdf_result).__name__}")
else:
info.append("pdf_analysis_result=None")
# DXF 관련 속성
if hasattr(result, 'dxf_title_blocks'):
dxf_blocks = result.dxf_title_blocks
if dxf_blocks:
info.append(f"dxf_title_blocks=list({len(dxf_blocks)} blocks)")
else:
info.append("dxf_title_blocks=None")
return " | ".join(info) if info else "구조 분석 실패"
except Exception as e:
return f"분석 오류: {str(e)}"
def _print_debug_summary(self, processing_results: List[Any]):
"""디버깅을 위한 요약 정보 출력"""
logger.info("=== 디버깅 요약 ===")
success_count = 0
pdf_count = 0
dxf_count = 0
has_pdf_data = 0
has_dxf_data = 0
for i, result in enumerate(processing_results):
try:
if hasattr(result, 'success') and result.success:
success_count += 1
file_type = getattr(result, 'file_type', 'unknown').lower()
if file_type == 'pdf':
pdf_count += 1
if getattr(result, 'pdf_analysis_result', None):
has_pdf_data += 1
elif file_type == 'dxf':
dxf_count += 1
if getattr(result, 'dxf_title_blocks', None):
has_dxf_data += 1
except Exception as e:
logger.error(f"결과 {i+1} 분석 중 오류: {str(e)}")
logger.info(f"총 결과: {len(processing_results)}")
logger.info(f"성공한 결과: {success_count}")
logger.info(f"PDF 파일: {pdf_count}개 (분석 데이터 있음: {has_pdf_data}개)")
logger.info(f"DXF 파일: {dxf_count}개 (타이틀블록 데이터 있음: {has_dxf_data}개)")
def _extract_key_value_pairs(
self,
result: Any,
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""
단일 파일 결과에서 key-value 쌍 추출
Args:
result: 파일 처리 결과
include_coordinates: 좌표 정보 포함 여부
coordinate_source: 좌표 정보 출처
Returns:
key-value 쌍 리스트
"""
data_rows = []
try:
# 기본 정보 확인
file_name = getattr(result, 'file_name', 'Unknown')
file_type = getattr(result, 'file_type', 'Unknown')
base_info = {
'file_name': file_name,
'file_type': file_type,
}
if self.debug_mode:
logger.info(f"처리 중: {file_name} ({file_type})")
# PDF 분석 결과 처리
if file_type.lower() == 'pdf':
pdf_result = getattr(result, 'pdf_analysis_result', None)
if pdf_result:
pdf_rows = self._extract_pdf_key_values(result, base_info, include_coordinates, coordinate_source)
data_rows.extend(pdf_rows)
if self.debug_mode:
logger.info(f"PDF에서 {len(pdf_rows)}개 key-value 쌍 추출")
else:
if self.debug_mode:
logger.warning(f"PDF 분석 결과가 없습니다: {file_name}")
# DXF 분석 결과 처리
elif file_type.lower() == 'dxf':
dxf_blocks = getattr(result, 'dxf_title_blocks', None)
if dxf_blocks:
dxf_rows = self._extract_dxf_key_values(result, base_info, include_coordinates, coordinate_source)
data_rows.extend(dxf_rows)
if self.debug_mode:
logger.info(f"DXF에서 {len(dxf_rows)}개 key-value 쌍 추출")
else:
if self.debug_mode:
logger.warning(f"DXF 타이틀블록 데이터가 없습니다: {file_name}")
else:
if self.debug_mode:
logger.warning(f"지원하지 않는 파일 형식: {file_type}")
except Exception as e:
logger.error(f"Key-value 추출 오류 ({getattr(result, 'file_name', 'Unknown')}): {str(e)}")
return data_rows
def _extract_pdf_key_values(
self,
result: Any,
base_info: Dict[str, str],
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""PDF 분석 결과에서 key-value 쌍 추출"""
data_rows = []
try:
# PDF 분석 결과를 JSON으로 파싱
analysis_result = getattr(result, 'pdf_analysis_result', None)
if not analysis_result:
return data_rows
if isinstance(analysis_result, str):
try:
analysis_data = json.loads(analysis_result)
except json.JSONDecodeError:
# JSON이 아닌 경우 텍스트로 처리
analysis_data = {"분석결과": analysis_result}
else:
analysis_data = analysis_result
if self.debug_mode:
logger.info(f"PDF 분석 데이터 구조: {type(analysis_data).__name__}")
if isinstance(analysis_data, dict):
logger.info(f"PDF 분석 데이터 키: {list(analysis_data.keys())}")
# 중첩된 구조를 평탄화하여 key-value 쌍 생성
flattened_data = self._flatten_dict(analysis_data)
for key, value in flattened_data.items():
if value is None or str(value).strip() == "":
continue # 빈 값 제외
row_data = base_info.copy()
row_data.update({
'key': key,
'value': str(value),
})
# 좌표 정보 추가
if include_coordinates and coordinate_source != "none":
coordinates = self._extract_coordinates(key, value, coordinate_source)
row_data.update(coordinates)
data_rows.append(row_data)
except Exception as e:
logger.error(f"PDF key-value 추출 오류: {str(e)}")
return data_rows
def _extract_dxf_key_values(
self,
result: Any,
base_info: Dict[str, str],
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""DXF 분석 결과에서 key-value 쌍 추출"""
data_rows = []
try:
title_blocks = getattr(result, 'dxf_title_blocks', None)
if not title_blocks:
return data_rows
if self.debug_mode:
logger.info(f"DXF 타이틀블록 수: {len(title_blocks)}")
for block_idx, title_block in enumerate(title_blocks):
if not isinstance(title_block, dict):
continue
block_name = title_block.get('block_name', 'Unknown')
# 블록 정보
row_data = base_info.copy()
row_data.update({
'key': f"{block_name}_블록명",
'value': block_name,
})
if include_coordinates and coordinate_source != "none":
coordinates = self._extract_coordinates('블록명', block_name, coordinate_source)
row_data.update(coordinates)
data_rows.append(row_data)
# 속성 정보
attributes = title_block.get('attributes', [])
if self.debug_mode:
logger.info(f"블록 {block_idx+1} ({block_name}): {len(attributes)}개 속성")
for attr_idx, attr in enumerate(attributes):
if not isinstance(attr, dict):
continue
attr_text = attr.get('text', '')
if not attr_text or str(attr_text).strip() == "":
continue # 빈 속성 제외
# 속성별 key-value 쌍 생성
attr_key = attr.get('tag', attr.get('prompt', f'Unknown_Attr_{attr_idx}'))
attr_value = str(attr_text)
row_data = base_info.copy()
row_data.update({
'key': attr_key,
'value': attr_value,
})
# DXF 속성의 경우 insert 좌표 사용
if include_coordinates and coordinate_source != "none":
x_coord = attr.get('insert_x', '')
y_coord = attr.get('insert_y', '')
if x_coord and y_coord:
row_data.update({
'x': round(float(x_coord), 2) if isinstance(x_coord, (int, float)) else x_coord,
'y': round(float(y_coord), 2) if isinstance(y_coord, (int, float)) else y_coord,
})
else:
row_data.update({'x': '', 'y': ''})
data_rows.append(row_data)
except Exception as e:
logger.error(f"DXF key-value 추출 오류: {str(e)}")
return data_rows
def _flatten_dict(self, data: Dict[str, Any], parent_key: str = '', sep: str = '_') -> Dict[str, Any]:
"""
중첩된 딕셔너리를 평탄화
Args:
data: 평탄화할 딕셔너리
parent_key: 부모 키
sep: 구분자
Returns:
평탄화된 딕셔너리
"""
items = []
for k, v in data.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
# 중첩된 딕셔너리인 경우 재귀 호출
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
elif isinstance(v, list):
# 리스트인 경우 인덱스와 함께 처리
for i, item in enumerate(v):
if isinstance(item, dict):
items.extend(self._flatten_dict(item, f"{new_key}_{i}", sep=sep).items())
else:
items.append((f"{new_key}_{i}", item))
else:
items.append((new_key, v))
return dict(items)
def _extract_coordinates(self, key: str, value: str, coordinate_source: str) -> Dict[str, str]:
"""
텍스트에서 좌표 정보 추출
Args:
key: 키
value: 값
coordinate_source: 좌표 정보 출처
Returns:
좌표 딕셔너리
"""
coordinates = {'x': '', 'y': ''}
try:
# 값에서 좌표 패턴 찾기
matches = self.coordinate_pattern.findall(str(value))
if matches:
# 첫 번째 매치 사용
x, y = matches[0]
coordinates = {'x': x, 'y': y}
else:
# 키에서 좌표 정보 찾기
key_matches = self.coordinate_pattern.findall(str(key))
if key_matches:
x, y = key_matches[0]
coordinates = {'x': x, 'y': y}
except Exception as e:
logger.warning(f"좌표 추출 오류: {str(e)}")
return coordinates
def generate_cross_tabulated_csv_filename(base_name: str = "cross_tabulated_analysis") -> str:
"""기본 Cross-tabulated CSV 파일명 생성"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{base_name}_results_{timestamp}.csv"
# 사용 예시
if __name__ == "__main__":
# 테스트용 예시
exporter = CrossTabulatedCSVExporter()
# 샘플 처리 결과 (실제 데이터 구조에 맞게 수정)
sample_results = []
# 실제 사용 시에는 processing_results를 전달
# success = exporter.export_cross_tabulated_csv(
# sample_results,
# "test_cross_tabulated.csv",
# include_coordinates=True
# )
print("Cross-tabulated CSV 내보내기 모듈 (통합 버전) 테스트 완료")

View File

@@ -0,0 +1,331 @@
"""
Cross-Tabulated CSV 내보내기 모듈
JSON 형태의 분석 결과를 key-value 형태의 cross-tabulated CSV로 저장하는 기능을 제공합니다.
Author: Claude Assistant
Created: 2025-07-15
Version: 1.0.0
"""
import pandas as pd
import json
import logging
from datetime import datetime
from typing import List, Dict, Any, Optional, Union
import os
import re
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class CrossTabulatedCSVExporter:
"""Cross-Tabulated CSV 내보내기 클래스"""
def __init__(self):
"""Cross-Tabulated CSV 내보내기 초기화"""
self.coordinate_pattern = re.compile(r'\b(\d+)\s*,\s*(\d+)\b') # x,y 좌표 패턴
def export_cross_tabulated_csv(
self,
processing_results: List[Any],
output_path: str,
include_coordinates: bool = True,
coordinate_source: str = "auto" # "auto", "text_blocks", "analysis_result", "none"
) -> bool:
"""
처리 결과를 cross-tabulated CSV 형태로 저장
Args:
processing_results: 다중 파일 처리 결과 리스트
output_path: 출력 CSV 파일 경로
include_coordinates: 좌표 정보 포함 여부
coordinate_source: 좌표 정보 출처 ("auto", "text_blocks", "analysis_result", "none")
Returns:
저장 성공 여부
"""
try:
logger.info(f"Cross-tabulated CSV 저장 시작: {len(processing_results)}개 파일")
# 모든 파일의 key-value 쌍을 수집
all_data_rows = []
for result in processing_results:
if not result.success:
continue # 실패한 파일은 제외
file_data = self._extract_key_value_pairs(result, include_coordinates, coordinate_source)
all_data_rows.extend(file_data)
if not all_data_rows:
logger.warning("저장할 데이터가 없습니다")
return False
# DataFrame 생성
df = pd.DataFrame(all_data_rows)
# 컬럼 순서 정렬
column_order = ['file_name', 'file_type', 'key', 'value']
if include_coordinates and coordinate_source != "none":
column_order.extend(['x', 'y'])
# 추가 컬럼들을 뒤에 배치
existing_columns = [col for col in column_order if col in df.columns]
additional_columns = [col for col in df.columns if col not in existing_columns]
df = df[existing_columns + additional_columns]
# UTF-8 BOM으로 저장 (한글 호환성)
df.to_csv(output_path, index=False, encoding='utf-8-sig')
logger.info(f"Cross-tabulated CSV 저장 완료: {output_path}")
logger.info(f"{len(all_data_rows)}개 key-value 쌍 저장")
return True
except Exception as e:
logger.error(f"Cross-tabulated CSV 저장 오류: {str(e)}")
return False
def _extract_key_value_pairs(
self,
result: Any,
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""
단일 파일 결과에서 key-value 쌍 추출
Args:
result: 파일 처리 결과
include_coordinates: 좌표 정보 포함 여부
coordinate_source: 좌표 정보 출처
Returns:
key-value 쌍 리스트
"""
data_rows = []
try:
# 기본 정보
base_info = {
'file_name': result.file_name,
'file_type': result.file_type,
}
# PDF 분석 결과 처리
if result.file_type.lower() == 'pdf' and result.pdf_analysis_result:
data_rows.extend(
self._extract_pdf_key_values(result, base_info, include_coordinates, coordinate_source)
)
# DXF 분석 결과 처리
elif result.file_type.lower() == 'dxf' and result.dxf_title_blocks:
data_rows.extend(
self._extract_dxf_key_values(result, base_info, include_coordinates, coordinate_source)
)
except Exception as e:
logger.error(f"Key-value 추출 오류 ({result.file_name}): {str(e)}")
return data_rows
def _extract_pdf_key_values(
self,
result: Any,
base_info: Dict[str, str],
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""PDF 분석 결과에서 key-value 쌍 추출"""
data_rows = []
try:
# PDF 분석 결과를 JSON으로 파싱
analysis_result = result.pdf_analysis_result
if isinstance(analysis_result, str):
try:
analysis_data = json.loads(analysis_result)
except json.JSONDecodeError:
# JSON이 아닌 경우 텍스트로 처리
analysis_data = {"분석결과": analysis_result}
else:
analysis_data = analysis_result
# 중첩된 구조를 평탄화하여 key-value 쌍 생성
flattened_data = self._flatten_dict(analysis_data)
for key, value in flattened_data.items():
if value is None or str(value).strip() == "":
continue # 빈 값 제외
row_data = base_info.copy()
row_data.update({
'key': key,
'value': str(value),
})
# 좌표 정보 추가
if include_coordinates and coordinate_source != "none":
coordinates = self._extract_coordinates(key, value, coordinate_source)
row_data.update(coordinates)
data_rows.append(row_data)
except Exception as e:
logger.error(f"PDF key-value 추출 오류: {str(e)}")
return data_rows
def _extract_dxf_key_values(
self,
result: Any,
base_info: Dict[str, str],
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""DXF 분석 결과에서 key-value 쌍 추출"""
data_rows = []
try:
for title_block in result.dxf_title_blocks:
block_name = title_block.get('block_name', 'Unknown')
# 블록 정보
row_data = base_info.copy()
row_data.update({
'key': f"{block_name}_블록명",
'value': block_name,
})
if include_coordinates and coordinate_source != "none":
coordinates = self._extract_coordinates('블록명', block_name, coordinate_source)
row_data.update(coordinates)
data_rows.append(row_data)
# 속성 정보
for attr in title_block.get('attributes', []):
if not attr.get('text') or str(attr.get('text')).strip() == "":
continue # 빈 속성 제외
# 속성별 key-value 쌍 생성
attr_key = attr.get('tag', attr.get('prompt', 'Unknown'))
attr_value = attr.get('text', '')
row_data = base_info.copy()
row_data.update({
'key': attr_key,
'value': str(attr_value),
})
# DXF 속성의 경우 insert 좌표 사용
if include_coordinates and coordinate_source != "none":
x_coord = attr.get('insert_x', '')
y_coord = attr.get('insert_y', '')
if x_coord and y_coord:
row_data.update({
'x': round(float(x_coord), 2) if isinstance(x_coord, (int, float)) else x_coord,
'y': round(float(y_coord), 2) if isinstance(y_coord, (int, float)) else y_coord,
})
else:
row_data.update({'x': '', 'y': ''})
data_rows.append(row_data)
except Exception as e:
logger.error(f"DXF key-value 추출 오류: {str(e)}")
return data_rows
def _flatten_dict(self, data: Dict[str, Any], parent_key: str = '', sep: str = '_') -> Dict[str, Any]:
"""
중첩된 딕셔너리를 평탄화
Args:
data: 평탄화할 딕셔너리
parent_key: 부모 키
sep: 구분자
Returns:
평탄화된 딕셔너리
"""
items = []
for k, v in data.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
# 중첩된 딕셔너리인 경우 재귀 호출
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
elif isinstance(v, list):
# 리스트인 경우 인덱스와 함께 처리
for i, item in enumerate(v):
if isinstance(item, dict):
items.extend(self._flatten_dict(item, f"{new_key}_{i}", sep=sep).items())
else:
items.append((f"{new_key}_{i}", item))
else:
items.append((new_key, v))
return dict(items)
def _extract_coordinates(self, key: str, value: str, coordinate_source: str) -> Dict[str, str]:
"""
텍스트에서 좌표 정보 추출
Args:
key: 키
value: 값
coordinate_source: 좌표 정보 출처
Returns:
좌표 딕셔너리
"""
coordinates = {'x': '', 'y': ''}
try:
# 값에서 좌표 패턴 찾기
matches = self.coordinate_pattern.findall(str(value))
if matches:
# 첫 번째 매치 사용
x, y = matches[0]
coordinates = {'x': x, 'y': y}
else:
# 키에서 좌표 정보 찾기
key_matches = self.coordinate_pattern.findall(str(key))
if key_matches:
x, y = key_matches[0]
coordinates = {'x': x, 'y': y}
except Exception as e:
logger.warning(f"좌표 추출 오류: {str(e)}")
return coordinates
def generate_cross_tabulated_csv_filename(base_name: str = "cross_tabulated_analysis") -> str:
"""기본 Cross-tabulated CSV 파일명 생성"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{base_name}_results_{timestamp}.csv"
# 사용 예시
if __name__ == "__main__":
# 테스트용 예시
exporter = CrossTabulatedCSVExporter()
# 샘플 처리 결과 (실제 데이터 구조에 맞게 수정)
sample_results = []
# 실제 사용 시에는 processing_results를 전달
# success = exporter.export_cross_tabulated_csv(
# sample_results,
# "test_cross_tabulated.csv",
# include_coordinates=True
# )
print("Cross-tabulated CSV 내보내기 모듈 테스트 완료")

View File

@@ -0,0 +1,489 @@
"""
Cross-Tabulated CSV 내보내기 모듈 (수정 버전)
JSON 형태의 분석 결과를 key-value 형태의 cross-tabulated CSV로 저장하는 기능을 제공합니다.
Author: Claude Assistant
Created: 2025-07-15
Updated: 2025-07-16 (디버깅 개선 버전)
Version: 1.1.0
"""
import pandas as pd
import json
import logging
from datetime import datetime
from typing import List, Dict, Any, Optional, Union
import os
import re
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class CrossTabulatedCSVExporter:
"""Cross-Tabulated CSV 내보내기 클래스 (수정 버전)"""
def __init__(self):
"""Cross-Tabulated CSV 내보내기 초기화"""
self.coordinate_pattern = re.compile(r'\b(\d+)\s*,\s*(\d+)\b') # x,y 좌표 패턴
self.debug_mode = True # 디버깅 모드 활성화
def export_cross_tabulated_csv(
self,
processing_results: List[Any],
output_path: str,
include_coordinates: bool = True,
coordinate_source: str = "auto" # "auto", "text_blocks", "analysis_result", "none"
) -> bool:
"""
처리 결과를 cross-tabulated CSV 형태로 저장
Args:
processing_results: 다중 파일 처리 결과 리스트
output_path: 출력 CSV 파일 경로
include_coordinates: 좌표 정보 포함 여부
coordinate_source: 좌표 정보 출처 ("auto", "text_blocks", "analysis_result", "none")
Returns:
저장 성공 여부
"""
try:
if self.debug_mode:
logger.info(f"=== Cross-tabulated CSV 저장 시작 ===")
logger.info(f"입력된 결과 수: {len(processing_results)}")
logger.info(f"출력 경로: {output_path}")
logger.info(f"좌표 포함: {include_coordinates}, 좌표 출처: {coordinate_source}")
# 입력 데이터 검증
if not processing_results:
logger.warning("입력된 처리 결과가 비어있습니다.")
return False
# 각 결과 객체의 구조 분석
for i, result in enumerate(processing_results):
if self.debug_mode:
logger.info(f"결과 {i+1}: {self._analyze_result_structure(result)}")
# 모든 파일의 key-value 쌍을 수집
all_data_rows = []
for i, result in enumerate(processing_results):
try:
if not hasattr(result, 'success'):
logger.warning(f"결과 {i+1}: 'success' 속성이 없습니다. 스킵합니다.")
continue
if not result.success:
if self.debug_mode:
logger.info(f"결과 {i+1}: 실패한 파일, 스킵합니다 ({getattr(result, 'error_message', 'Unknown error')})")
continue # 실패한 파일은 제외
file_data = self._extract_key_value_pairs(result, include_coordinates, coordinate_source)
if file_data:
all_data_rows.extend(file_data)
if self.debug_mode:
logger.info(f"결과 {i+1}: {len(file_data)}개 key-value 쌍 추출")
else:
if self.debug_mode:
logger.warning(f"결과 {i+1}: key-value 쌍을 추출할 수 없습니다")
except Exception as e:
logger.error(f"결과 {i+1} 처리 중 오류: {str(e)}")
continue
if not all_data_rows:
logger.warning("저장할 데이터가 없습니다. 모든 파일에서 유효한 key-value 쌍을 추출할 수 없었습니다.")
if self.debug_mode:
self._print_debug_summary(processing_results)
return False
# DataFrame 생성
df = pd.DataFrame(all_data_rows)
# 컬럼 순서 정렬
column_order = ['file_name', 'file_type', 'key', 'value']
if include_coordinates and coordinate_source != "none":
column_order.extend(['x', 'y'])
# 추가 컬럼들을 뒤에 배치
existing_columns = [col for col in column_order if col in df.columns]
additional_columns = [col for col in df.columns if col not in existing_columns]
df = df[existing_columns + additional_columns]
# 출력 디렉토리 생성
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# UTF-8 BOM으로 저장 (한글 호환성)
df.to_csv(output_path, index=False, encoding='utf-8-sig')
logger.info(f"Cross-tabulated CSV 저장 완료: {output_path}")
logger.info(f"{len(all_data_rows)}개 key-value 쌍 저장")
return True
except Exception as e:
logger.error(f"Cross-tabulated CSV 저장 오류: {str(e)}")
return False
def _analyze_result_structure(self, result: Any) -> str:
"""결과 객체의 구조를 분석하여 문자열로 반환"""
try:
info = []
# 기본 속성들 확인
if hasattr(result, 'file_name'):
info.append(f"file_name='{result.file_name}'")
if hasattr(result, 'file_type'):
info.append(f"file_type='{result.file_type}'")
if hasattr(result, 'success'):
info.append(f"success={result.success}")
# PDF 관련 속성
if hasattr(result, 'pdf_analysis_result'):
pdf_result = result.pdf_analysis_result
if pdf_result:
if isinstance(pdf_result, str):
info.append(f"pdf_analysis_result=str({len(pdf_result)} chars)")
else:
info.append(f"pdf_analysis_result={type(pdf_result).__name__}")
else:
info.append("pdf_analysis_result=None")
# DXF 관련 속성
if hasattr(result, 'dxf_title_blocks'):
dxf_blocks = result.dxf_title_blocks
if dxf_blocks:
info.append(f"dxf_title_blocks=list({len(dxf_blocks)} blocks)")
else:
info.append("dxf_title_blocks=None")
return " | ".join(info) if info else "구조 분석 실패"
except Exception as e:
return f"분석 오류: {str(e)}"
def _print_debug_summary(self, processing_results: List[Any]):
"""디버깅을 위한 요약 정보 출력"""
logger.info("=== 디버깅 요약 ===")
success_count = 0
pdf_count = 0
dxf_count = 0
has_pdf_data = 0
has_dxf_data = 0
for i, result in enumerate(processing_results):
try:
if hasattr(result, 'success') and result.success:
success_count += 1
file_type = getattr(result, 'file_type', 'unknown').lower()
if file_type == 'pdf':
pdf_count += 1
if getattr(result, 'pdf_analysis_result', None):
has_pdf_data += 1
elif file_type == 'dxf':
dxf_count += 1
if getattr(result, 'dxf_title_blocks', None):
has_dxf_data += 1
except Exception as e:
logger.error(f"결과 {i+1} 분석 중 오류: {str(e)}")
logger.info(f"총 결과: {len(processing_results)}")
logger.info(f"성공한 결과: {success_count}")
logger.info(f"PDF 파일: {pdf_count}개 (분석 데이터 있음: {has_pdf_data}개)")
logger.info(f"DXF 파일: {dxf_count}개 (타이틀블록 데이터 있음: {has_dxf_data}개)")
def _extract_key_value_pairs(
self,
result: Any,
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""
단일 파일 결과에서 key-value 쌍 추출
Args:
result: 파일 처리 결과
include_coordinates: 좌표 정보 포함 여부
coordinate_source: 좌표 정보 출처
Returns:
key-value 쌍 리스트
"""
data_rows = []
try:
# 기본 정보 확인
file_name = getattr(result, 'file_name', 'Unknown')
file_type = getattr(result, 'file_type', 'Unknown')
base_info = {
'file_name': file_name,
'file_type': file_type,
}
if self.debug_mode:
logger.info(f"처리 중: {file_name} ({file_type})")
# PDF 분석 결과 처리
if file_type.lower() == 'pdf':
pdf_result = getattr(result, 'pdf_analysis_result', None)
if pdf_result:
pdf_rows = self._extract_pdf_key_values(result, base_info, include_coordinates, coordinate_source)
data_rows.extend(pdf_rows)
if self.debug_mode:
logger.info(f"PDF에서 {len(pdf_rows)}개 key-value 쌍 추출")
else:
if self.debug_mode:
logger.warning(f"PDF 분석 결과가 없습니다: {file_name}")
# DXF 분석 결과 처리
elif file_type.lower() == 'dxf':
dxf_blocks = getattr(result, 'dxf_title_blocks', None)
if dxf_blocks:
dxf_rows = self._extract_dxf_key_values(result, base_info, include_coordinates, coordinate_source)
data_rows.extend(dxf_rows)
if self.debug_mode:
logger.info(f"DXF에서 {len(dxf_rows)}개 key-value 쌍 추출")
else:
if self.debug_mode:
logger.warning(f"DXF 타이틀블록 데이터가 없습니다: {file_name}")
else:
if self.debug_mode:
logger.warning(f"지원하지 않는 파일 형식: {file_type}")
except Exception as e:
logger.error(f"Key-value 추출 오류 ({getattr(result, 'file_name', 'Unknown')}): {str(e)}")
return data_rows
def _extract_pdf_key_values(
self,
result: Any,
base_info: Dict[str, str],
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""PDF 분석 결과에서 key-value 쌍 추출"""
data_rows = []
try:
# PDF 분석 결과를 JSON으로 파싱
analysis_result = getattr(result, 'pdf_analysis_result', None)
if not analysis_result:
return data_rows
if isinstance(analysis_result, str):
try:
analysis_data = json.loads(analysis_result)
except json.JSONDecodeError:
# JSON이 아닌 경우 텍스트로 처리
analysis_data = {"분석결과": analysis_result}
else:
analysis_data = analysis_result
if self.debug_mode:
logger.info(f"PDF 분석 데이터 구조: {type(analysis_data).__name__}")
if isinstance(analysis_data, dict):
logger.info(f"PDF 분석 데이터 키: {list(analysis_data.keys())}")
# 중첩된 구조를 평탄화하여 key-value 쌍 생성
flattened_data = self._flatten_dict(analysis_data)
for key, value in flattened_data.items():
if value is None or str(value).strip() == "":
continue # 빈 값 제외
row_data = base_info.copy()
row_data.update({
'key': key,
'value': str(value),
})
# 좌표 정보 추가
if include_coordinates and coordinate_source != "none":
coordinates = self._extract_coordinates(key, value, coordinate_source)
row_data.update(coordinates)
data_rows.append(row_data)
except Exception as e:
logger.error(f"PDF key-value 추출 오류: {str(e)}")
return data_rows
def _extract_dxf_key_values(
self,
result: Any,
base_info: Dict[str, str],
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""DXF 분석 결과에서 key-value 쌍 추출"""
data_rows = []
try:
title_blocks = getattr(result, 'dxf_title_blocks', None)
if not title_blocks:
return data_rows
if self.debug_mode:
logger.info(f"DXF 타이틀블록 수: {len(title_blocks)}")
for block_idx, title_block in enumerate(title_blocks):
if not isinstance(title_block, dict):
continue
block_name = title_block.get('block_name', 'Unknown')
# 블록 정보
row_data = base_info.copy()
row_data.update({
'key': f"{block_name}_블록명",
'value': block_name,
})
if include_coordinates and coordinate_source != "none":
coordinates = self._extract_coordinates('블록명', block_name, coordinate_source)
row_data.update(coordinates)
data_rows.append(row_data)
# 속성 정보
attributes = title_block.get('attributes', [])
if self.debug_mode:
logger.info(f"블록 {block_idx+1} ({block_name}): {len(attributes)}개 속성")
for attr_idx, attr in enumerate(attributes):
if not isinstance(attr, dict):
continue
attr_text = attr.get('text', '')
if not attr_text or str(attr_text).strip() == "":
continue # 빈 속성 제외
# 속성별 key-value 쌍 생성
attr_key = attr.get('tag', attr.get('prompt', f'Unknown_Attr_{attr_idx}'))
attr_value = str(attr_text)
row_data = base_info.copy()
row_data.update({
'key': attr_key,
'value': attr_value,
})
# DXF 속성의 경우 insert 좌표 사용
if include_coordinates and coordinate_source != "none":
x_coord = attr.get('insert_x', '')
y_coord = attr.get('insert_y', '')
if x_coord and y_coord:
row_data.update({
'x': round(float(x_coord), 2) if isinstance(x_coord, (int, float)) else x_coord,
'y': round(float(y_coord), 2) if isinstance(y_coord, (int, float)) else y_coord,
})
else:
row_data.update({'x': '', 'y': ''})
data_rows.append(row_data)
except Exception as e:
logger.error(f"DXF key-value 추출 오류: {str(e)}")
return data_rows
def _flatten_dict(self, data: Dict[str, Any], parent_key: str = '', sep: str = '_') -> Dict[str, Any]:
"""
중첩된 딕셔너리를 평탄화
Args:
data: 평탄화할 딕셔너리
parent_key: 부모 키
sep: 구분자
Returns:
평탄화된 딕셔너리
"""
items = []
for k, v in data.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
# 중첩된 딕셔너리인 경우 재귀 호출
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
elif isinstance(v, list):
# 리스트인 경우 인덱스와 함께 처리
for i, item in enumerate(v):
if isinstance(item, dict):
items.extend(self._flatten_dict(item, f"{new_key}_{i}", sep=sep).items())
else:
items.append((f"{new_key}_{i}", item))
else:
items.append((new_key, v))
return dict(items)
def _extract_coordinates(self, key: str, value: str, coordinate_source: str) -> Dict[str, str]:
"""
텍스트에서 좌표 정보 추출
Args:
key: 키
value: 값
coordinate_source: 좌표 정보 출처
Returns:
좌표 딕셔너리
"""
coordinates = {'x': '', 'y': ''}
try:
# 값에서 좌표 패턴 찾기
matches = self.coordinate_pattern.findall(str(value))
if matches:
# 첫 번째 매치 사용
x, y = matches[0]
coordinates = {'x': x, 'y': y}
else:
# 키에서 좌표 정보 찾기
key_matches = self.coordinate_pattern.findall(str(key))
if key_matches:
x, y = key_matches[0]
coordinates = {'x': x, 'y': y}
except Exception as e:
logger.warning(f"좌표 추출 오류: {str(e)}")
return coordinates
def generate_cross_tabulated_csv_filename(base_name: str = "cross_tabulated_analysis") -> str:
"""기본 Cross-tabulated CSV 파일명 생성"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{base_name}_results_{timestamp}.csv"
# 사용 예시
if __name__ == "__main__":
# 테스트용 예시
exporter = CrossTabulatedCSVExporter()
# 샘플 처리 결과 (실제 데이터 구조에 맞게 수정)
sample_results = []
# 실제 사용 시에는 processing_results를 전달
# success = exporter.export_cross_tabulated_csv(
# sample_results,
# "test_cross_tabulated.csv",
# include_coordinates=True
# )
print("Cross-tabulated CSV 내보내기 모듈 (수정 버전) 테스트 완료")

View File

@@ -0,0 +1,489 @@
"""
Cross-Tabulated CSV 내보내기 모듈 (수정 버전)
JSON 형태의 분석 결과를 key-value 형태의 cross-tabulated CSV로 저장하는 기능을 제공합니다.
Author: Claude Assistant
Created: 2025-07-15
Updated: 2025-07-16 (디버깅 개선 버전)
Version: 1.1.0
"""
import pandas as pd
import json
import logging
from datetime import datetime
from typing import List, Dict, Any, Optional, Union
import os
import re
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class CrossTabulatedCSVExporter:
"""Cross-Tabulated CSV 내보내기 클래스 (수정 버전)"""
def __init__(self):
"""Cross-Tabulated CSV 내보내기 초기화"""
self.coordinate_pattern = re.compile(r'\b(\d+)\s*,\s*(\d+)\b') # x,y 좌표 패턴
self.debug_mode = True # 디버깅 모드 활성화
def export_cross_tabulated_csv(
self,
processing_results: List[Any],
output_path: str,
include_coordinates: bool = True,
coordinate_source: str = "auto" # "auto", "text_blocks", "analysis_result", "none"
) -> bool:
"""
처리 결과를 cross-tabulated CSV 형태로 저장
Args:
processing_results: 다중 파일 처리 결과 리스트
output_path: 출력 CSV 파일 경로
include_coordinates: 좌표 정보 포함 여부
coordinate_source: 좌표 정보 출처 ("auto", "text_blocks", "analysis_result", "none")
Returns:
저장 성공 여부
"""
try:
if self.debug_mode:
logger.info(f"=== Cross-tabulated CSV 저장 시작 ===")
logger.info(f"입력된 결과 수: {len(processing_results)}")
logger.info(f"출력 경로: {output_path}")
logger.info(f"좌표 포함: {include_coordinates}, 좌표 출처: {coordinate_source}")
# 입력 데이터 검증
if not processing_results:
logger.warning("입력된 처리 결과가 비어있습니다.")
return False
# 각 결과 객체의 구조 분석
for i, result in enumerate(processing_results):
if self.debug_mode:
logger.info(f"결과 {i+1}: {self._analyze_result_structure(result)}")
# 모든 파일의 key-value 쌍을 수집
all_data_rows = []
for i, result in enumerate(processing_results):
try:
if not hasattr(result, 'success'):
logger.warning(f"결과 {i+1}: 'success' 속성이 없습니다. 스킵합니다.")
continue
if not result.success:
if self.debug_mode:
logger.info(f"결과 {i+1}: 실패한 파일, 스킵합니다 ({getattr(result, 'error_message', 'Unknown error')})")
continue # 실패한 파일은 제외
file_data = self._extract_key_value_pairs(result, include_coordinates, coordinate_source)
if file_data:
all_data_rows.extend(file_data)
if self.debug_mode:
logger.info(f"결과 {i+1}: {len(file_data)}개 key-value 쌍 추출")
else:
if self.debug_mode:
logger.warning(f"결과 {i+1}: key-value 쌍을 추출할 수 없습니다")
except Exception as e:
logger.error(f"결과 {i+1} 처리 중 오류: {str(e)}")
continue
if not all_data_rows:
logger.warning("저장할 데이터가 없습니다. 모든 파일에서 유효한 key-value 쌍을 추출할 수 없었습니다.")
if self.debug_mode:
self._print_debug_summary(processing_results)
return False
# DataFrame 생성
df = pd.DataFrame(all_data_rows)
# 컬럼 순서 정렬
column_order = ['file_name', 'file_type', 'key', 'value']
if include_coordinates and coordinate_source != "none":
column_order.extend(['x', 'y'])
# 추가 컬럼들을 뒤에 배치
existing_columns = [col for col in column_order if col in df.columns]
additional_columns = [col for col in df.columns if col not in existing_columns]
df = df[existing_columns + additional_columns]
# 출력 디렉토리 생성
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# UTF-8 BOM으로 저장 (한글 호환성)
df.to_csv(output_path, index=False, encoding='utf-8-sig')
logger.info(f"Cross-tabulated CSV 저장 완료: {output_path}")
logger.info(f"{len(all_data_rows)}개 key-value 쌍 저장")
return True
except Exception as e:
logger.error(f"Cross-tabulated CSV 저장 오류: {str(e)}")
return False
def _analyze_result_structure(self, result: Any) -> str:
"""결과 객체의 구조를 분석하여 문자열로 반환"""
try:
info = []
# 기본 속성들 확인
if hasattr(result, 'file_name'):
info.append(f"file_name='{result.file_name}'")
if hasattr(result, 'file_type'):
info.append(f"file_type='{result.file_type}'")
if hasattr(result, 'success'):
info.append(f"success={result.success}")
# PDF 관련 속성
if hasattr(result, 'pdf_analysis_result'):
pdf_result = result.pdf_analysis_result
if pdf_result:
if isinstance(pdf_result, str):
info.append(f"pdf_analysis_result=str({len(pdf_result)} chars)")
else:
info.append(f"pdf_analysis_result={type(pdf_result).__name__}")
else:
info.append("pdf_analysis_result=None")
# DXF 관련 속성
if hasattr(result, 'dxf_title_blocks'):
dxf_blocks = result.dxf_title_blocks
if dxf_blocks:
info.append(f"dxf_title_blocks=list({len(dxf_blocks)} blocks)")
else:
info.append("dxf_title_blocks=None")
return " | ".join(info) if info else "구조 분석 실패"
except Exception as e:
return f"분석 오류: {str(e)}"
def _print_debug_summary(self, processing_results: List[Any]):
"""디버깅을 위한 요약 정보 출력"""
logger.info("=== 디버깅 요약 ===")
success_count = 0
pdf_count = 0
dxf_count = 0
has_pdf_data = 0
has_dxf_data = 0
for i, result in enumerate(processing_results):
try:
if hasattr(result, 'success') and result.success:
success_count += 1
file_type = getattr(result, 'file_type', 'unknown').lower()
if file_type == 'pdf':
pdf_count += 1
if getattr(result, 'pdf_analysis_result', None):
has_pdf_data += 1
elif file_type == 'dxf':
dxf_count += 1
if getattr(result, 'dxf_title_blocks', None):
has_dxf_data += 1
except Exception as e:
logger.error(f"결과 {i+1} 분석 중 오류: {str(e)}")
logger.info(f"총 결과: {len(processing_results)}")
logger.info(f"성공한 결과: {success_count}")
logger.info(f"PDF 파일: {pdf_count}개 (분석 데이터 있음: {has_pdf_data}개)")
logger.info(f"DXF 파일: {dxf_count}개 (타이틀블록 데이터 있음: {has_dxf_data}개)")
def _extract_key_value_pairs(
self,
result: Any,
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""
단일 파일 결과에서 key-value 쌍 추출
Args:
result: 파일 처리 결과
include_coordinates: 좌표 정보 포함 여부
coordinate_source: 좌표 정보 출처
Returns:
key-value 쌍 리스트
"""
data_rows = []
try:
# 기본 정보 확인
file_name = getattr(result, 'file_name', 'Unknown')
file_type = getattr(result, 'file_type', 'Unknown')
base_info = {
'file_name': file_name,
'file_type': file_type,
}
if self.debug_mode:
logger.info(f"처리 중: {file_name} ({file_type})")
# PDF 분석 결과 처리
if file_type.lower() == 'pdf':
pdf_result = getattr(result, 'pdf_analysis_result', None)
if pdf_result:
pdf_rows = self._extract_pdf_key_values(result, base_info, include_coordinates, coordinate_source)
data_rows.extend(pdf_rows)
if self.debug_mode:
logger.info(f"PDF에서 {len(pdf_rows)}개 key-value 쌍 추출")
else:
if self.debug_mode:
logger.warning(f"PDF 분석 결과가 없습니다: {file_name}")
# DXF 분석 결과 처리
elif file_type.lower() == 'dxf':
dxf_blocks = getattr(result, 'dxf_title_blocks', None)
if dxf_blocks:
dxf_rows = self._extract_dxf_key_values(result, base_info, include_coordinates, coordinate_source)
data_rows.extend(dxf_rows)
if self.debug_mode:
logger.info(f"DXF에서 {len(dxf_rows)}개 key-value 쌍 추출")
else:
if self.debug_mode:
logger.warning(f"DXF 타이틀블록 데이터가 없습니다: {file_name}")
else:
if self.debug_mode:
logger.warning(f"지원하지 않는 파일 형식: {file_type}")
except Exception as e:
logger.error(f"Key-value 추출 오류 ({getattr(result, 'file_name', 'Unknown')}): {str(e)}")
return data_rows
def _extract_pdf_key_values(
self,
result: Any,
base_info: Dict[str, str],
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""PDF 분석 결과에서 key-value 쌍 추출"""
data_rows = []
try:
# PDF 분석 결과를 JSON으로 파싱
analysis_result = getattr(result, 'pdf_analysis_result', None)
if not analysis_result:
return data_rows
if isinstance(analysis_result, str):
try:
analysis_data = json.loads(analysis_result)
except json.JSONDecodeError:
# JSON이 아닌 경우 텍스트로 처리
analysis_data = {"분석결과": analysis_result}
else:
analysis_data = analysis_result
if self.debug_mode:
logger.info(f"PDF 분석 데이터 구조: {type(analysis_data).__name__}")
if isinstance(analysis_data, dict):
logger.info(f"PDF 분석 데이터 키: {list(analysis_data.keys())}")
# 중첩된 구조를 평탄화하여 key-value 쌍 생성
flattened_data = self._flatten_dict(analysis_data)
for key, value in flattened_data.items():
if value is None or str(value).strip() == "":
continue # 빈 값 제외
row_data = base_info.copy()
row_data.update({
'key': key,
'value': str(value),
})
# 좌표 정보 추가
if include_coordinates and coordinate_source != "none":
coordinates = self._extract_coordinates(key, value, coordinate_source)
row_data.update(coordinates)
data_rows.append(row_data)
except Exception as e:
logger.error(f"PDF key-value 추출 오류: {str(e)}")
return data_rows
def _extract_dxf_key_values(
self,
result: Any,
base_info: Dict[str, str],
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""DXF 분석 결과에서 key-value 쌍 추출"""
data_rows = []
try:
title_blocks = getattr(result, 'dxf_title_blocks', None)
if not title_blocks:
return data_rows
if self.debug_mode:
logger.info(f"DXF 타이틀블록 수: {len(title_blocks)}")
for block_idx, title_block in enumerate(title_blocks):
if not isinstance(title_block, dict):
continue
block_name = title_block.get('block_name', 'Unknown')
# 블록 정보
row_data = base_info.copy()
row_data.update({
'key': f"{block_name}_블록명",
'value': block_name,
})
if include_coordinates and coordinate_source != "none":
coordinates = self._extract_coordinates('블록명', block_name, coordinate_source)
row_data.update(coordinates)
data_rows.append(row_data)
# 속성 정보
attributes = title_block.get('attributes', [])
if self.debug_mode:
logger.info(f"블록 {block_idx+1} ({block_name}): {len(attributes)}개 속성")
for attr_idx, attr in enumerate(attributes):
if not isinstance(attr, dict):
continue
attr_text = attr.get('text', '')
if not attr_text or str(attr_text).strip() == "":
continue # 빈 속성 제외
# 속성별 key-value 쌍 생성
attr_key = attr.get('tag', attr.get('prompt', f'Unknown_Attr_{attr_idx}'))
attr_value = str(attr_text)
row_data = base_info.copy()
row_data.update({
'key': attr_key,
'value': attr_value,
})
# DXF 속성의 경우 insert 좌표 사용
if include_coordinates and coordinate_source != "none":
x_coord = attr.get('insert_x', '')
y_coord = attr.get('insert_y', '')
if x_coord and y_coord:
row_data.update({
'x': round(float(x_coord), 2) if isinstance(x_coord, (int, float)) else x_coord,
'y': round(float(y_coord), 2) if isinstance(y_coord, (int, float)) else y_coord,
})
else:
row_data.update({'x': '', 'y': ''})
data_rows.append(row_data)
except Exception as e:
logger.error(f"DXF key-value 추출 오류: {str(e)}")
return data_rows
def _flatten_dict(self, data: Dict[str, Any], parent_key: str = '', sep: str = '_') -> Dict[str, Any]:
"""
중첩된 딕셔너리를 평탄화
Args:
data: 평탄화할 딕셔너리
parent_key: 부모 키
sep: 구분자
Returns:
평탄화된 딕셔너리
"""
items = []
for k, v in data.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
# 중첩된 딕셔너리인 경우 재귀 호출
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
elif isinstance(v, list):
# 리스트인 경우 인덱스와 함께 처리
for i, item in enumerate(v):
if isinstance(item, dict):
items.extend(self._flatten_dict(item, f"{new_key}_{i}", sep=sep).items())
else:
items.append((f"{new_key}_{i}", item))
else:
items.append((new_key, v))
return dict(items)
def _extract_coordinates(self, key: str, value: str, coordinate_source: str) -> Dict[str, str]:
"""
텍스트에서 좌표 정보 추출
Args:
key: 키
value: 값
coordinate_source: 좌표 정보 출처
Returns:
좌표 딕셔너리
"""
coordinates = {'x': '', 'y': ''}
try:
# 값에서 좌표 패턴 찾기
matches = self.coordinate_pattern.findall(str(value))
if matches:
# 첫 번째 매치 사용
x, y = matches[0]
coordinates = {'x': x, 'y': y}
else:
# 키에서 좌표 정보 찾기
key_matches = self.coordinate_pattern.findall(str(key))
if key_matches:
x, y = key_matches[0]
coordinates = {'x': x, 'y': y}
except Exception as e:
logger.warning(f"좌표 추출 오류: {str(e)}")
return coordinates
def generate_cross_tabulated_csv_filename(base_name: str = "cross_tabulated_analysis") -> str:
"""기본 Cross-tabulated CSV 파일명 생성"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{base_name}_results_{timestamp}.csv"
# 사용 예시
if __name__ == "__main__":
# 테스트용 예시
exporter = CrossTabulatedCSVExporter()
# 샘플 처리 결과 (실제 데이터 구조에 맞게 수정)
sample_results = []
# 실제 사용 시에는 processing_results를 전달
# success = exporter.export_cross_tabulated_csv(
# sample_results,
# "test_cross_tabulated.csv",
# include_coordinates=True
# )
print("Cross-tabulated CSV 내보내기 모듈 (수정 버전) 테스트 완료")

306
csv_exporter.py Normal file
View File

@@ -0,0 +1,306 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CSV 저장 유틸리티 모듈
DXF 타이틀블럭 Attribute 정보를 CSV 형식으로 저장
"""
import csv
import os
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime
from config import Config
logger = logging.getLogger(__name__)
class TitleBlockCSVExporter:
"""타이틀블럭 속성 정보 CSV 저장 클래스"""
def __init__(self, output_dir: str = None):
"""CSV 저장기 초기화"""
self.output_dir = output_dir or Config.RESULTS_FOLDER
os.makedirs(self.output_dir, exist_ok=True)
def export_title_block_attributes(
self,
title_block_info: Dict[str, Any],
filename: str = None
) -> Optional[str]:
"""
타이틀블럭 속성 정보를 CSV 파일로 저장
Args:
title_block_info: 타이틀블럭 정보 딕셔너리
filename: 저장할 파일명 (없으면 자동 생성)
Returns:
저장된 파일 경로 또는 None (실패시)
"""
try:
if not title_block_info or not title_block_info.get('all_attributes'):
logger.warning("타이틀블럭 속성 정보가 없습니다.")
return None
# 파일명 생성
if not filename:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
block_name = title_block_info.get('block_name', 'Unknown_Block')
filename = f"title_block_attributes_{block_name}_{timestamp}.csv"
# 확장자 확인
if not filename.endswith('.csv'):
filename += '.csv'
filepath = os.path.join(self.output_dir, filename)
# CSV 헤더 정의
headers = [
'block_name', # block_ref.name
'attr_prompt', # attr.prompt
'attr_text', # attr.text
'attr_tag', # attr.tag
'attr_insert_x', # attr.insert_x
'attr_insert_y', # attr.insert_y
'bounding_box_min_x', # attr.bounding_box.min_x
'bounding_box_min_y', # attr.bounding_box.min_y
'bounding_box_max_x', # attr.bounding_box.max_x
'bounding_box_max_y', # attr.bounding_box.max_y
'bounding_box_width', # attr.bounding_box.width
'bounding_box_height', # attr.bounding_box.height
'attr_height', # 추가: 텍스트 높이
'attr_rotation', # 추가: 회전각
'attr_layer', # 추가: 레이어
'attr_style', # 추가: 스타일
'entity_handle' # 추가: 엔티티 핸들
]
# CSV 데이터 준비
csv_rows = []
block_name = title_block_info.get('block_name', '')
for attr in title_block_info.get('all_attributes', []):
row = {
'block_name': block_name,
'attr_prompt': attr.get('prompt', '') or '',
'attr_text': attr.get('text', '') or '',
'attr_tag': attr.get('tag', '') or '',
'attr_insert_x': attr.get('insert_x', '') or '',
'attr_insert_y': attr.get('insert_y', '') or '',
'attr_height': attr.get('height', '') or '',
'attr_rotation': attr.get('rotation', '') or '',
'attr_layer': attr.get('layer', '') or '',
'attr_style': attr.get('style', '') or '',
'entity_handle': attr.get('entity_handle', '') or '',
}
# 바운딩 박스 정보 추가
bbox = attr.get('bounding_box')
if bbox:
row.update({
'bounding_box_min_x': bbox.get('min_x', ''),
'bounding_box_min_y': bbox.get('min_y', ''),
'bounding_box_max_x': bbox.get('max_x', ''),
'bounding_box_max_y': bbox.get('max_y', ''),
'bounding_box_width': bbox.get('max_x', 0) - bbox.get('min_x', 0) if bbox.get('max_x') and bbox.get('min_x') else '',
'bounding_box_height': bbox.get('max_y', 0) - bbox.get('min_y', 0) if bbox.get('max_y') and bbox.get('min_y') else '',
})
else:
row.update({
'bounding_box_min_x': '',
'bounding_box_min_y': '',
'bounding_box_max_x': '',
'bounding_box_max_y': '',
'bounding_box_width': '',
'bounding_box_height': '',
})
csv_rows.append(row)
# CSV 파일 저장
with open(filepath, 'w', newline='', encoding='utf-8-sig') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=headers)
# 헤더 작성
writer.writeheader()
# 데이터 작성
writer.writerows(csv_rows)
logger.info(f"타이틀블럭 속성 CSV 저장 완료: {filepath}")
return filepath
except Exception as e:
logger.error(f"CSV 저장 중 오류: {e}")
return None
def create_attribute_table_data(
self,
title_block_info: Dict[str, Any]
) -> List[Dict[str, str]]:
"""
UI 테이블 표시용 데이터 생성
Args:
title_block_info: 타이틀블럭 정보 딕셔너리
Returns:
테이블 표시용 데이터 리스트
"""
try:
if not title_block_info or not title_block_info.get('all_attributes'):
return []
table_data = []
block_name = title_block_info.get('block_name', '')
for i, attr in enumerate(title_block_info.get('all_attributes', [])):
# 바운딩 박스 정보 포맷팅
bbox_str = ""
bbox = attr.get('bounding_box')
if bbox:
bbox_str = f"({bbox.get('min_x', 0):.1f}, {bbox.get('min_y', 0):.1f}) - ({bbox.get('max_x', 0):.1f}, {bbox.get('max_y', 0):.1f})"
row = {
'No.': str(i + 1),
'Block Name': block_name,
'Tag': attr.get('tag', ''),
'Text': attr.get('text', '')[:30] + ('...' if len(attr.get('text', '')) > 30 else ''), # 텍스트 길이 제한
'Prompt': attr.get('prompt', '') or 'N/A',
'X': f"{attr.get('insert_x', 0):.1f}",
'Y': f"{attr.get('insert_y', 0):.1f}",
'Bounding Box': bbox_str or 'N/A',
'Height': f"{attr.get('height', 0):.1f}",
'Layer': attr.get('layer', ''),
}
table_data.append(row)
return table_data
except Exception as e:
logger.error(f"테이블 데이터 생성 중 오류: {e}")
return []
def main():
"""테스트용 메인 함수"""
logging.basicConfig(level=logging.INFO)
# 테스트 데이터
test_title_block = {
'block_name': 'TEST_TITLE_BLOCK',
'all_attributes': [
{
'tag': 'DRAWING_NAME',
'text': '테스트 도면',
'prompt': '도면명을 입력하세요',
'insert_x': 100.0,
'insert_y': 200.0,
'height': 5.0,
'rotation': 0.0,
'layer': '0',
'style': 'Standard',
'entity_handle': 'ABC123',
'bounding_box': {
'min_x': 100.0,
'min_y': 200.0,
'max_x': 180.0,
'max_y': 210.0
}
},
{
'tag': 'DRAWING_NUMBER',
'text': 'TEST-001',
'prompt': '도면번호를 입력하세요',
'insert_x': 100.0,
'insert_y': 190.0,
'height': 4.0,
'rotation': 0.0,
'layer': '0',
'style': 'Standard',
'entity_handle': 'DEF456',
'bounding_box': {
'min_x': 100.0,
'min_y': 190.0,
'max_x': 150.0,
'max_y': 198.0
}
}
]
}
# CSV 저장 테스트
exporter = TitleBlockCSVExporter()
# 테이블 데이터 생성 테스트
table_data = exporter.create_attribute_table_data(test_title_block)
print("테이블 데이터:")
for row in table_data:
print(row)
# CSV 저장 테스트
saved_path = exporter.export_title_block_attributes(test_title_block, "test_export.csv")
if saved_path:
print(f"\nCSV 저장 성공: {saved_path}")
else:
print("\nCSV 저장 실패")
if __name__ == "__main__":
main()
import json
def export_analysis_results_to_csv(data: List[Dict[str, Any]], file_path: str):
"""
분석 결과를 CSV 파일로 저장합니다. pdf_analysis_result 컬럼의 JSON 데이터를 평탄화합니다.
Args:
data: 분석 결과 딕셔너리 리스트
file_path: 저장할 CSV 파일 경로
"""
if not data:
logger.warning("내보낼 데이터가 없습니다.")
return
all_keys = set()
processed_data = []
for row in data:
new_row = row.copy()
if 'pdf_analysis_result' in new_row and new_row['pdf_analysis_result']:
try:
json_data = new_row['pdf_analysis_result']
if isinstance(json_data, str):
json_data = json.loads(json_data)
if isinstance(json_data, dict):
for k, v in json_data.items():
new_row[f"pdf_analysis_result_{k}"] = v
del new_row['pdf_analysis_result']
else:
new_row['pdf_analysis_result'] = str(json_data)
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"pdf_analysis_result 파싱 오류: {e}, 원본 데이터 유지: {new_row['pdf_analysis_result']}")
new_row['pdf_analysis_result'] = str(new_row['pdf_analysis_result'])
processed_data.append(new_row)
all_keys.update(new_row.keys())
# 'pdf_analysis_result'가 평탄화된 경우 최종 키에서 제거
if 'pdf_analysis_result' in all_keys:
all_keys.remove('pdf_analysis_result')
sorted_keys = sorted(list(all_keys))
try:
with open(file_path, 'w', newline='', encoding='utf-8-sig') as output_file:
dict_writer = csv.DictWriter(output_file, sorted_keys)
dict_writer.writeheader()
dict_writer.writerows(processed_data)
logger.info(f"분석 결과 CSV 저장 완료: {file_path}")
except Exception as e:
logger.error(f"분석 결과 CSV 저장 중 오류: {e}")

484
docs/developer_guide.md Normal file
View File

@@ -0,0 +1,484 @@
# 개발자 가이드
PDF 도면 분석기의 개발 및 확장을 위한 가이드입니다.
## 목차
1. [프로젝트 구조](#프로젝트-구조)
2. [개발 환경 설정](#개발-환경-설정)
3. [모듈 구조](#모듈-구조)
4. [API 참조](#api-참조)
5. [확장 가이드](#확장-가이드)
6. [기여하기](#기여하기)
## 프로젝트 구조
```
fletimageanalysis/
├── main.py # 메인 애플리케이션 진입점
├── config.py # 설정 관리 모듈
├── pdf_processor.py # PDF 처리 및 이미지 변환
├── gemini_analyzer.py # Gemini API 연동
├── ui_components.py # UI 컴포넌트 정의
├── utils.py # 유틸리티 함수들
├── setup.py # 설치 스크립트
├── test_project.py # 테스트 스크립트
├── requirements.txt # Python 의존성
├── .env.example # 환경 변수 템플릿
├── README.md # 프로젝트 개요
├── LICENSE # 라이선스
├── project_plan.md # 프로젝트 계획
├── uploads/ # 업로드된 파일 저장
├── results/ # 분석 결과 저장
├── assets/ # 정적 자산
└── docs/ # 문서
├── user_guide.md # 사용자 가이드
└── developer_guide.md # 개발자 가이드
```
## 개발 환경 설정
### 필수 요구사항
- Python 3.9+
- pip 최신 버전
- Google Gemini API 키
### 개발 환경 구성
1. **저장소 클론**
```bash
git clone <repository-url>
cd fletimageanalysis
```
2. **가상 환경 생성**
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
venv\Scripts\activate # Windows
```
3. **개발 의존성 설치**
```bash
pip install -r requirements.txt
pip install black flake8 pytest # 개발 도구
```
4. **환경 설정**
```bash
cp .env.example .env
# .env 파일에서 GEMINI_API_KEY 설정
```
### 코드 스타일
프로젝트는 다음 코딩 스타일을 따릅니다:
- **포맷터**: Black
- **린터**: Flake8
- **라인 길이**: 88자
- **문서화**: Google 스타일 docstring
```bash
# 코드 포맷팅
black .
# 코드 검사
flake8 .
# 테스트 실행
python test_project.py
```
## 모듈 구조
### 1. config.py - 설정 관리
```python
class Config:
"""애플리케이션 설정 클래스"""
# 기본 설정
APP_TITLE = "PDF 도면 분석기"
APP_VERSION = "1.0.0"
# API 설정
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
GEMINI_MODEL = "gemini-2.5-flash"
# 파일 설정
MAX_FILE_SIZE_MB = 50
ALLOWED_EXTENSIONS = ["pdf"]
```
**주요 기능:**
- 환경 변수 로드
- 설정 유효성 검사
- 경로 관리
### 2. pdf_processor.py - PDF 처리
```python
class PDFProcessor:
"""PDF 파일 처리 클래스"""
def validate_pdf_file(self, file_path: str) -> bool:
"""PDF 파일 유효성 검사"""
def convert_pdf_page_to_image(self, file_path: str, page_number: int) -> Image:
"""PDF 페이지를 PIL Image로 변환"""
def pdf_page_to_base64(self, file_path: str, page_number: int) -> str:
"""PDF 페이지를 base64 문자열로 변환"""
```
**주요 기능:**
- PDF 파일 검증
- 페이지별 이미지 변환
- Base64 인코딩
- 메타데이터 추출
### 3. gemini_analyzer.py - API 연동
```python
class GeminiAnalyzer:
"""Gemini API 이미지 분석 클래스"""
def analyze_image_from_base64(self, base64_data: str, prompt: str) -> str:
"""Base64 이미지 데이터 분석"""
def analyze_pdf_images(self, base64_images: list, prompt: str) -> dict:
"""여러 PDF 페이지 일괄 분석"""
```
**주요 기능:**
- API 클라이언트 관리
- 이미지 분석 요청
- 응답 처리
- 오류 처리
### 4. ui_components.py - UI 컴포넌트
```python
class UIComponents:
"""UI 컴포넌트 클래스"""
@staticmethod
def create_app_bar() -> ft.AppBar:
"""애플리케이션 상단 바 생성"""
@staticmethod
def create_file_upload_section() -> ft.Container:
"""파일 업로드 섹션 생성"""
```
**주요 기능:**
- 재사용 가능한 UI 컴포넌트
- Material Design 스타일
- 이벤트 핸들러 정의
### 5. utils.py - 유틸리티
```python
class FileUtils:
"""파일 관련 유틸리티"""
class AnalysisResultSaver:
"""분석 결과 저장"""
class DateTimeUtils:
"""날짜/시간 유틸리티"""
```
**주요 기능:**
- 파일 조작
- 결과 저장
- 텍스트 처리
- 검증 함수
## API 참조
### PDFProcessor
#### `validate_pdf_file(file_path: str) -> bool`
PDF 파일의 유효성을 검사합니다.
**매개변수:**
- `file_path`: PDF 파일 경로
**반환값:**
- `bool`: 유효한 PDF인지 여부
#### `get_pdf_info(file_path: str) -> dict`
PDF 파일의 메타데이터를 조회합니다.
**반환값:**
```python
{
'page_count': int,
'metadata': dict,
'file_size': int,
'filename': str
}
```
### GeminiAnalyzer
#### `analyze_image_from_base64(base64_data: str, prompt: str) -> str`
Base64 이미지를 분석합니다.
**매개변수:**
- `base64_data`: Base64로 인코딩된 이미지
- `prompt`: 분석 요청 텍스트
**반환값:**
- `str`: 분석 결과 텍스트
### AnalysisResultSaver
#### `save_analysis_results(pdf_filename, analysis_results, pdf_info, analysis_settings) -> str`
분석 결과를 텍스트 파일로 저장합니다.
**반환값:**
- `str`: 저장된 파일 경로
## 확장 가이드
### 새로운 분석 모드 추가
1. **UI 업데이트**
```python
# ui_components.py에서 새 라디오 버튼 추가
ft.Radio(value="new_mode", label="새로운 모드")
```
2. **분석 로직 추가**
```python
# main.py의 run_analysis()에서 프롬프트 설정
elif self.analysis_mode.value == "new_mode":
prompt = "새로운 분석 모드의 프롬프트"
```
### 새로운 파일 형식 지원
1. **설정 업데이트**
```python
# config.py
ALLOWED_EXTENSIONS = ["pdf", "docx"] # 새 형식 추가
```
2. **처리기 확장**
```python
# 새로운 처리 클래스 구현
class DOCXProcessor:
def validate_docx_file(self, file_path: str) -> bool:
# DOCX 검증 로직
pass
```
### 새로운 AI 모델 지원
1. **설정 추가**
```python
# config.py
ALTERNATIVE_MODEL = "claude-3-5-sonnet"
```
2. **분석기 확장**
```python
class ClaudeAnalyzer:
def analyze_image(self, image_data: str) -> str:
# Claude API 연동 로직
pass
```
### UI 컴포넌트 확장
1. **새 컴포넌트 추가**
```python
# ui_components.py
@staticmethod
def create_advanced_settings_section() -> ft.Container:
"""고급 설정 섹션"""
return ft.Container(...)
```
2. **메인 UI에 통합**
```python
# main.py의 build_ui()에서 새 컴포넌트 추가
```
## 기여하기
### 기여 프로세스
1. **이슈 생성**
- 새 기능이나 버그 리포트
- 명확한 설명과 예시 제공
2. **브랜치 생성**
```bash
git checkout -b feature/new-feature
git checkout -b bugfix/fix-issue
```
3. **개발 및 테스트**
```bash
# 개발 후 테스트 실행
python test_project.py
black .
flake8 .
```
4. **커밋 및 푸시**
```bash
git add .
git commit -m "feat: add new feature"
git push origin feature/new-feature
```
5. **Pull Request 생성**
- 명확한 제목과 설명
- 변경사항 설명
- 테스트 결과 포함
### 커밋 메시지 규칙
- `feat:` 새로운 기능
- `fix:` 버그 수정
- `docs:` 문서 업데이트
- `style:` 코드 스타일 변경
- `refactor:` 코드 리팩토링
- `test:` 테스트 추가/수정
- `chore:` 기타 작업
### 코드 리뷰 체크리스트
- [ ] 코드 스타일 준수 (Black, Flake8)
- [ ] 테스트 통과
- [ ] 문서화 완료
- [ ] 타입 힌트 추가
- [ ] 에러 처리 적절
- [ ] 성능 고려
- [ ] 보안 검토
## 디버깅
### 로깅 설정
```python
import logging
# 개발 시 상세 로깅
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
```
### 일반적인 디버깅 시나리오
1. **API 연결 문제**
```python
# gemini_analyzer.py에서 연결 테스트
if not analyzer.validate_api_connection():
logger.error("API 연결 실패")
```
2. **파일 처리 오류**
```python
# pdf_processor.py에서 상세 오류 정보
try:
doc = fitz.open(file_path)
except Exception as e:
logger.error(f"PDF 열기 실패: {e}")
```
3. **UI 업데이트 문제**
```python
# main.py에서 스레드 안전 업데이트
def safe_ui_update():
def update():
# UI 업데이트 코드
self.page.update()
self.page.run_thread(update)
```
## 성능 최적화
### 메모리 관리
1. **대용량 PDF 처리**
```python
# 페이지별 순차 처리
for page_num in range(total_pages):
# 메모리 해제
del previous_image
gc.collect()
```
2. **이미지 크기 최적화**
```python
# 적절한 줌 레벨 선택
zoom = min(2.0, target_width / pdf_width)
```
### API 호출 최적화
1. **요청 배치 처리**
```python
# 여러 페이지를 하나의 요청으로 처리
combined_prompt = f"다음 {len(images)}개 이미지를 분석..."
```
2. **캐싱 구현**
```python
# 분석 결과 캐시
@lru_cache(maxsize=100)
def cached_analysis(image_hash: str, prompt: str) -> str:
return analyzer.analyze_image(image_data, prompt)
```
---
더 자세한 정보나 질문이 있으시면 GitHub Issues에서 문의해 주세요.

222
docs/user_guide.md Normal file
View File

@@ -0,0 +1,222 @@
# 사용자 가이드
PDF 도면 분석기의 상세한 사용법을 안내합니다.
## 목차
1. [설치 후 첫 실행](#설치-후-첫-실행)
2. [기본 사용법](#기본-사용법)
3. [고급 기능](#고급-기능)
4. [문제 해결](#문제-해결)
5. [팁과 요령](#팁과-요령)
## 설치 후 첫 실행
### 1. API 키 설정 확인
애플리케이션을 처음 실행하기 전에 Gemini API 키가 올바르게 설정되었는지 확인하세요.
```bash
# .env 파일 확인
GEMINI_API_KEY=your_actual_api_key_here
```
### 2. 테스트 실행
설치가 올바르게 되었는지 확인:
```bash
python test_project.py
```
### 3. 애플리케이션 실행
```bash
python main.py
```
## 기본 사용법
### 1. PDF 파일 업로드
1. **파일 선택**: "PDF 파일 선택" 버튼을 클릭합니다.
2. **파일 확인**: 선택된 파일의 정보(이름, 페이지 수, 크기)를 확인합니다.
3. **유효성 검사**: 시스템이 자동으로 PDF 파일의 유효성을 검사합니다.
**지원되는 파일:**
- ✅ PDF 형식 파일
- ✅ 최대 50MB 크기
- ✅ 모든 페이지 수
**지원되지 않는 파일:**
- ❌ 암호로 보호된 PDF
- ❌ 손상된 PDF 파일
- ❌ 이미지 파일 (JPG, PNG 등)
### 2. 분석 설정
#### 페이지 선택
- **첫 번째 페이지**: 첫 페이지만 분석 (빠름, 비용 절약)
- **모든 페이지**: 전체 페이지 분석 (상세함, 시간 소요)
#### 분석 모드
- **기본 분석**: 문서 유형과 기본 정보 분석
- **상세 분석**: 도면, 도표, 텍스트 등 상세 분석
- **사용자 정의**: 원하는 분석 내용을 직접 입력
### 3. 분석 실행
1. **분석 시작**: "분석 시작" 버튼을 클릭합니다.
2. **진행 상황 확인**: 진행률 바와 상태 메시지를 확인합니다.
3. **결과 확인**: 분석 완료 후 결과를 검토합니다.
### 4. 결과 저장
분석 완료 후 두 가지 형식으로 저장할 수 있습니다:
- **텍스트 저장**: 읽기 쉬운 텍스트 형식
- **JSON 저장**: 구조화된 데이터 형식
## 고급 기능
### 사용자 정의 분석
분석 모드에서 "사용자 정의"를 선택하면 원하는 분석 내용을 직접 지정할 수 있습니다.
**예시 프롬프트:**
```
이 도면에서 다음 정보를 추출해주세요:
1. 도면 제목과 도면 번호
2. 주요 치수 정보
3. 사용된 재료 정보
4. 특별한 주의사항
```
### 대용량 PDF 처리
큰 PDF 파일을 처리할 때 팁:
1. **첫 페이지만 분석**: 전체 분석 전에 테스트
2. **인터넷 연결 확인**: 안정적인 연결 필요
3. **충분한 시간 확보**: 페이지당 1-2분 소요
### 배치 처리
여러 PDF를 순차적으로 처리하는 방법:
1. 첫 번째 PDF 분석 완료
2. 결과 저장
3. 다음 PDF 업로드
4. 반복
## 문제 해결
### 일반적인 오류들
#### 1. API 키 오류
```
오류: Gemini API 키가 설정되지 않았습니다.
```
**해결책:**
- `.env` 파일의 `GEMINI_API_KEY` 확인
- API 키가 올바른지 Google AI Studio에서 확인
#### 2. PDF 파일 오류
```
오류: 유효하지 않은 PDF 파일입니다.
```
**해결책:**
- 다른 PDF 뷰어에서 파일 열어보기
- 파일 손상 여부 확인
- 파일 크기 제한 확인 (50MB 이하)
#### 3. 네트워크 오류
```
오류: 분석 중 오류가 발생했습니다.
```
**해결책:**
- 인터넷 연결 상태 확인
- 방화벽 설정 확인
- 잠시 후 다시 시도
#### 4. 메모리 부족
```
오류: 메모리가 부족합니다.
```
**해결책:**
- 다른 프로그램 종료
- 첫 번째 페이지만 분석
- 시스템 재시작
### 로그 확인
문제 발생 시 콘솔 출력을 확인하세요:
```bash
python main.py > app.log 2>&1
```
## 팁과 요령
### 1. 효율적인 분석
**빠른 분석을 위해:**
- 첫 번째 페이지만 선택
- 기본 분석 모드 사용
- 작은 크기의 PDF 사용
**정확한 분석을 위해:**
- 모든 페이지 선택
- 상세 분석 모드 사용
- 구체적인 사용자 정의 프롬프트 작성
### 2. 프롬프트 작성 요령
**좋은 프롬프트 예시:**
```
이 건축 도면을 분석하여 다음을 알려주세요:
- 건물 유형과 규모
- 주요 치수 (길이, 폭, 높이)
- 방의 개수와 용도
- 특별한 설계 요소
```
**피해야 할 프롬프트:**
```
분석해줘 (너무 일반적)
모든 것을 알려줘 (너무 광범위)
```
### 3. 결과 활용
**텍스트 결과**:
- 보고서 작성에 적합
- 직접 복사/붙여넣기 가능
**JSON 결과**:
- 다른 시스템과 연동
- 추가 데이터 처리 가능
### 4. 성능 최적화
**시스템 성능 향상:**
- 충분한 RAM 확보 (8GB 이상 권장)
- SSD 사용 시 더 빠른 처리
- 안정적인 인터넷 연결
**비용 최적화:**
- 필요한 페이지만 분석
- 기본 분석 모드 우선 사용
- 중복 분석 방지
## 자주 묻는 질문 (FAQ)
### Q: 분석 시간이 얼마나 걸리나요?
A: 페이지당 1-2분 정도 소요됩니다. 네트워크 상태와 이미지 복잡도에 따라 달라집니다.
### Q: 어떤 종류의 도면을 분석할 수 있나요?
A: 건축 도면, 기계 도면, 전기 회로도, 지도, 차트 등 모든 종류의 이미지가 포함된 PDF를 분석할 수 있습니다.
### Q: 분석 결과의 정확도는 어느 정도인가요?
A: Google Gemini AI의 최신 기술을 사용하여 높은 정확도를 제공하지만, 복잡한 도면이나 불분명한 이미지의 경우 제한이 있을 수 있습니다.
### Q: 개인정보나 민감한 문서도 안전한가요?
A: 업로드된 파일은 로컬에서만 처리되며, Google API로는 이미지 데이터만 전송됩니다. 원본 파일은 로컬에 보관됩니다.
### Q: 오프라인에서도 사용할 수 있나요?
A: 아니요. Gemini API 호출을 위해 인터넷 연결이 필요합니다.
---
추가 질문이나 문제가 있으시면 GitHub Issues에서 문의해 주세요.

871
dxf_processor.py Normal file
View File

@@ -0,0 +1,871 @@
# -*- coding: utf-8 -*-
"""
향상된 DXF 파일 처리 모듈
ezdxf 라이브러리를 사용하여 DXF 파일에서 도곽 정보, 텍스트 엔티티 및 모든 Block Reference/Attribute Reference를 추출
"""
import os
import json
import logging
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, asdict, field
try:
import ezdxf
from ezdxf.document import Drawing
from ezdxf.entities import Insert, Attrib, AttDef, Text, MText
from ezdxf.layouts import BlockLayout, Modelspace
from ezdxf import bbox, disassemble
EZDXF_AVAILABLE = True
except ImportError:
EZDXF_AVAILABLE = False
logging.warning("ezdxf 라이브러리가 설치되지 않았습니다. DXF 기능이 비활성화됩니다.")
from config import Config
@dataclass
class BoundingBox:
"""바운딩 박스 정보를 담는 데이터 클래스"""
min_x: float
min_y: float
max_x: float
max_y: float
@property
def width(self) -> float:
return self.max_x - self.min_x
@property
def height(self) -> float:
return self.max_y - self.min_y
@property
def center(self) -> Tuple[float, float]:
return ((self.min_x + self.max_x) / 2, (self.min_y + self.max_y) / 2)
def merge(self, other: 'BoundingBox') -> 'BoundingBox':
"""다른 바운딩 박스와 병합하여 가장 큰 외곽 박스 반환"""
return BoundingBox(
min_x=min(self.min_x, other.min_x),
min_y=min(self.min_y, other.min_y),
max_x=max(self.max_x, other.max_x),
max_y=max(self.max_y, other.max_y)
)
@dataclass
class TextInfo:
"""텍스트 엔티티 정보를 담는 데이터 클래스"""
entity_type: str # TEXT, MTEXT, ATTRIB
text: str
position: Tuple[float, float, float]
height: float
rotation: float
layer: str
bounding_box: Optional[BoundingBox] = None
entity_handle: Optional[str] = None
style: Optional[str] = None
color: Optional[int] = None
@dataclass
class AttributeInfo:
"""속성 정보를 담는 데이터 클래스 - 모든 DXF 속성 포함"""
tag: str
text: str
position: Tuple[float, float, float] # insert point (x, y, z)
height: float
width: float
rotation: float
layer: str
bounding_box: Optional[BoundingBox] = None
# 추가 DXF 속성들
prompt: Optional[str] = None
style: Optional[str] = None
invisible: bool = False
const: bool = False
verify: bool = False
preset: bool = False
align_point: Optional[Tuple[float, float, float]] = None
halign: int = 0
valign: int = 0
text_generation_flag: int = 0
oblique_angle: float = 0.0
width_factor: float = 1.0
color: Optional[int] = None
linetype: Optional[str] = None
lineweight: Optional[int] = None
# 좌표 정보
insert_x: float = 0.0
insert_y: float = 0.0
insert_z: float = 0.0
# 계산된 정보
estimated_width: float = 0.0
entity_handle: Optional[str] = None
@dataclass
class BlockInfo:
"""블록 정보를 담는 데이터 클래스"""
name: str
position: Tuple[float, float, float]
scale: Tuple[float, float, float]
rotation: float
layer: str
attributes: List[AttributeInfo]
bounding_box: Optional[BoundingBox] = None
@dataclass
class TitleBlockInfo:
"""도곽 정보를 담는 데이터 클래스"""
drawing_name: Optional[str] = None
drawing_number: Optional[str] = None
construction_field: Optional[str] = None
construction_stage: Optional[str] = None
scale: Optional[str] = None
project_name: Optional[str] = None
designer: Optional[str] = None
date: Optional[str] = None
revision: Optional[str] = None
location: Optional[str] = None
bounding_box: Optional[BoundingBox] = None
block_name: Optional[str] = None
# 모든 attributes 정보 저장
all_attributes: List[AttributeInfo] = field(default_factory=list)
attributes_count: int = 0
# 추가 메타데이터
block_position: Optional[Tuple[float, float, float]] = None
block_scale: Optional[Tuple[float, float, float]] = None
block_rotation: float = 0.0
block_layer: Optional[str] = None
def __post_init__(self):
"""초기화 후 처리"""
self.attributes_count = len(self.all_attributes)
@dataclass
class ComprehensiveExtractionResult:
"""종합적인 추출 결과를 담는 데이터 클래스"""
text_entities: List[TextInfo] = field(default_factory=list)
all_block_references: List[BlockInfo] = field(default_factory=list)
title_block: Optional[TitleBlockInfo] = None
overall_bounding_box: Optional[BoundingBox] = None
summary: Dict[str, Any] = field(default_factory=dict)
class EnhancedDXFProcessor:
"""향상된 DXF 파일 처리 클래스"""
# 도곽 식별을 위한 키워드 정의
TITLE_BLOCK_KEYWORDS = {
'건설분야': ['construction_field', 'field', '분야', '공사', 'category'],
'건설단계': ['construction_stage', 'stage', '단계', 'phase'],
'도면명': ['drawing_name', 'title', '제목', 'name', ''],
'축척': ['scale', '축척', 'ratio', '비율'],
'도면번호': ['drawing_number', 'number', '번호', 'no', 'dwg'],
'설계자': ['designer', '설계', 'design', 'drawn'],
'프로젝트': ['project', '사업', '공사명'],
'날짜': ['date', '일자', '작성일'],
'리비전': ['revision', 'rev', '개정'],
'위치': ['location', '위치', '지역']
}
def __init__(self):
"""DXF 처리기 초기화"""
self.logger = logging.getLogger(__name__)
if not EZDXF_AVAILABLE:
raise ImportError("ezdxf 라이브러리가 필요합니다. 'pip install ezdxf'로 설치하세요.")
def validate_dxf_file(self, file_path: str) -> bool:
"""DXF 파일 유효성 검사"""
try:
if not os.path.exists(file_path):
self.logger.error(f"파일이 존재하지 않습니다: {file_path}")
return False
if not file_path.lower().endswith('.dxf'):
self.logger.error(f"DXF 파일이 아닙니다: {file_path}")
return False
# ezdxf로 파일 읽기 시도
doc = ezdxf.readfile(file_path)
if doc is None:
return False
self.logger.info(f"DXF 파일 유효성 검사 성공: {file_path}")
return True
except ezdxf.DXFStructureError as e:
self.logger.error(f"DXF 구조 오류: {e}")
return False
except Exception as e:
self.logger.error(f"DXF 파일 검증 중 오류: {e}")
return False
def load_dxf_document(self, file_path: str) -> Optional[Drawing]:
"""DXF 문서 로드"""
try:
doc = ezdxf.readfile(file_path)
self.logger.info(f"DXF 문서 로드 성공: {file_path}")
return doc
except Exception as e:
self.logger.error(f"DXF 문서 로드 실패: {e}")
return None
def _is_empty_text(self, text: str) -> bool:
"""텍스트가 비어있는지 확인 (공백 문자만 있거나 완전히 비어있는 경우)"""
return not text or text.strip() == ""
def calculate_comprehensive_bounding_box(self, doc: Drawing) -> Optional[BoundingBox]:
"""전체 문서의 종합적인 바운딩 박스 계산 (ezdxf.bbox 사용)"""
try:
msp = doc.modelspace()
# ezdxf의 bbox 모듈을 사용하여 전체 바운딩 박스 계산
cache = bbox.Cache()
overall_bbox = bbox.extents(msp, cache=cache)
if overall_bbox:
self.logger.info(f"전체 바운딩 박스: {overall_bbox}")
return BoundingBox(
min_x=overall_bbox.extmin.x,
min_y=overall_bbox.extmin.y,
max_x=overall_bbox.extmax.x,
max_y=overall_bbox.extmax.y
)
else:
self.logger.warning("바운딩 박스 계산 실패")
return None
except Exception as e:
self.logger.warning(f"바운딩 박스 계산 중 오류: {e}")
return None
def extract_all_text_entities(self, doc: Drawing) -> List[TextInfo]:
"""모든 텍스트 엔티티 추출 (TEXT, MTEXT, DBTEXT)"""
text_entities = []
try:
msp = doc.modelspace()
# TEXT 엔티티 추출
for text_entity in msp.query('TEXT'):
text_content = getattr(text_entity.dxf, 'text', '')
if not self._is_empty_text(text_content):
text_info = self._extract_text_info(text_entity, 'TEXT')
if text_info:
text_entities.append(text_info)
# MTEXT 엔티티 추출
for mtext_entity in msp.query('MTEXT'):
# MTEXT는 .text 속성 사용
text_content = getattr(mtext_entity, 'text', '') or getattr(mtext_entity.dxf, 'text', '')
if not self._is_empty_text(text_content):
text_info = self._extract_text_info(mtext_entity, 'MTEXT')
if text_info:
text_entities.append(text_info)
# ATTRIB 엔티티 추출 (블록 외부의 독립적인 속성)
for attrib_entity in msp.query('ATTRIB'):
text_content = getattr(attrib_entity.dxf, 'text', '')
if not self._is_empty_text(text_content):
text_info = self._extract_text_info(attrib_entity, 'ATTRIB')
if text_info:
text_entities.append(text_info)
# 페이퍼스페이스도 확인
for layout_name in doc.layout_names_in_taborder():
if layout_name.startswith('*'): # 모델스페이스 제외
continue
try:
layout = doc.paperspace(layout_name)
# TEXT, MTEXT, ATTRIB 추출
for entity_type in ['TEXT', 'MTEXT', 'ATTRIB']:
for entity in layout.query(entity_type):
if entity_type == 'MTEXT':
text_content = getattr(entity, 'text', '') or getattr(entity.dxf, 'text', '')
else:
text_content = getattr(entity.dxf, 'text', '')
if not self._is_empty_text(text_content):
text_info = self._extract_text_info(entity, entity_type)
if text_info:
text_entities.append(text_info)
except Exception as e:
self.logger.warning(f"레이아웃 {layout_name} 처리 중 오류: {e}")
self.logger.info(f"{len(text_entities)}개의 텍스트 엔티티를 찾았습니다.")
return text_entities
except Exception as e:
self.logger.error(f"텍스트 엔티티 추출 중 오류: {e}")
return []
def _extract_text_info(self, entity, entity_type: str) -> Optional[TextInfo]:
"""텍스트 엔티티에서 정보 추출"""
try:
# 텍스트 내용 추출
if entity_type == 'MTEXT':
text_content = getattr(entity, 'text', '') or getattr(entity.dxf, 'text', '')
else:
text_content = getattr(entity.dxf, 'text', '')
# 위치 정보
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
position = (
insert_point.x if hasattr(insert_point, 'x') else insert_point[0],
insert_point.y if hasattr(insert_point, 'y') else insert_point[1],
insert_point.z if hasattr(insert_point, 'z') else insert_point[2]
)
# 기본 속성
height = getattr(entity.dxf, 'height', 1.0)
rotation = getattr(entity.dxf, 'rotation', 0.0)
layer = getattr(entity.dxf, 'layer', '0')
entity_handle = getattr(entity.dxf, 'handle', None)
style = getattr(entity.dxf, 'style', None)
color = getattr(entity.dxf, 'color', None)
# 바운딩 박스 계산
bounding_box = self._calculate_text_bounding_box(entity)
return TextInfo(
entity_type=entity_type,
text=text_content,
position=position,
height=height,
rotation=rotation,
layer=layer,
bounding_box=bounding_box,
entity_handle=entity_handle,
style=style,
color=color
)
except Exception as e:
self.logger.warning(f"텍스트 정보 추출 중 오류: {e}")
return None
def _calculate_text_bounding_box(self, entity) -> Optional[BoundingBox]:
"""텍스트 엔티티의 바운딩 박스 계산"""
try:
# ezdxf bbox 모듈 사용
entity_bbox = bbox.extents([entity])
if entity_bbox:
return BoundingBox(
min_x=entity_bbox.extmin.x,
min_y=entity_bbox.extmin.y,
max_x=entity_bbox.extmax.x,
max_y=entity_bbox.extmax.y
)
except Exception as e:
self.logger.debug(f"바운딩 박스 계산 실패, 추정값 사용: {e}")
# 대안: 추정 계산
try:
if hasattr(entity, 'dxf'):
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
height = getattr(entity.dxf, 'height', 1.0)
# 텍스트 내용 길이 추정
if hasattr(entity, 'text'):
text_content = entity.text
elif hasattr(entity.dxf, 'text'):
text_content = entity.dxf.text
else:
text_content = ""
# 텍스트 너비 추정 (높이의 0.6배 * 글자 수)
estimated_width = len(text_content) * height * 0.6
x, y = insert_point[0], insert_point[1]
return BoundingBox(
min_x=x,
min_y=y,
max_x=x + estimated_width,
max_y=y + height
)
except Exception as e:
self.logger.warning(f"텍스트 바운딩 박스 계산 실패: {e}")
return None
def extract_all_block_references(self, doc: Drawing) -> List[BlockInfo]:
"""모든 Block Reference 추출 (재귀적으로 중첩된 블록도 포함)"""
block_refs = []
try:
# 모델스페이스에서 INSERT 엔티티 찾기
msp = doc.modelspace()
for insert in msp.query('INSERT'):
block_info = self._process_block_reference(doc, insert)
if block_info:
block_refs.append(block_info)
# 페이퍼스페이스도 확인
for layout_name in doc.layout_names_in_taborder():
if layout_name.startswith('*'): # 모델스페이스 제외
continue
try:
layout = doc.paperspace(layout_name)
for insert in layout.query('INSERT'):
block_info = self._process_block_reference(doc, insert)
if block_info:
block_refs.append(block_info)
except Exception as e:
self.logger.warning(f"레이아웃 {layout_name} 처리 중 오류: {e}")
# 블록 정의 내부도 재귀적으로 검사
for block_layout in doc.blocks:
if not block_layout.name.startswith('*'): # 시스템 블록 제외
for insert in block_layout.query('INSERT'):
block_info = self._process_block_reference(doc, insert)
if block_info:
block_refs.append(block_info)
self.logger.info(f"{len(block_refs)}개의 블록 참조를 찾았습니다.")
return block_refs
except Exception as e:
self.logger.error(f"블록 참조 추출 중 오류: {e}")
return []
def _process_block_reference(self, doc: Drawing, insert: Insert) -> Optional[BlockInfo]:
"""개별 Block Reference 처리 - ATTDEF 정보도 함께 수집"""
try:
# 블록 정보 추출
block_name = insert.dxf.name
position = (insert.dxf.insert.x, insert.dxf.insert.y, insert.dxf.insert.z)
scale = (
getattr(insert.dxf, 'xscale', 1.0),
getattr(insert.dxf, 'yscale', 1.0),
getattr(insert.dxf, 'zscale', 1.0)
)
rotation = getattr(insert.dxf, 'rotation', 0.0)
layer = getattr(insert.dxf, 'layer', '0')
# ATTDEF 정보 수집 (프롬프트 정보 포함)
attdef_info = {}
try:
block_layout = doc.blocks.get(block_name)
if block_layout:
for attdef in block_layout.query('ATTDEF'):
tag = getattr(attdef.dxf, 'tag', '')
prompt = getattr(attdef.dxf, 'prompt', '')
if tag:
attdef_info[tag] = {
'prompt': prompt,
'default_text': getattr(attdef.dxf, 'text', ''),
'position': (attdef.dxf.insert.x, attdef.dxf.insert.y, attdef.dxf.insert.z),
'height': getattr(attdef.dxf, 'height', 1.0),
'style': getattr(attdef.dxf, 'style', 'Standard'),
'invisible': getattr(attdef.dxf, 'invisible', False),
'const': getattr(attdef.dxf, 'const', False),
'verify': getattr(attdef.dxf, 'verify', False),
'preset': getattr(attdef.dxf, 'preset', False)
}
except Exception as e:
self.logger.debug(f"ATTDEF 정보 수집 실패: {e}")
# ATTRIB 속성 추출 및 ATTDEF 정보와 결합 (빈 텍스트 제외)
attributes = []
for attrib in insert.attribs:
attr_info = self._extract_attribute_info(attrib)
if attr_info and not self._is_empty_text(attr_info.text):
# ATTDEF에서 프롬프트 정보 추가
if attr_info.tag in attdef_info:
attr_info.prompt = attdef_info[attr_info.tag]['prompt']
attributes.append(attr_info)
# 블록 바운딩 박스 계산
block_bbox = self._calculate_block_bounding_box(insert)
return BlockInfo(
name=block_name,
position=position,
scale=scale,
rotation=rotation,
layer=layer,
attributes=attributes,
bounding_box=block_bbox
)
except Exception as e:
self.logger.warning(f"블록 참조 처리 중 오류: {e}")
return None
def _calculate_block_bounding_box(self, insert: Insert) -> Optional[BoundingBox]:
"""블록의 바운딩 박스 계산"""
try:
# ezdxf bbox 모듈 사용
block_bbox = bbox.extents([insert])
if block_bbox:
return BoundingBox(
min_x=block_bbox.extmin.x,
min_y=block_bbox.extmin.y,
max_x=block_bbox.extmax.x,
max_y=block_bbox.extmax.y
)
except Exception as e:
self.logger.debug(f"블록 바운딩 박스 계산 실패: {e}")
return None
def _extract_attribute_info(self, attrib: Attrib) -> Optional[AttributeInfo]:
"""Attribute Reference에서 모든 정보 추출 (빈 텍스트 포함)"""
try:
# 기본 속성
tag = getattr(attrib.dxf, 'tag', '')
text = getattr(attrib.dxf, 'text', '')
# 위치 정보
insert_point = getattr(attrib.dxf, 'insert', (0, 0, 0))
position = (insert_point.x if hasattr(insert_point, 'x') else insert_point[0],
insert_point.y if hasattr(insert_point, 'y') else insert_point[1],
insert_point.z if hasattr(insert_point, 'z') else insert_point[2])
# 텍스트 속성
height = getattr(attrib.dxf, 'height', 1.0)
width = getattr(attrib.dxf, 'width', 1.0)
rotation = getattr(attrib.dxf, 'rotation', 0.0)
# 레이어 및 스타일
layer = getattr(attrib.dxf, 'layer', '0')
style = getattr(attrib.dxf, 'style', 'Standard')
# 속성 플래그
invisible = getattr(attrib.dxf, 'invisible', False)
const = getattr(attrib.dxf, 'const', False)
verify = getattr(attrib.dxf, 'verify', False)
preset = getattr(attrib.dxf, 'preset', False)
# 정렬 정보
align_point_data = getattr(attrib.dxf, 'align_point', None)
align_point = None
if align_point_data:
align_point = (align_point_data.x if hasattr(align_point_data, 'x') else align_point_data[0],
align_point_data.y if hasattr(align_point_data, 'y') else align_point_data[1],
align_point_data.z if hasattr(align_point_data, 'z') else align_point_data[2])
halign = getattr(attrib.dxf, 'halign', 0)
valign = getattr(attrib.dxf, 'valign', 0)
# 텍스트 형식
text_generation_flag = getattr(attrib.dxf, 'text_generation_flag', 0)
oblique_angle = getattr(attrib.dxf, 'oblique_angle', 0.0)
width_factor = getattr(attrib.dxf, 'width_factor', 1.0)
# 시각적 속성
color = getattr(attrib.dxf, 'color', None)
linetype = getattr(attrib.dxf, 'linetype', None)
lineweight = getattr(attrib.dxf, 'lineweight', None)
# 엔티티 핸들
entity_handle = getattr(attrib.dxf, 'handle', None)
# 텍스트 폭 추정
estimated_width = len(text) * height * 0.6 * width_factor
# 바운딩 박스 계산
bounding_box = self._calculate_text_bounding_box(attrib)
return AttributeInfo(
tag=tag,
text=text,
position=position,
height=height,
width=width,
rotation=rotation,
layer=layer,
bounding_box=bounding_box,
prompt=None, # 나중에 ATTDEF에서 설정
style=style,
invisible=invisible,
const=const,
verify=verify,
preset=preset,
align_point=align_point,
halign=halign,
valign=valign,
text_generation_flag=text_generation_flag,
oblique_angle=oblique_angle,
width_factor=width_factor,
color=color,
linetype=linetype,
lineweight=lineweight,
insert_x=position[0],
insert_y=position[1],
insert_z=position[2],
estimated_width=estimated_width,
entity_handle=entity_handle
)
except Exception as e:
self.logger.warning(f"속성 정보 추출 중 오류: {e}")
return None
def identify_title_block(self, block_refs: List[BlockInfo]) -> Optional[TitleBlockInfo]:
"""블록 참조들 중에서 도곽을 식별하고 정보 추출"""
title_block_candidates = []
for block_ref in block_refs:
# 도곽 키워드를 포함한 속성이 있는지 확인
keyword_matches = 0
for attr in block_ref.attributes:
for keyword_group in self.TITLE_BLOCK_KEYWORDS.keys():
if self._contains_keyword(attr.tag, keyword_group) or \
self._contains_keyword(attr.text, keyword_group):
keyword_matches += 1
break
# 충분한 키워드가 매칭되면 도곽 후보로 추가
if keyword_matches >= 2: # 최소 2개 이상의 키워드 매칭
title_block_candidates.append((block_ref, keyword_matches))
if not title_block_candidates:
self.logger.warning("도곽 블록을 찾을 수 없습니다.")
return None
# 가장 많은 키워드를 포함한 블록을 도곽으로 선택
title_block_candidates.sort(key=lambda x: x[1], reverse=True)
best_candidate = title_block_candidates[0][0]
self.logger.info(f"도곽 블록 발견: {best_candidate.name} (키워드 매칭: {title_block_candidates[0][1]})")
return self._extract_title_block_info(best_candidate)
def _contains_keyword(self, text: str, keyword_group: str) -> bool:
"""텍스트에 특정 키워드 그룹의 단어가 포함되어 있는지 확인"""
if not text:
return False
text_lower = text.lower()
keywords = self.TITLE_BLOCK_KEYWORDS.get(keyword_group, [])
return any(keyword.lower() in text_lower for keyword in keywords)
def _extract_title_block_info(self, block_ref: BlockInfo) -> TitleBlockInfo:
"""도곽 블록에서 상세 정보 추출"""
# TitleBlockInfo 객체 생성
title_block = TitleBlockInfo(
block_name=block_ref.name,
all_attributes=block_ref.attributes.copy(),
block_position=block_ref.position,
block_scale=block_ref.scale,
block_rotation=block_ref.rotation,
block_layer=block_ref.layer
)
# 속성들을 분석하여 도곽 정보 매핑
for attr in block_ref.attributes:
text_value = attr.text.strip()
if not text_value:
continue
# 각 키워드 그룹별로 매칭 시도
if self._contains_keyword(attr.tag, '도면명') or self._contains_keyword(attr.text, '도면명'):
title_block.drawing_name = text_value
elif self._contains_keyword(attr.tag, '도면번호') or self._contains_keyword(attr.text, '도면번호'):
title_block.drawing_number = text_value
elif self._contains_keyword(attr.tag, '건설분야') or self._contains_keyword(attr.text, '건설분야'):
title_block.construction_field = text_value
elif self._contains_keyword(attr.tag, '건설단계') or self._contains_keyword(attr.text, '건설단계'):
title_block.construction_stage = text_value
elif self._contains_keyword(attr.tag, '축척') or self._contains_keyword(attr.text, '축척'):
title_block.scale = text_value
elif self._contains_keyword(attr.tag, '설계자') or self._contains_keyword(attr.text, '설계자'):
title_block.designer = text_value
elif self._contains_keyword(attr.tag, '프로젝트') or self._contains_keyword(attr.text, '프로젝트'):
title_block.project_name = text_value
elif self._contains_keyword(attr.tag, '날짜') or self._contains_keyword(attr.text, '날짜'):
title_block.date = text_value
elif self._contains_keyword(attr.tag, '리비전') or self._contains_keyword(attr.text, '리비전'):
title_block.revision = text_value
elif self._contains_keyword(attr.tag, '위치') or self._contains_keyword(attr.text, '위치'):
title_block.location = text_value
# 도곽 바운딩 박스는 블록의 바운딩 박스 사용
title_block.bounding_box = block_ref.bounding_box
# 속성 개수 업데이트
title_block.attributes_count = len(title_block.all_attributes)
self.logger.info(f"도곽 '{block_ref.name}'에서 {title_block.attributes_count}개의 속성 추출 완료")
return title_block
def process_dxf_file_comprehensive(self, file_path: str) -> Dict[str, Any]:
"""DXF 파일 종합적인 처리"""
result = {
'success': False,
'error': None,
'file_path': file_path,
'comprehensive_result': None,
'summary': {}
}
try:
# 파일 유효성 검사
if not self.validate_dxf_file(file_path):
result['error'] = "유효하지 않은 DXF 파일입니다."
return result
# DXF 문서 로드
doc = self.load_dxf_document(file_path)
if not doc:
result['error'] = "DXF 문서를 로드할 수 없습니다."
return result
# 종합적인 추출 시작
comprehensive_result = ComprehensiveExtractionResult()
# 1. 모든 텍스트 엔티티 추출
self.logger.info("텍스트 엔티티 추출 중...")
comprehensive_result.text_entities = self.extract_all_text_entities(doc)
# 2. 모든 블록 참조 추출
self.logger.info("블록 참조 추출 중...")
comprehensive_result.all_block_references = self.extract_all_block_references(doc)
# 3. 도곽 정보 추출
self.logger.info("도곽 정보 추출 중...")
comprehensive_result.title_block = self.identify_title_block(comprehensive_result.all_block_references)
# 4. 전체 바운딩 박스 계산
self.logger.info("전체 바운딩 박스 계산 중...")
comprehensive_result.overall_bounding_box = self.calculate_comprehensive_bounding_box(doc)
# 5. 요약 정보 생성
comprehensive_result.summary = {
'total_text_entities': len(comprehensive_result.text_entities),
'total_block_references': len(comprehensive_result.all_block_references),
'title_block_found': comprehensive_result.title_block is not None,
'title_block_name': comprehensive_result.title_block.block_name if comprehensive_result.title_block else None,
'total_attributes': sum(len(br.attributes) for br in comprehensive_result.all_block_references),
'non_empty_attributes': sum(len([attr for attr in br.attributes if not self._is_empty_text(attr.text)])
for br in comprehensive_result.all_block_references),
'overall_bounding_box': comprehensive_result.overall_bounding_box.__dict__ if comprehensive_result.overall_bounding_box else None
}
# 결과 저장
result['comprehensive_result'] = asdict(comprehensive_result)
result['summary'] = comprehensive_result.summary
result['success'] = True
self.logger.info(f"DXF 파일 종합 처리 완료: {file_path}")
self.logger.info(f"추출 요약: 텍스트 엔티티 {comprehensive_result.summary['total_text_entities']}개, "
f"블록 참조 {comprehensive_result.summary['total_block_references']}개, "
f"비어있지 않은 속성 {comprehensive_result.summary['non_empty_attributes']}")
except Exception as e:
self.logger.error(f"DXF 파일 처리 중 오류: {e}")
result['error'] = str(e)
return result
def save_analysis_result(self, result: Dict[str, Any], output_file: str) -> bool:
"""분석 결과를 JSON 파일로 저장"""
try:
os.makedirs(Config.RESULTS_FOLDER, exist_ok=True)
output_path = os.path.join(Config.RESULTS_FOLDER, output_file)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
self.logger.info(f"분석 결과 저장 완료: {output_path}")
return True
except Exception as e:
self.logger.error(f"분석 결과 저장 실패: {e}")
return False
# 기존 클래스명과의 호환성을 위한 별칭
DXFProcessor = EnhancedDXFProcessor
def main():
"""테스트용 메인 함수"""
logging.basicConfig(level=logging.INFO)
if not EZDXF_AVAILABLE:
print("ezdxf 라이브러리가 설치되지 않았습니다.")
return
processor = EnhancedDXFProcessor()
# 테스트 파일 경로 (실제 파일 경로로 변경 필요)
test_file = "test_drawing.dxf"
if os.path.exists(test_file):
result = processor.process_dxf_file_comprehensive(test_file)
if result['success']:
print("DXF 파일 종합 처리 성공!")
summary = result['summary']
print(f"텍스트 엔티티: {summary['total_text_entities']}")
print(f"블록 참조: {summary['total_block_references']}")
print(f"도곽 발견: {summary['title_block_found']}")
print(f"비어있지 않은 속성: {summary['non_empty_attributes']}")
if summary['overall_bounding_box']:
bbox_info = summary['overall_bounding_box']
print(f"전체 바운딩 박스: ({bbox_info['min_x']:.2f}, {bbox_info['min_y']:.2f}) ~ "
f"({bbox_info['max_x']:.2f}, {bbox_info['max_y']:.2f})")
else:
print(f"처리 실패: {result['error']}")
else:
print(f"테스트 파일을 찾을 수 없습니다: {test_file}")
def process_dxf_file(self, file_path: str) -> Dict[str, Any]:
"""
기존 코드와의 호환성을 위한 메서드
process_dxf_file_comprehensive를 호출하고 기존 형식으로 변환
"""
try:
# 새로운 종합 처리 메서드 호출
comprehensive_result = self.process_dxf_file_comprehensive(file_path)
if not comprehensive_result['success']:
return comprehensive_result
# 기존 형식으로 변환
comp_data = comprehensive_result['comprehensive_result']
# 기존 형식으로 데이터 재구성
result = {
'success': True,
'error': None,
'file_path': file_path,
'title_block': comp_data.get('title_block'),
'block_references': comp_data.get('all_block_references', []),
'summary': comp_data.get('summary', {})
}
return result
except Exception as e:
self.logger.error(f"DXF 파일 처리 중 오류: {e}")
return {
'success': False,
'error': str(e),
'file_path': file_path,
'title_block': None,
'block_references': [],
'summary': {}
}

637
dxf_processor_fixed.py Normal file
View File

@@ -0,0 +1,637 @@
# -*- coding: utf-8 -*-
"""
수정된 DXF 파일 처리 모듈 - 속성 추출 문제 해결
ezdxf 라이브러리를 사용하여 DXF 파일에서 모든 속성을 정확히 추출
"""
import os
import json
import logging
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, asdict, field
try:
import ezdxf
from ezdxf.document import Drawing
from ezdxf.entities import Insert, Attrib, AttDef, Text, MText
from ezdxf.layouts import BlockLayout, Modelspace
from ezdxf import bbox, disassemble
EZDXF_AVAILABLE = True
except ImportError:
EZDXF_AVAILABLE = False
logging.warning("ezdxf 라이브러리가 설치되지 않았습니다. DXF 기능이 비활성화됩니다.")
from config import Config
@dataclass
class BoundingBox:
"""바운딩 박스 정보를 담는 데이터 클래스"""
min_x: float
min_y: float
max_x: float
max_y: float
@property
def width(self) -> float:
return self.max_x - self.min_x
@property
def height(self) -> float:
return self.max_y - self.min_y
@property
def center(self) -> Tuple[float, float]:
return ((self.min_x + self.max_x) / 2, (self.min_y + self.max_y) / 2)
def merge(self, other: 'BoundingBox') -> 'BoundingBox':
"""다른 바운딩 박스와 병합하여 가장 큰 외곽 박스 반환"""
return BoundingBox(
min_x=min(self.min_x, other.min_x),
min_y=min(self.min_y, other.min_y),
max_x=max(self.max_x, other.max_x),
max_y=max(self.max_y, other.max_y)
)
@dataclass
class AttributeInfo:
"""속성 정보를 담는 데이터 클래스"""
tag: str
text: str
position: Tuple[float, float, float]
height: float
rotation: float
layer: str
prompt: Optional[str] = None
style: Optional[str] = None
invisible: bool = False
const: bool = False
bounding_box: Optional[BoundingBox] = None
entity_handle: Optional[str] = None
# 좌표 정보
insert_x: float = 0.0
insert_y: float = 0.0
insert_z: float = 0.0
@dataclass
class BlockInfo:
"""블록 정보를 담는 데이터 클래스"""
name: str
position: Tuple[float, float, float]
scale: Tuple[float, float, float]
rotation: float
layer: str
attributes: List[AttributeInfo]
bounding_box: Optional[BoundingBox] = None
@dataclass
class TitleBlockInfo:
"""도곽 정보를 담는 데이터 클래스"""
drawing_name: Optional[str] = None
drawing_number: Optional[str] = None
construction_field: Optional[str] = None
construction_stage: Optional[str] = None
scale: Optional[str] = None
project_name: Optional[str] = None
designer: Optional[str] = None
date: Optional[str] = None
revision: Optional[str] = None
location: Optional[str] = None
bounding_box: Optional[BoundingBox] = None
block_name: Optional[str] = None
# 모든 attributes 정보 저장
all_attributes: List[AttributeInfo] = field(default_factory=list)
attributes_count: int = 0
def __post_init__(self):
"""초기화 후 처리"""
self.attributes_count = len(self.all_attributes)
class FixedDXFProcessor:
"""수정된 DXF 파일 처리기 - 속성 추출 문제 해결"""
def __init__(self):
self.logger = logging.getLogger(self.__class__.__name__)
# 도곽 식별을 위한 키워드 (한국어/영어)
self.title_block_keywords = {
'도면명': ['도면명', '도면', 'drawing', 'title', 'name', 'dwg'],
'도면번호': ['도면번호', '번호', 'number', 'no', 'dwg_no'],
'건설분야': ['건설분야', '분야', 'field', 'construction', 'civil'],
'건설단계': ['건설단계', '단계', 'stage', 'phase', 'step'],
'축척': ['축척', 'scale', 'ratio'],
'설계자': ['설계자', '설계', 'designer', 'design', 'engineer'],
'프로젝트': ['프로젝트', '사업', 'project', 'work'],
'날짜': ['날짜', '일자', 'date', 'time'],
'리비전': ['리비전', '개정', 'revision', 'rev'],
'위치': ['위치', '장소', 'location', 'place', 'site']
}
if not EZDXF_AVAILABLE:
self.logger.warning("ezdxf 라이브러리가 설치되지 않았습니다.")
def validate_dxf_file(self, file_path: str) -> bool:
"""DXF 파일 유효성 검사"""
if not EZDXF_AVAILABLE:
self.logger.error("ezdxf 라이브러리가 설치되지 않음")
return False
if not os.path.exists(file_path):
self.logger.error(f"DXF 파일이 존재하지 않음: {file_path}")
return False
if not file_path.lower().endswith('.dxf'):
self.logger.error(f"DXF 파일이 아님: {file_path}")
return False
try:
# 파일 열기 시도
doc = ezdxf.readfile(file_path)
self.logger.info(f"DXF 파일 유효성 검사 통과: {file_path}")
return True
except Exception as e:
self.logger.error(f"DXF 파일 유효성 검사 실패: {e}")
return False
def load_dxf_document(self, file_path: str) -> Optional[Drawing]:
"""DXF 문서 로드"""
try:
doc = ezdxf.readfile(file_path)
self.logger.info(f"DXF 문서 로드 성공: {file_path}")
return doc
except Exception as e:
self.logger.error(f"DXF 문서 로드 실패: {e}")
return None
def extract_all_insert_attributes(self, doc: Drawing) -> List[BlockInfo]:
"""모든 INSERT 엔티티에서 속성 추출 - 수정된 로직"""
block_refs = []
try:
# 모델스페이스에서 INSERT 엔티티 검색
msp = doc.modelspace()
inserts = msp.query('INSERT')
self.logger.info(f"모델스페이스에서 {len(inserts)}개의 INSERT 엔티티 발견")
for insert in inserts:
block_info = self._process_insert_entity(insert, doc)
if block_info:
block_refs.append(block_info)
# 페이퍼스페이스에서도 검색
for layout in doc.layouts:
if layout.is_any_paperspace:
inserts = layout.query('INSERT')
self.logger.info(f"페이퍼스페이스 '{layout.name}'에서 {len(inserts)}개의 INSERT 엔티티 발견")
for insert in inserts:
block_info = self._process_insert_entity(insert, doc)
if block_info:
block_refs.append(block_info)
self.logger.info(f"{len(block_refs)}개의 블록 참조 처리 완료")
return block_refs
except Exception as e:
self.logger.error(f"INSERT 속성 추출 중 오류: {e}")
return []
def _process_insert_entity(self, insert: Insert, doc: Drawing) -> Optional[BlockInfo]:
"""개별 INSERT 엔티티 처리 - 향상된 속성 추출"""
try:
attributes = []
# 방법 1: INSERT에 연결된 ATTRIB 엔티티들 추출
self.logger.debug(f"INSERT '{insert.dxf.name}'의 연결된 속성 추출 중...")
# insert.attribs는 리스트를 반환
insert_attribs = insert.attribs
self.logger.debug(f"INSERT.attribs: {len(insert_attribs)}개 발견")
for attrib in insert_attribs:
attr_info = self._extract_attrib_info(attrib)
if attr_info:
attributes.append(attr_info)
self.logger.debug(f"ATTRIB 추출: tag='{attr_info.tag}', text='{attr_info.text}'")
# 방법 2: 블록 정의에서 ATTDEF 정보 추출 (search_const=True와 유사한 효과)
try:
block_layout = doc.blocks.get(insert.dxf.name)
if block_layout:
# 블록 정의에서 ATTDEF 엔티티 검색
attdefs = block_layout.query('ATTDEF')
self.logger.debug(f"블록 정의에서 {len(attdefs)}개의 ATTDEF 발견")
for attdef in attdefs:
# ATTDEF에서 기본값이나 상수 값 추출
if hasattr(attdef.dxf, 'text') and attdef.dxf.text.strip():
attr_info = self._extract_attdef_info(attdef, insert)
if attr_info:
# 중복 체크 (같은 tag가 이미 있으면 건너뛰기)
existing_tags = [attr.tag for attr in attributes]
if attr_info.tag not in existing_tags:
attributes.append(attr_info)
self.logger.debug(f"ATTDEF 추출: tag='{attr_info.tag}', text='{attr_info.text}'")
# 방법 3: 블록 내부의 TEXT/MTEXT 엔티티도 추출
text_entities = block_layout.query('TEXT MTEXT')
self.logger.debug(f"블록 정의에서 {len(text_entities)}개의 TEXT/MTEXT 발견")
for text_entity in text_entities:
attr_info = self._extract_text_as_attribute(text_entity, insert)
if attr_info:
attributes.append(attr_info)
self.logger.debug(f"TEXT 추출: text='{attr_info.text}'")
except Exception as e:
self.logger.warning(f"블록 정의 처리 중 오류: {e}")
# 방법 4: get_attrib_text 메서드를 사용한 확인
try:
# 일반적인 속성 태그들로 시도
common_tags = ['TITLE', 'NAME', 'NUMBER', 'DATE', 'SCALE', 'DESIGNER',
'PROJECT', 'DRAWING', 'DWG_NO', 'REV', 'REVISION']
for tag in common_tags:
try:
# search_const=True로 ATTDEF도 검색
text_value = insert.get_attrib_text(tag, search_const=True)
if text_value and text_value.strip():
# 이미 있는 태그인지 확인
existing_tags = [attr.tag for attr in attributes]
if tag not in existing_tags:
attr_info = AttributeInfo(
tag=tag,
text=text_value.strip(),
position=insert.dxf.insert,
height=12.0, # 기본값
rotation=insert.dxf.rotation,
layer=insert.dxf.layer,
insert_x=insert.dxf.insert[0],
insert_y=insert.dxf.insert[1],
insert_z=insert.dxf.insert[2] if len(insert.dxf.insert) > 2 else 0.0
)
attributes.append(attr_info)
self.logger.debug(f"get_attrib_text 추출: tag='{tag}', text='{text_value.strip()}'")
except:
continue
except Exception as e:
self.logger.warning(f"get_attrib_text 처리 중 오류: {e}")
# BlockInfo 생성
if attributes or True: # 속성이 없어도 블록 정보는 수집
block_info = BlockInfo(
name=insert.dxf.name,
position=insert.dxf.insert,
scale=(insert.dxf.xscale, insert.dxf.yscale, insert.dxf.zscale),
rotation=insert.dxf.rotation,
layer=insert.dxf.layer,
attributes=attributes
)
self.logger.info(f"INSERT '{insert.dxf.name}' 처리 완료: {len(attributes)}개 속성")
return block_info
return None
except Exception as e:
self.logger.error(f"INSERT 엔티티 처리 중 오류: {e}")
return None
def _extract_attrib_info(self, attrib: Attrib) -> Optional[AttributeInfo]:
"""ATTRIB 엔티티에서 속성 정보 추출"""
try:
# 텍스트가 비어있으면 건너뛰기
text_value = attrib.dxf.text.strip()
if not text_value:
return None
attr_info = AttributeInfo(
tag=attrib.dxf.tag,
text=text_value,
position=attrib.dxf.insert,
height=getattr(attrib.dxf, 'height', 12.0),
rotation=getattr(attrib.dxf, 'rotation', 0.0),
layer=getattr(attrib.dxf, 'layer', '0'),
style=getattr(attrib.dxf, 'style', None),
invisible=getattr(attrib, 'is_invisible', False),
const=getattr(attrib, 'is_const', False),
entity_handle=attrib.dxf.handle,
insert_x=attrib.dxf.insert[0],
insert_y=attrib.dxf.insert[1],
insert_z=attrib.dxf.insert[2] if len(attrib.dxf.insert) > 2 else 0.0
)
return attr_info
except Exception as e:
self.logger.warning(f"ATTRIB 정보 추출 중 오류: {e}")
return None
def _extract_attdef_info(self, attdef: AttDef, insert: Insert) -> Optional[AttributeInfo]:
"""ATTDEF 엔티티에서 속성 정보 추출"""
try:
# 텍스트가 비어있으면 건너뛰기
text_value = attdef.dxf.text.strip()
if not text_value:
return None
# INSERT의 위치를 기준으로 실제 위치 계산
actual_position = (
insert.dxf.insert[0] + attdef.dxf.insert[0] * insert.dxf.xscale,
insert.dxf.insert[1] + attdef.dxf.insert[1] * insert.dxf.yscale,
insert.dxf.insert[2] + (attdef.dxf.insert[2] if len(attdef.dxf.insert) > 2 else 0.0)
)
attr_info = AttributeInfo(
tag=attdef.dxf.tag,
text=text_value,
position=actual_position,
height=getattr(attdef.dxf, 'height', 12.0),
rotation=getattr(attdef.dxf, 'rotation', 0.0) + insert.dxf.rotation,
layer=getattr(attdef.dxf, 'layer', insert.dxf.layer),
prompt=getattr(attdef.dxf, 'prompt', None),
style=getattr(attdef.dxf, 'style', None),
invisible=getattr(attdef, 'is_invisible', False),
const=getattr(attdef, 'is_const', False),
entity_handle=attdef.dxf.handle,
insert_x=actual_position[0],
insert_y=actual_position[1],
insert_z=actual_position[2]
)
return attr_info
except Exception as e:
self.logger.warning(f"ATTDEF 정보 추출 중 오류: {e}")
return None
def _extract_text_as_attribute(self, text_entity, insert: Insert) -> Optional[AttributeInfo]:
"""TEXT/MTEXT 엔티티를 속성으로 추출"""
try:
# 텍스트 내용 추출
if hasattr(text_entity, 'text'):
text_value = text_entity.text.strip()
elif hasattr(text_entity.dxf, 'text'):
text_value = text_entity.dxf.text.strip()
else:
return None
if not text_value:
return None
# INSERT의 위치를 기준으로 실제 위치 계산
text_pos = text_entity.dxf.insert
actual_position = (
insert.dxf.insert[0] + text_pos[0] * insert.dxf.xscale,
insert.dxf.insert[1] + text_pos[1] * insert.dxf.yscale,
insert.dxf.insert[2] + (text_pos[2] if len(text_pos) > 2 else 0.0)
)
# 태그는 텍스트 내용의 첫 단어나 전체 내용으로 설정
tag = text_value.split()[0] if ' ' in text_value else text_value[:20]
attr_info = AttributeInfo(
tag=f"TEXT_{tag}",
text=text_value,
position=actual_position,
height=getattr(text_entity.dxf, 'height', 12.0),
rotation=getattr(text_entity.dxf, 'rotation', 0.0) + insert.dxf.rotation,
layer=getattr(text_entity.dxf, 'layer', insert.dxf.layer),
style=getattr(text_entity.dxf, 'style', None),
entity_handle=text_entity.dxf.handle,
insert_x=actual_position[0],
insert_y=actual_position[1],
insert_z=actual_position[2]
)
return attr_info
except Exception as e:
self.logger.warning(f"TEXT 엔티티 추출 중 오류: {e}")
return None
def identify_title_block(self, block_refs: List[BlockInfo]) -> Optional[TitleBlockInfo]:
"""도곽 블록 식별 및 정보 추출"""
if not block_refs:
return None
title_block_candidates = []
# 각 블록 참조에 대해 도곽 가능성 점수 계산
for block_ref in block_refs:
score = self._calculate_title_block_score(block_ref)
if score > 0:
title_block_candidates.append((block_ref, score))
if not title_block_candidates:
self.logger.warning("도곽 블록을 찾을 수 없습니다.")
return None
# 가장 높은 점수의 블록을 도곽으로 선택
title_block_candidates.sort(key=lambda x: x[1], reverse=True)
best_candidate = title_block_candidates[0][0]
self.logger.info(f"도곽 블록 식별: '{best_candidate.name}' (점수: {title_block_candidates[0][1]})")
# TitleBlockInfo 생성
title_block = TitleBlockInfo(
block_name=best_candidate.name,
all_attributes=best_candidate.attributes
)
# 속성들을 분류하여 해당 필드에 할당
for attr in best_candidate.attributes:
self._assign_attribute_to_field(title_block, attr)
title_block.attributes_count = len(best_candidate.attributes)
return title_block
def _calculate_title_block_score(self, block_ref: BlockInfo) -> int:
"""블록이 도곽일 가능성 점수 계산"""
score = 0
# 블록 이름에 도곽 관련 키워드가 있는지 확인
name_lower = block_ref.name.lower()
title_keywords = ['title', 'titleblock', 'title_block', '도곽', '타이틀', 'border', 'frame']
for keyword in title_keywords:
if keyword in name_lower:
score += 10
break
# 속성 개수 (도곽은 보통 많은 속성을 가짐)
if len(block_ref.attributes) >= 5:
score += 5
elif len(block_ref.attributes) >= 3:
score += 3
elif len(block_ref.attributes) >= 1:
score += 1
# 도곽 관련 속성이 있는지 확인
for attr in block_ref.attributes:
for field_name, keywords in self.title_block_keywords.items():
if self._contains_any_keyword(attr.tag.lower(), keywords) or \
self._contains_any_keyword(attr.text.lower(), keywords):
score += 2
return score
def _contains_any_keyword(self, text: str, keywords: List[str]) -> bool:
"""텍스트에 키워드 중 하나라도 포함되어 있는지 확인"""
text_lower = text.lower()
return any(keyword.lower() in text_lower for keyword in keywords)
def _assign_attribute_to_field(self, title_block: TitleBlockInfo, attr: AttributeInfo):
"""속성을 도곽 정보의 해당 필드에 할당"""
attr_text = attr.text.strip()
if not attr_text:
return
# 태그나 텍스트에서 키워드 매칭
attr_tag_lower = attr.tag.lower()
attr_text_lower = attr_text.lower()
# 각 필드별로 키워드 매칭
for field_name, keywords in self.title_block_keywords.items():
if self._contains_any_keyword(attr_tag_lower, keywords) or \
self._contains_any_keyword(attr_text_lower, keywords):
if field_name == '도면명' and not title_block.drawing_name:
title_block.drawing_name = attr_text
elif field_name == '도면번호' and not title_block.drawing_number:
title_block.drawing_number = attr_text
elif field_name == '건설분야' and not title_block.construction_field:
title_block.construction_field = attr_text
elif field_name == '건설단계' and not title_block.construction_stage:
title_block.construction_stage = attr_text
elif field_name == '축척' and not title_block.scale:
title_block.scale = attr_text
elif field_name == '설계자' and not title_block.designer:
title_block.designer = attr_text
elif field_name == '프로젝트' and not title_block.project_name:
title_block.project_name = attr_text
elif field_name == '날짜' and not title_block.date:
title_block.date = attr_text
elif field_name == '리비전' and not title_block.revision:
title_block.revision = attr_text
elif field_name == '위치' and not title_block.location:
title_block.location = attr_text
break
def process_dxf_file_comprehensive(self, file_path: str) -> Dict[str, Any]:
"""DXF 파일 종합적인 처리 - 수정된 버전"""
result = {
'success': False,
'error': None,
'file_path': file_path,
'title_block': None,
'block_references': [],
'summary': {}
}
try:
# 파일 유효성 검사
if not self.validate_dxf_file(file_path):
result['error'] = "유효하지 않은 DXF 파일입니다."
return result
# DXF 문서 로드
doc = self.load_dxf_document(file_path)
if not doc:
result['error'] = "DXF 문서를 로드할 수 없습니다."
return result
# 모든 INSERT 엔티티에서 속성 추출
self.logger.info("INSERT 엔티티 속성 추출 중...")
block_references = self.extract_all_insert_attributes(doc)
# 도곽 정보 추출
self.logger.info("도곽 정보 추출 중...")
title_block = self.identify_title_block(block_references)
# 요약 정보 생성
total_attributes = sum(len(br.attributes) for br in block_references)
non_empty_attributes = sum(len([attr for attr in br.attributes if attr.text.strip()])
for br in block_references)
summary = {
'total_blocks': len(block_references),
'title_block_found': title_block is not None,
'title_block_name': title_block.block_name if title_block else None,
'attributes_count': title_block.attributes_count if title_block else 0,
'total_attributes': total_attributes,
'non_empty_attributes': non_empty_attributes
}
# 결과 설정
result['title_block'] = asdict(title_block) if title_block else None
result['block_references'] = [asdict(br) for br in block_references]
result['summary'] = summary
result['success'] = True
self.logger.info(f"DXF 파일 처리 완료: {file_path}")
self.logger.info(f"처리 요약: 블록 {len(block_references)}개, 속성 {total_attributes}개 (비어있지 않은 속성: {non_empty_attributes}개)")
except Exception as e:
self.logger.error(f"DXF 파일 처리 중 오류: {e}")
result['error'] = str(e)
return result
# 기존 클래스명과의 호환성을 위한 별칭
DXFProcessor = FixedDXFProcessor
def main():
"""테스트용 메인 함수"""
logging.basicConfig(level=logging.DEBUG)
if not EZDXF_AVAILABLE:
print("ezdxf 라이브러리가 설치되지 않았습니다.")
return
processor = FixedDXFProcessor()
# 업로드 폴더에서 DXF 파일 찾기
upload_dir = "uploads"
if os.path.exists(upload_dir):
for file in os.listdir(upload_dir):
if file.lower().endswith('.dxf'):
test_file = os.path.join(upload_dir, file)
print(f"\n테스트 파일: {test_file}")
result = processor.process_dxf_file_comprehensive(test_file)
if result['success']:
print("✅ DXF 파일 처리 성공!")
summary = result['summary']
print(f" 블록 수: {summary['total_blocks']}")
print(f" 도곽 발견: {summary['title_block_found']}")
print(f" 도곽 속성 수: {summary['attributes_count']}")
print(f" 전체 속성 수: {summary['total_attributes']}")
print(f" 비어있지 않은 속성 수: {summary['non_empty_attributes']}")
if summary['title_block_name']:
print(f" 도곽 블록명: {summary['title_block_name']}")
else:
print(f"❌ 처리 실패: {result['error']}")
break
else:
print("uploads 폴더를 찾을 수 없습니다.")
if __name__ == "__main__":
main()

209
gemini_analyzer.py Normal file
View File

@@ -0,0 +1,209 @@
"""
Gemini API 연동 모듈 (좌표 추출 기능 추가)
Google Gemini API를 사용하여 이미지와 텍스트 좌표를 함께 분석합니다.
"""
import base64
import logging
import json
from google import genai
from google.genai import types
from typing import Optional, Dict, Any, List
from config import Config
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# --- 새로운 스키마 정의 ---
# 좌표를 포함하는 값을 위한 재사용 가능한 스키마
ValueWithCoords = types.Schema(
type=types.Type.OBJECT,
properties={
"value": types.Schema(type=types.Type.STRING, description="추출된 텍스트 값"),
"x": types.Schema(type=types.Type.NUMBER, description="텍스트의 시작 x 좌표"),
"y": types.Schema(type=types.Type.NUMBER, description="텍스트의 시작 y 좌표"),
},
required=["value", "x", "y"]
)
# 모든 필드가 ValueWithCoords를 사용하도록 스키마 업데이트
SCHEMA_EXPRESSWAY = types.Schema(
type=types.Type.OBJECT,
properties={
"사업명": ValueWithCoords,
"시설_공구": ValueWithCoords,
"노선이정": ValueWithCoords,
"설계사": ValueWithCoords,
"시공사": ValueWithCoords,
"건설분야": ValueWithCoords,
"건설단계": ValueWithCoords,
"계정번호": ValueWithCoords,
"계정날짜": ValueWithCoords,
"개정내용": ValueWithCoords,
"작성자": ValueWithCoords,
"검토자": ValueWithCoords,
"확인자": ValueWithCoords,
"설계공구_Station": ValueWithCoords,
"시공공구_Station": ValueWithCoords,
"도면번호": ValueWithCoords,
"도면축척": ValueWithCoords,
"도면명": ValueWithCoords,
"편철번호": ValueWithCoords,
"적용표준버전": ValueWithCoords,
"Note": ValueWithCoords,
"Title": ValueWithCoords,
"기타정보": ValueWithCoords,
},
)
SCHEMA_TRANSPORTATION = types.Schema(
type=types.Type.OBJECT,
properties={
"사업명": ValueWithCoords,
"시설_공구": ValueWithCoords,
"건설분야": ValueWithCoords,
"건설단계": ValueWithCoords,
"계정차수": ValueWithCoords,
"계정일자": ValueWithCoords,
"개정내용": ValueWithCoords,
"과업책임자": ValueWithCoords,
"분야별책임자": ValueWithCoords,
"설계자": ValueWithCoords,
"위치정보": ValueWithCoords,
"축척": ValueWithCoords,
"도면번호": ValueWithCoords,
"도면명": ValueWithCoords,
"편철번호": ValueWithCoords,
"적용표준": ValueWithCoords,
"Note": ValueWithCoords,
"Title": ValueWithCoords,
"기타정보": ValueWithCoords,
},
)
class GeminiAnalyzer:
"""Gemini API 이미지 및 텍스트 분석 클래스"""
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
self.api_key = api_key or Config.GEMINI_API_KEY
self.model = model or Config.GEMINI_MODEL
self.default_prompt = Config.DEFAULT_PROMPT
if not self.api_key:
raise ValueError("Gemini API 키가 설정되지 않았습니다.")
try:
self.client = genai.Client(api_key=self.api_key)
logger.info(f"Gemini 클라이언트 초기화 완료 (모델: {self.model})")
except Exception as e:
logger.error(f"Gemini 클라이언트 초기화 실패: {e}")
raise
def _get_schema(self, organization_type: str) -> types.Schema:
"""조직 유형에 따른 스키마를 반환합니다."""
return SCHEMA_EXPRESSWAY if organization_type == "한국도로공사" else SCHEMA_TRANSPORTATION
def analyze_pdf_page(
self,
base64_data: str,
text_blocks: List[Dict[str, Any]],
prompt: Optional[str] = None,
mime_type: str = "image/png",
organization_type: str = "transportation"
) -> Optional[str]:
"""
Base64 이미지와 추출된 텍스트 좌표를 함께 분석합니다.
Args:
base64_data: Base64로 인코딩된 이미지 데이터.
text_blocks: PDF에서 추출된 텍스트와 좌표 정보 리스트.
prompt: 분석 요청 텍스트 (None인 경우 기본값 사용).
mime_type: 이미지 MIME 타입.
organization_type: 조직 유형 ("transportation" 또는 "expressway").
Returns:
분석 결과 JSON 문자열 또는 None (실패 시).
"""
try:
# 텍스트 블록 정보를 JSON 문자열로 변환하여 프롬프트에 추가
text_context = "\n".join([
f"- text: '{block['text']}', bbox: ({block['bbox'][0]:.0f}, {block['bbox'][1]:.0f})"
for block in text_blocks
])
analysis_prompt = (
(prompt or self.default_prompt) +
"\n\n--- 추출된 텍스트와 좌표 정보 ---\n" +
text_context +
"\n\n--- 지시사항 ---\n"
"위 텍스트와 좌표 정보를 바탕으로, 이미지의 내용을 분석하여 JSON 스키마를 채워주세요."
"각 필드에 해당하는 텍스트를 찾고, 해당 텍스트의 'value'와 시작 'x', 'y' 좌표를 JSON에 기입하세요."
)
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
mime_type=mime_type,
data=base64.b64decode(base64_data),
),
types.Part.from_text(text=analysis_prompt),
],
)
]
selected_schema = self._get_schema(organization_type)
generate_content_config = types.GenerateContentConfig(
temperature=0,
top_p=0.05,
response_mime_type="application/json",
response_schema=selected_schema
)
logger.info("Gemini API 분석 요청 시작 (텍스트 좌표 포함)...")
response = self.client.models.generate_content(
model=self.model,
contents=contents,
config=generate_content_config,
)
if response and hasattr(response, 'text'):
result = response.text
# JSON 응답을 파싱하여 다시 직렬화 (일관된 포맷팅)
parsed_json = json.loads(result)
pretty_result = json.dumps(parsed_json, ensure_ascii=False, indent=2)
logger.info(f"분석 완료: {len(pretty_result)} 문자")
return pretty_result
else:
logger.error("API 응답에서 텍스트를 찾을 수 없습니다.")
return None
except Exception as e:
logger.error(f"이미지 및 텍스트 분석 중 오류 발생: {e}")
return None
# --- 기존 다른 메서드들은 필요에 따라 수정 또는 유지 ---
# analyze_image_stream_from_base64, analyze_pdf_images 등은
# 새로운 analyze_pdf_page 메서드와 호환되도록 수정 필요.
# 지금은 핵심 기능에 집중.
def validate_api_connection(self) -> bool:
"""API 연결 상태를 확인합니다."""
try:
test_response = self.client.models.generate_content("안녕하세요")
if test_response and hasattr(test_response, 'text'):
logger.info("Gemini API 연결 테스트 성공")
return True
else:
logger.error("Gemini API 연결 테스트 실패")
return False
except Exception as e:
logger.error(f"Gemini API 연결 테스트 중 오류: {e}")
return False

1200
main.py Normal file

File diff suppressed because it is too large Load Diff

653
multi_file_main.py Normal file
View File

@@ -0,0 +1,653 @@
"""
다중 파일 처리 애플리케이션 클래스
여러 PDF/DXF 파일을 배치로 처리하고 결과를 CSV로 저장하는 기능을 제공합니다.
Author: Claude Assistant
Created: 2025-07-14
Version: 1.0.0
"""
import flet as ft
import asyncio
import logging
import os
from datetime import datetime
from typing import List, Optional
import time
# 프로젝트 모듈 임포트
from config import Config
from multi_file_processor import MultiFileProcessor, BatchProcessingConfig, generate_default_csv_filename
from ui_components import MultiFileUIComponents
from utils import DateTimeUtils
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class MultiFileApp:
"""다중 파일 처리 애플리케이션 클래스"""
def __init__(self, page: ft.Page):
self.page = page
self.selected_files = []
self.processing_results = []
self.is_processing = False
self.is_cancelled = False
self.is_paused = False
self.processor = None
# UI 컴포넌트 참조
self.file_picker = None
self.files_container = None
self.clear_files_button = None
self.batch_analysis_button = None
# 배치 설정 컴포넌트
self.organization_selector = None
self.concurrent_files_slider = None
self.enable_batch_mode = None
self.save_intermediate_results = None
self.include_error_files = None
self.csv_output_path = None
self.browse_button = None
# 진행률 컴포넌트
self.overall_progress_bar = None
self.progress_text = None
self.current_status_text = None
self.timing_info = None
self.log_container = None
self.cancel_button = None
self.pause_resume_button = None
# 결과 컴포넌트
self.summary_stats = None
self.results_table = None
self.save_csv_button = None
self.save_cross_csv_button = None # 새로 추가
self.save_excel_button = None
self.clear_results_button = None
# 시간 추적
self.start_time = None
# Gemini API 키 확인
self.init_processor()
def init_processor(self):
"""다중 파일 처리기 초기화"""
try:
config_errors = Config.validate_config()
if config_errors:
logger.error(f"설정 오류: {config_errors}")
return
# Gemini API 키가 있는지 확인
gemini_api_key = Config.get_gemini_api_key()
if not gemini_api_key:
logger.error("Gemini API 키가 설정되지 않았습니다")
return
self.processor = MultiFileProcessor(gemini_api_key)
logger.info("다중 파일 처리기 초기화 완료")
except Exception as e:
logger.error(f"다중 파일 처리기 초기화 실패: {e}")
def build_ui(self) -> ft.Column:
"""다중 파일 처리 UI 구성"""
# 파일 업로드 섹션
upload_section = self.create_file_upload_section()
# 배치 설정 섹션
settings_section = self.create_batch_settings_section()
# 진행률 섹션
progress_section = self.create_progress_section()
# 결과 섹션
results_section = self.create_results_section()
# 좌측 컨트롤 패널
left_panel = ft.Container(
content=ft.Column([
upload_section,
ft.Divider(height=10),
settings_section,
ft.Divider(height=10),
progress_section,
], scroll=ft.ScrollMode.AUTO),
col={"sm": 12, "md": 5, "lg": 4},
padding=10,
)
# 우측 결과 패널
right_panel = ft.Container(
content=results_section,
col={"sm": 12, "md": 7, "lg": 8},
padding=10,
)
# ResponsiveRow를 사용한 좌우 분할 레이아웃
main_layout = ft.ResponsiveRow([left_panel, right_panel])
return ft.Column([
main_layout
], expand=True, scroll=ft.ScrollMode.AUTO)
def create_file_upload_section(self) -> ft.Container:
"""파일 업로드 섹션 생성"""
upload_container = MultiFileUIComponents.create_multi_file_upload_section(
on_files_selected=self.on_files_selected,
on_batch_analysis_click=self.on_batch_analysis_click,
on_clear_files_click=self.on_clear_files_click
)
# 참조 저장
self.file_picker = upload_container.content.controls[-1] # 마지막 요소가 file_picker
self.files_container = upload_container.content.controls[4] # files_container
self.clear_files_button = upload_container.content.controls[2].controls[1]
self.batch_analysis_button = upload_container.content.controls[2].controls[2]
# overlay에 추가
self.page.overlay.append(self.file_picker)
return upload_container
def create_batch_settings_section(self) -> ft.Container:
"""배치 설정 섹션 생성"""
(
container,
self.organization_selector,
self.concurrent_files_slider,
self.enable_batch_mode,
self.save_intermediate_results,
self.include_error_files,
self.csv_output_path,
self.browse_button
) = MultiFileUIComponents.create_batch_settings_section()
# 슬라이더 변경 이벤트 처리
self.concurrent_files_slider.on_change = self.on_concurrent_files_change
# 경로 선택 버튼 이벤트 처리
self.browse_button.on_click = self.on_browse_csv_path_click
return container
def create_progress_section(self) -> ft.Container:
"""진행률 섹션 생성"""
(
container,
self.overall_progress_bar,
self.progress_text,
self.current_status_text,
self.timing_info,
self.log_container,
self.cancel_button,
self.pause_resume_button
) = MultiFileUIComponents.create_batch_progress_section()
# 버튼 이벤트 처리
self.cancel_button.on_click = self.on_cancel_click
self.pause_resume_button.on_click = self.on_pause_resume_click
return container
def create_results_section(self) -> ft.Container:
"""결과 섹션 생성"""
(
container,
self.summary_stats,
self.results_table,
self.save_csv_button,
self.save_cross_csv_button, # 새로 추가
self.save_excel_button,
self.clear_results_button
) = MultiFileUIComponents.create_batch_results_section()
# 버튼 이벤트 처리
self.save_csv_button.on_click = self.on_save_csv_click
self.save_cross_csv_button.on_click = self.on_save_cross_csv_click # 새로 추가
self.save_excel_button.on_click = self.on_save_excel_click
self.clear_results_button.on_click = self.on_clear_results_click
return container
# 이벤트 핸들러들
def on_files_selected(self, e: ft.FilePickerResultEvent):
"""파일 선택 이벤트 핸들러"""
if e.files:
# 선택된 파일들을 기존 목록에 추가 (중복 제거)
existing_paths = {f.path for f in self.selected_files}
new_files = [f for f in e.files if f.path not in existing_paths]
if new_files:
self.selected_files.extend(new_files)
MultiFileUIComponents.update_selected_files_list(
self.files_container,
self.selected_files,
self.clear_files_button,
self.batch_analysis_button
)
MultiFileUIComponents.add_log_message(
self.log_container,
f"{len(new_files)}개 파일 추가됨 (총 {len(self.selected_files)}개)",
"info"
)
else:
MultiFileUIComponents.add_log_message(
self.log_container,
"선택된 파일들이 이미 목록에 있습니다",
"warning"
)
else:
MultiFileUIComponents.add_log_message(
self.log_container,
"파일 선택이 취소되었습니다",
"info"
)
self.page.update()
def on_clear_files_click(self, e):
"""파일 목록 지우기 이벤트 핸들러"""
file_count = len(self.selected_files)
self.selected_files.clear()
MultiFileUIComponents.update_selected_files_list(
self.files_container,
self.selected_files,
self.clear_files_button,
self.batch_analysis_button
)
MultiFileUIComponents.add_log_message(
self.log_container,
f"{file_count}개 파일 목록 초기화",
"info"
)
self.page.update()
def on_batch_analysis_click(self, e):
"""배치 분석 시작 이벤트 핸들러"""
if not self.selected_files:
return
if self.is_processing:
return
if not self.processor:
self.show_error_dialog("처리기 오류", "다중 파일 처리기가 초기화되지 않았습니다.")
return
# 비동기 처리 시작
self.page.run_task(self.start_batch_processing)
def on_concurrent_files_change(self, e):
"""동시 처리 수 변경 이벤트 핸들러"""
value = int(e.control.value)
# 슬라이더 레이블 업데이트
# 상위 Container에서 텍스트 찾아서 업데이트
try:
settings_container = e.control.parent.parent # Column -> Container
text_control = settings_container.content.controls[2].controls[1].controls[0] # 해당 텍스트
text_control.value = f"동시 처리 수: {value}"
self.page.update()
except:
logger.warning("슬라이더 레이블 업데이트 실패")
def on_browse_csv_path_click(self, e):
"""CSV 저장 경로 선택 이벤트 핸들러"""
# 간단한 구현 - 현재 디렉토리 + 자동 생성 파일명 설정
default_filename = generate_default_csv_filename()
default_path = os.path.join(os.getcwd(), "results", default_filename)
self.csv_output_path.value = default_path
self.page.update()
MultiFileUIComponents.add_log_message(
self.log_container,
f"CSV 저장 경로 설정: {default_filename}",
"info"
)
def on_cancel_click(self, e):
"""처리 취소 이벤트 핸들러"""
if self.is_processing:
self.is_cancelled = True
MultiFileUIComponents.add_log_message(
self.log_container,
"사용자가 처리를 취소했습니다",
"warning"
)
self.page.update()
def on_pause_resume_click(self, e):
"""일시정지/재개 이벤트 핸들러"""
# 현재 구현에서는 단순한 토글 기능만 제공
if self.is_processing:
self.is_paused = not self.is_paused
if self.is_paused:
self.pause_resume_button.text = "재개"
self.pause_resume_button.icon = ft.Icons.PLAY_ARROW
MultiFileUIComponents.add_log_message(
self.log_container,
"처리가 일시정지되었습니다",
"warning"
)
else:
self.pause_resume_button.text = "일시정지"
self.pause_resume_button.icon = ft.Icons.PAUSE
MultiFileUIComponents.add_log_message(
self.log_container,
"처리가 재개되었습니다",
"success"
)
self.page.update()
def on_save_csv_click(self, e):
"""CSV 저장 이벤트 핸들러"""
if not self.processing_results:
self.show_error_dialog("저장 오류", "저장할 결과가 없습니다.")
return
self.page.run_task(self.save_results_to_csv)
def on_save_cross_csv_click(self, e):
"""Cross-Tabulated CSV 저장 이벤트 핸들러 (새로 추가)"""
if not self.processing_results:
self.show_error_dialog("저장 오류", "저장할 결과가 없습니다.")
return
self.page.run_task(self.save_cross_tabulated_csv)
def on_save_excel_click(self, e):
"""Excel 저장 이벤트 핸들러"""
if not self.processing_results:
self.show_error_dialog("저장 오류", "저장할 결과가 없습니다.")
return
# Excel 저장 기능 (향후 구현)
self.show_info_dialog("개발 중", "Excel 저장 기능은 곧 추가될 예정입니다.")
def on_clear_results_click(self, e):
"""결과 초기화 이벤트 핸들러"""
self.processing_results.clear()
MultiFileUIComponents.update_batch_results(
self.summary_stats,
self.results_table,
self.processing_results,
self.save_csv_button,
self.save_cross_csv_button, # 새로 추가
self.save_excel_button,
self.clear_results_button
)
MultiFileUIComponents.add_log_message(
self.log_container,
"결과가 초기화되었습니다",
"info"
)
self.page.update()
# 비동기 처리 함수들
async def start_batch_processing(self):
"""배치 처리 시작"""
try:
self.is_processing = True
self.is_cancelled = False
self.is_paused = False
self.start_time = time.time()
# UI 상태 업데이트
self.batch_analysis_button.disabled = True
self.cancel_button.disabled = False
self.pause_resume_button.disabled = False
# 처리 설정 구성
config = BatchProcessingConfig(
organization_type=self.organization_selector.value,
enable_gemini_batch_mode=self.enable_batch_mode.value,
max_concurrent_files=int(self.concurrent_files_slider.value),
save_intermediate_results=self.save_intermediate_results.value,
output_csv_path=self.csv_output_path.value or None,
include_error_files=self.include_error_files.value
)
MultiFileUIComponents.add_log_message(
self.log_container,
f"배치 처리 시작: {len(self.selected_files)}개 파일",
"info"
)
# 파일 경로 추출
file_paths = [f.path for f in self.selected_files]
# 진행률 콜백 함수
def progress_callback(current: int, total: int, status: str):
elapsed_time = time.time() - self.start_time if self.start_time else 0
estimated_remaining = (elapsed_time / current * (total - current)) if current > 0 else 0
MultiFileUIComponents.update_batch_progress(
self.overall_progress_bar,
self.progress_text,
self.current_status_text,
self.timing_info,
current,
total,
status,
elapsed_time,
estimated_remaining
)
MultiFileUIComponents.add_log_message(
self.log_container,
status,
"success" if "완료" in status else "info"
)
self.page.update()
# 처리 실행
self.processing_results = await self.processor.process_multiple_files(
file_paths, config, progress_callback
)
# 결과 표시
MultiFileUIComponents.update_batch_results(
self.summary_stats,
self.results_table,
self.processing_results,
self.save_csv_button,
self.save_cross_csv_button, # 새로 추가
self.save_excel_button,
self.clear_results_button
)
# 요약 정보
summary = self.processor.get_processing_summary()
MultiFileUIComponents.add_log_message(
self.log_container,
f"처리 완료! 성공: {summary['success_files']}, 실패: {summary['failed_files']}, 성공률: {summary['success_rate']}%",
"success"
)
except Exception as e:
logger.error(f"배치 처리 오류: {e}")
MultiFileUIComponents.add_log_message(
self.log_container,
f"처리 오류: {str(e)}",
"error"
)
self.show_error_dialog("처리 오류", f"배치 처리 중 오류가 발생했습니다:\n{str(e)}")
finally:
# UI 상태 복원
self.is_processing = False
self.batch_analysis_button.disabled = False
self.cancel_button.disabled = True
self.pause_resume_button.disabled = True
self.pause_resume_button.text = "일시정지"
self.pause_resume_button.icon = ft.Icons.PAUSE
self.page.update()
async def save_results_to_csv(self):
"""결과를 CSV로 저장"""
try:
if not self.processor:
self.show_error_dialog("오류", "처리기가 초기화되지 않았습니다.")
return
# CSV 경로 설정
output_path = self.csv_output_path.value
if not output_path:
# 자동 생성
results_dir = os.path.join(os.getcwd(), "results")
os.makedirs(results_dir, exist_ok=True)
output_path = os.path.join(results_dir, generate_default_csv_filename())
# CSV 저장
await self.processor.save_results_to_csv(output_path)
self.show_info_dialog(
"저장 완료",
f"배치 처리 결과가 CSV 파일로 저장되었습니다:\n\n{output_path}"
)
MultiFileUIComponents.add_log_message(
self.log_container,
f"CSV 저장 완료: {os.path.basename(output_path)}",
"success"
)
except Exception as e:
logger.error(f"CSV 저장 오류: {e}")
self.show_error_dialog("저장 오류", f"CSV 저장 중 오류가 발생했습니다:\n{str(e)}")
MultiFileUIComponents.add_log_message(
self.log_container,
f"CSV 저장 실패: {str(e)}",
"error"
)
async def save_cross_tabulated_csv(self):
"""Cross-tabulated CSV로 저장 (새로 추가)"""
try:
# Cross-tabulated CSV 내보내기 모듈 임포트
from cross_tabulated_csv_exporter import CrossTabulatedCSVExporter, generate_cross_tabulated_csv_filename
exporter = CrossTabulatedCSVExporter()
# CSV 경로 설정
results_dir = os.path.join(os.getcwd(), "results")
os.makedirs(results_dir, exist_ok=True)
# Cross-tabulated CSV 파일명 생성
cross_csv_filename = generate_cross_tabulated_csv_filename("batch_key_value_analysis")
output_path = os.path.join(results_dir, cross_csv_filename)
# Cross-tabulated CSV 저장
success = exporter.export_cross_tabulated_csv(
self.processing_results,
output_path,
include_coordinates=True,
coordinate_source="auto"
)
if success:
self.show_info_dialog(
"Key-Value CSV 저장 완료",
f"분석 결과가 Key-Value 형태의 CSV 파일로 저장되었습니다:\n\n{output_path}\n\n" +
"이 CSV는 다음과 같은 형태로 구성됩니다:\n" +
"- file_name: 파일명\n" +
"- file_type: 파일 형식\n" +
"- key: 속성 키\n" +
"- value: 속성 값\n" +
"- x, y: 좌표 정보 (가능한 경우)"
)
MultiFileUIComponents.add_log_message(
self.log_container,
f"Key-Value CSV 저장 완료: {os.path.basename(output_path)}",
"success"
)
else:
self.show_error_dialog(
"저장 실패",
"Key-Value CSV 저장에 실패했습니다. 로그를 확인해주세요."
)
MultiFileUIComponents.add_log_message(
self.log_container,
"Key-Value CSV 저장 실패",
"error"
)
except Exception as e:
logger.error(f"Cross-tabulated CSV 저장 오류: {e}")
self.show_error_dialog(
"저장 오류",
f"Key-Value CSV 저장 중 오류가 발생했습니다:\n{str(e)}"
)
MultiFileUIComponents.add_log_message(
self.log_container,
f"Key-Value CSV 저장 실패: {str(e)}",
"error"
)
# 유틸리티 함수들
def show_error_dialog(self, title: str, message: str):
"""오류 다이얼로그 표시"""
from ui_components import UIComponents
dialog = UIComponents.create_error_dialog(title, message)
def close_dialog(e):
dialog.open = False
self.page.update()
dialog.actions[0].on_click = close_dialog
self.page.dialog = dialog
dialog.open = True
self.page.update()
def show_info_dialog(self, title: str, message: str):
"""정보 다이얼로그 표시"""
from ui_components import UIComponents
dialog = UIComponents.create_info_dialog(title, message)
def close_dialog(e):
dialog.open = False
self.page.update()
dialog.actions[0].on_click = close_dialog
self.page.dialog = dialog
dialog.open = True
self.page.update()
# 사용 예시
if __name__ == "__main__":
def main_multi_file(page: ft.Page):
"""다중 파일 처리 앱 테스트 실행"""
page.title = "다중 파일 처리 테스트"
page.theme_mode = ft.ThemeMode.LIGHT
app = MultiFileApp(page)
ui = app.build_ui()
page.add(ui)
ft.app(target=main_multi_file)

510
multi_file_processor.py Normal file
View File

@@ -0,0 +1,510 @@
"""
다중 파일 처리 모듈
여러 PDF/DXF 파일을 배치로 처리하고 결과를 CSV로 저장하는 기능을 제공합니다.
Author: Claude Assistant
Created: 2025-07-14
Version: 1.0.0
"""
import asyncio
import os
import pandas as pd
from datetime import datetime
from typing import List, Dict, Any, Optional, Callable
from dataclasses import dataclass
import logging
from pdf_processor import PDFProcessor
from dxf_processor import EnhancedDXFProcessor
from gemini_analyzer import GeminiAnalyzer
from csv_exporter import TitleBlockCSVExporter
import json # Added import
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@dataclass
class FileProcessingResult:
"""단일 파일 처리 결과"""
file_path: str
file_name: str
file_type: str
file_size: int
processing_time: float
success: bool
error_message: Optional[str] = None
# PDF 분석 결과
pdf_analysis_result: Optional[str] = None
# DXF 분석 결과
dxf_title_blocks: Optional[List[Dict]] = None
dxf_total_attributes: Optional[int] = None
dxf_total_text_entities: Optional[int] = None
# 공통 메타데이터
processed_at: Optional[str] = None
@dataclass
class BatchProcessingConfig:
"""배치 처리 설정"""
organization_type: str = "국토교통부"
enable_gemini_batch_mode: bool = False
max_concurrent_files: int = 3
save_intermediate_results: bool = True
output_csv_path: Optional[str] = None
include_error_files: bool = True
class MultiFileProcessor:
"""다중 파일 처리기"""
def __init__(self, gemini_api_key: str):
"""
다중 파일 처리기 초기화
Args:
gemini_api_key: Gemini API 키
"""
self.gemini_api_key = gemini_api_key
self.pdf_processor = PDFProcessor()
self.dxf_processor = EnhancedDXFProcessor()
self.gemini_analyzer = GeminiAnalyzer(gemini_api_key)
self.csv_exporter = TitleBlockCSVExporter() # CSV 내보내기 추가
self.processing_results: List[FileProcessingResult] = []
self.current_progress = 0
self.total_files = 0
async def process_multiple_files(
self,
file_paths: List[str],
config: BatchProcessingConfig,
progress_callback: Optional[Callable[[int, int, str], None]] = None
) -> List[FileProcessingResult]:
"""
여러 파일을 배치로 처리
Args:
file_paths: 처리할 파일 경로 리스트
config: 배치 처리 설정
progress_callback: 진행률 콜백 함수 (current, total, status)
Returns:
처리 결과 리스트
"""
self.processing_results = []
self.total_files = len(file_paths)
self.current_progress = 0
logger.info(f"배치 처리 시작: {self.total_files}개 파일")
# 동시 처리 제한을 위한 세마포어
semaphore = asyncio.Semaphore(config.max_concurrent_files)
# 각 파일에 대한 처리 태스크 생성
tasks = []
for i, file_path in enumerate(file_paths):
task = self._process_single_file_with_semaphore(
semaphore, file_path, config, progress_callback, i + 1
)
tasks.append(task)
# 모든 파일 처리 완료까지 대기
results = await asyncio.gather(*tasks, return_exceptions=True)
# 예외 발생한 결과 처리
for i, result in enumerate(results):
if isinstance(result, Exception):
error_result = FileProcessingResult(
file_path=file_paths[i],
file_name=os.path.basename(file_paths[i]),
file_type="unknown",
file_size=0,
processing_time=0,
success=False,
error_message=str(result),
processed_at=datetime.now().isoformat()
)
self.processing_results.append(error_result)
logger.info(f"배치 처리 완료: {len(self.processing_results)}개 결과")
# CSV 저장
if config.output_csv_path:
await self.save_results_to_csv(config.output_csv_path)
return self.processing_results
async def _process_single_file_with_semaphore(
self,
semaphore: asyncio.Semaphore,
file_path: str,
config: BatchProcessingConfig,
progress_callback: Optional[Callable[[int, int, str], None]],
file_number: int
) -> None:
"""세마포어를 사용하여 단일 파일 처리"""
async with semaphore:
result = await self._process_single_file(file_path, config)
self.processing_results.append(result)
self.current_progress += 1
if progress_callback:
status = f"처리 완료: {result.file_name}"
if not result.success:
status = f"처리 실패: {result.file_name} - {result.error_message}"
progress_callback(self.current_progress, self.total_files, status)
async def _process_single_file(
self,
file_path: str,
config: BatchProcessingConfig
) -> FileProcessingResult:
"""
단일 파일 처리
Args:
file_path: 파일 경로
config: 처리 설정
Returns:
처리 결과
"""
start_time = asyncio.get_event_loop().time()
file_name = os.path.basename(file_path)
try:
# 파일 정보 수집
file_size = os.path.getsize(file_path)
file_type = self._detect_file_type(file_path)
logger.info(f"파일 처리 시작: {file_name} ({file_type})")
result = FileProcessingResult(
file_path=file_path,
file_name=file_name,
file_type=file_type,
file_size=file_size,
processing_time=0,
success=False,
processed_at=datetime.now().isoformat()
)
# 파일 유형에 따른 처리
if file_type.lower() == 'pdf':
await self._process_pdf_file(file_path, result, config)
elif file_type.lower() == 'dxf':
await self._process_dxf_file(file_path, result, config)
else:
raise ValueError(f"지원하지 않는 파일 형식: {file_type}")
result.success = True
except Exception as e:
logger.error(f"파일 처리 오류 ({file_name}): {str(e)}")
result.success = False
result.error_message = str(e)
finally:
# 처리 시간 계산
end_time = asyncio.get_event_loop().time()
result.processing_time = round(end_time - start_time, 2)
return result
async def _process_pdf_file(
self,
file_path: str,
result: FileProcessingResult,
config: BatchProcessingConfig
) -> None:
"""PDF 파일 처리"""
# PDF 이미지 변환
images = self.pdf_processor.convert_to_images(file_path)
if not images:
raise ValueError("PDF를 이미지로 변환할 수 없습니다")
# 첫 번째 페이지만 분석 (다중 페이지 처리는 향후 개선)
first_page = images[0]
base64_image = self.pdf_processor.image_to_base64(first_page)
# PDF에서 텍스트 블록 추출
text_blocks = self.pdf_processor.extract_text_with_coordinates(file_path, 0)
# Gemini API로 분석
# 실제 구현에서는 batch mode 사용 가능
analysis_result = await self._analyze_with_gemini(
base64_image, text_blocks, config.organization_type
)
result.pdf_analysis_result = analysis_result
async def _process_dxf_file(
self,
file_path: str,
result: FileProcessingResult,
config: BatchProcessingConfig
) -> None:
"""DXF 파일 처리"""
# DXF 파일 분석
extraction_result = self.dxf_processor.extract_comprehensive_data(file_path)
# 타이틀 블록 정보를 딕셔너리 리스트로 변환
title_blocks = []
for tb_info in extraction_result.title_blocks:
tb_dict = {
'block_name': tb_info.block_name,
'block_position': f"{tb_info.block_position[0]:.2f}, {tb_info.block_position[1]:.2f}",
'attributes_count': tb_info.attributes_count,
'attributes': [
{
'tag': attr.tag,
'text': attr.text,
'prompt': attr.prompt,
'insert_x': attr.insert_x,
'insert_y': attr.insert_y
}
for attr in tb_info.all_attributes
]
}
title_blocks.append(tb_dict)
result.dxf_title_blocks = title_blocks
result.dxf_total_attributes = sum(tb['attributes_count'] for tb in title_blocks)
result.dxf_total_text_entities = len(extraction_result.text_entities)
# 상세한 title block attributes CSV 생성
if extraction_result.title_blocks:
await self._save_detailed_dxf_csv(file_path, extraction_result)
async def _analyze_with_gemini(
self,
base64_image: str,
text_blocks: list,
organization_type: str
) -> str:
"""Gemini API로 이미지 분석"""
try:
# 비동기 처리를 위해 동기 함수를 태스크로 실행
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
self.gemini_analyzer.analyze_pdf_page,
base64_image,
text_blocks,
None, # prompt (default 사용)
"image/png", # mime_type
organization_type
)
return result
except Exception as e:
logger.error(f"Gemini 분석 오류: {str(e)}")
return f"분석 실패: {str(e)}"
async def _save_detailed_dxf_csv(
self,
file_path: str,
extraction_result
) -> None:
"""상세한 DXF title block attributes CSV 저장"""
try:
# 파일명에서 확장자 제거
file_name = os.path.splitext(os.path.basename(file_path))[0]
# 출력 디렉토리 확인 및 생성
output_dir = os.path.join(os.path.dirname(file_path), '..', 'results')
os.makedirs(output_dir, exist_ok=True)
# CSV 파일명 생성
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
csv_filename = f"detailed_title_blocks_{file_name}_{timestamp}.csv"
csv_path = os.path.join(output_dir, csv_filename)
# TitleBlockCSVExporter를 사용하여 CSV 생성
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
self.csv_exporter.save_title_block_info_to_csv,
extraction_result.title_blocks,
csv_path
)
logger.info(f"상세 DXF CSV 저장 완료: {csv_path}")
except Exception as e:
logger.error(f"상세 DXF CSV 저장 오류: {str(e)}")
def _detect_file_type(self, file_path: str) -> str:
"""파일 확장자로 파일 유형 검출"""
_, ext = os.path.splitext(file_path.lower())
if ext == '.pdf':
return 'PDF'
elif ext == '.dxf':
return 'DXF'
else:
return ext.upper().lstrip('.')
async def save_results_to_csv(self, output_path: str) -> None:
"""
처리 결과를 CSV 파일로 저장
Args:
output_path: 출력 CSV 파일 경로
"""
try:
# 결과를 DataFrame으로 변환
data_rows = []
for result in self.processing_results:
# 기본 정보
row = {
'file_name': result.file_name,
'file_path': result.file_path,
'file_type': result.file_type,
'file_size_bytes': result.file_size,
'file_size_mb': round(result.file_size / (1024 * 1024), 2),
'processing_time_seconds': result.processing_time,
'success': result.success,
'error_message': result.error_message or '',
'processed_at': result.processed_at
}
# PDF 분석 결과
if result.file_type.lower() == 'pdf':
row['pdf_analysis_result'] = result.pdf_analysis_result or ''
row['dxf_total_attributes'] = ''
row['dxf_total_text_entities'] = ''
row['dxf_title_blocks_summary'] = ''
# DXF 분석 결과
elif result.file_type.lower() == 'dxf':
row['pdf_analysis_result'] = ''
row['dxf_total_attributes'] = result.dxf_total_attributes or 0
row['dxf_total_text_entities'] = result.dxf_total_text_entities or 0
# 타이틀 블록 요약
if result.dxf_title_blocks:
summary = f"{len(result.dxf_title_blocks)}개 타이틀블록"
for tb in result.dxf_title_blocks[:3]: # 처음 3개만 표시
summary += f" | {tb['block_name']}({tb['attributes_count']}속성)"
if len(result.dxf_title_blocks) > 3:
summary += f" | ...외 {len(result.dxf_title_blocks)-3}"
row['dxf_title_blocks_summary'] = summary
else:
row['dxf_title_blocks_summary'] = '타이틀블록 없음'
data_rows.append(row)
# DataFrame 생성 및 CSV 저장
df = pd.DataFrame(data_rows)
# pdf_analysis_result 컬럼 평탄화
if 'pdf_analysis_result' in df.columns:
# JSON 문자열을 딕셔너리로 변환 (이미 딕셔너리인 경우도 처리)
df['pdf_analysis_result'] = df['pdf_analysis_result'].apply(lambda x: json.loads(x) if isinstance(x, str) and x.strip() else {}).fillna({})
# 평탄화된 데이터를 새로운 DataFrame으로 생성
# errors='ignore'를 사용하여 JSON이 아닌 값은 무시
# record_prefix를 사용하여 컬럼 이름에 접두사 추가
pdf_analysis_df = pd.json_normalize(df['pdf_analysis_result'], errors='ignore', record_prefix='pdf_analysis_result_')
# 원본 df에서 pdf_analysis_result 컬럼 제거
df = df.drop(columns=['pdf_analysis_result'])
# 원본 df와 평탄화된 DataFrame을 병합
df = pd.concat([df, pdf_analysis_df], axis=1)
# 컬럼 순서 정렬을 위한 기본 순서 정의
column_order = [
'file_name', 'file_type', 'file_size_mb', 'processing_time_seconds',
'success', 'error_message', 'processed_at', 'file_path', 'file_size_bytes',
'dxf_total_attributes', 'dxf_total_text_entities', 'dxf_title_blocks_summary'
]
# 기존 컬럼 순서를 유지하면서 새로운 컬럼을 추가
existing_columns = [col for col in column_order if col in df.columns]
new_columns = [col for col in df.columns if col not in existing_columns]
df = df[existing_columns + sorted(new_columns)]
# UTF-8 BOM으로 저장 (한글 호환성)
df.to_csv(output_path, index=False, encoding='utf-8-sig')
logger.info(f"CSV 저장 완료: {output_path}")
logger.info(f"{len(data_rows)}개 파일 결과 저장")
except Exception as e:
logger.error(f"CSV 저장 오류: {str(e)}")
raise
def get_processing_summary(self) -> Dict[str, Any]:
"""처리 결과 요약 정보 반환"""
if not self.processing_results:
return {}
total_files = len(self.processing_results)
success_files = sum(1 for r in self.processing_results if r.success)
failed_files = total_files - success_files
pdf_files = sum(1 for r in self.processing_results if r.file_type.lower() == 'pdf')
dxf_files = sum(1 for r in self.processing_results if r.file_type.lower() == 'dxf')
total_processing_time = sum(r.processing_time for r in self.processing_results)
avg_processing_time = total_processing_time / total_files if total_files > 0 else 0
total_file_size = sum(r.file_size for r in self.processing_results)
return {
'total_files': total_files,
'success_files': success_files,
'failed_files': failed_files,
'pdf_files': pdf_files,
'dxf_files': dxf_files,
'total_processing_time': round(total_processing_time, 2),
'avg_processing_time': round(avg_processing_time, 2),
'total_file_size_mb': round(total_file_size / (1024 * 1024), 2),
'success_rate': round((success_files / total_files) * 100, 1) if total_files > 0 else 0
}
def generate_default_csv_filename() -> str:
"""기본 CSV 파일명 생성"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"batch_analysis_results_{timestamp}.csv"
# 사용 예시
if __name__ == "__main__":
async def main():
# 테스트용 예시
processor = MultiFileProcessor("your-gemini-api-key")
config = BatchProcessingConfig(
organization_type="국토교통부",
max_concurrent_files=2,
output_csv_path="test_results.csv"
)
# 진행률 콜백 함수
def progress_callback(current: int, total: int, status: str):
print(f"진행률: {current}/{total} ({current/total*100:.1f}%) - {status}")
# 파일 경로 리스트 (실제 파일 경로로 교체 필요)
file_paths = [
"sample1.pdf",
"sample2.dxf",
"sample3.pdf"
]
results = await processor.process_multiple_files(
file_paths, config, progress_callback
)
summary = processor.get_processing_summary()
print("처리 요약:", summary)
# asyncio.run(main())

322
pdf_processor.py Normal file
View File

@@ -0,0 +1,322 @@
"""
PDF 처리 모듈
PDF 파일을 이미지로 변환하고 base64로 인코딩하는 기능을 제공합니다.
"""
import base64
import io
import fitz # PyMuPDF
from PIL import Image
from typing import List, Optional, Tuple, Dict, Any
import logging
from pathlib import Path
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class PDFProcessor:
"""PDF 파일 처리 클래스"""
def __init__(self):
self.supported_formats = ['pdf']
def validate_pdf_file(self, file_path: str) -> bool:
"""PDF 파일 유효성 검사"""
try:
path = Path(file_path)
# 파일 존재 여부 확인
if not path.exists():
logger.error(f"파일이 존재하지 않습니다: {file_path}")
return False
# 파일 확장자 확인
if path.suffix.lower() != '.pdf':
logger.error(f"지원하지 않는 파일 형식입니다: {path.suffix}")
return False
# PDF 파일 열기 테스트
doc = fitz.open(file_path)
page_count = len(doc)
doc.close()
if page_count == 0:
logger.error("PDF 파일에 페이지가 없습니다.")
return False
logger.info(f"PDF 검증 완료: {page_count}페이지")
return True
except Exception as e:
logger.error(f"PDF 파일 검증 중 오류 발생: {e}")
return False
def get_pdf_info(self, file_path: str) -> Optional[dict]:
"""PDF 파일 정보 조회"""
try:
doc = fitz.open(file_path)
info = {
'page_count': len(doc),
'metadata': doc.metadata,
'file_size': Path(file_path).stat().st_size,
'filename': Path(file_path).name
}
doc.close()
return info
except Exception as e:
logger.error(f"PDF 정보 조회 중 오류 발생: {e}")
return None
def convert_pdf_page_to_image(
self,
file_path: str,
page_number: int = 0,
zoom: float = 2.0,
image_format: str = "PNG"
) -> Optional[Image.Image]:
"""PDF 페이지를 PIL Image로 변환"""
try:
doc = fitz.open(file_path)
if page_number >= len(doc):
logger.error(f"페이지 번호가 범위를 벗어남: {page_number}")
doc.close()
return None
# 페이지 로드
page = doc.load_page(page_number)
# 이미지 변환을 위한 매트릭스 설정 (확대/축소)
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
# PIL Image로 변환
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
doc.close()
logger.info(f"페이지 {page_number + 1} 이미지 변환 완료: {img.size}")
return img
except Exception as e:
logger.error(f"PDF 페이지 이미지 변환 중 오류 발생: {e}")
return None
def convert_pdf_to_images(
self,
file_path: str,
max_pages: Optional[int] = None,
zoom: float = 2.0
) -> List[Image.Image]:
"""PDF의 모든 페이지를 이미지로 변환"""
images = []
try:
doc = fitz.open(file_path)
total_pages = len(doc)
# 최대 페이지 수 제한
if max_pages:
total_pages = min(total_pages, max_pages)
for page_num in range(total_pages):
img = self.convert_pdf_page_to_image(file_path, page_num, zoom)
if img:
images.append(img)
doc.close()
logger.info(f"{len(images)}개 페이지 이미지 변환 완료")
except Exception as e:
logger.error(f"PDF 전체 페이지 변환 중 오류 발생: {e}")
return images
def image_to_base64(
self,
image: Image.Image,
format: str = "PNG",
quality: int = 95
) -> Optional[str]:
"""PIL Image를 base64 문자열로 변환"""
try:
buffer = io.BytesIO()
# JPEG 형식인 경우 품질 설정
if format.upper() == "JPEG":
image.save(buffer, format=format, quality=quality)
else:
image.save(buffer, format=format)
buffer.seek(0)
base64_string = base64.b64encode(buffer.getvalue()).decode('utf-8')
logger.info(f"이미지를 base64로 변환 완료 (크기: {len(base64_string)} 문자)")
return base64_string
except Exception as e:
logger.error(f"이미지 base64 변환 중 오류 발생: {e}")
return None
def pdf_page_to_base64(
self,
file_path: str,
page_number: int = 0,
zoom: float = 2.0,
format: str = "PNG"
) -> Optional[str]:
"""PDF 페이지를 직접 base64로 변환"""
img = self.convert_pdf_page_to_image(file_path, page_number, zoom)
if img:
return self.image_to_base64(img, format)
return None
def pdf_page_to_image_bytes(
self,
file_path: str,
page_number: int = 0,
zoom: float = 2.0,
format: str = "PNG"
) -> Optional[bytes]:
"""PDF 페이지를 이미지 바이트로 변환 (Flet 이미지 표시용)"""
try:
img = self.convert_pdf_page_to_image(file_path, page_number, zoom)
if img:
buffer = io.BytesIO()
img.save(buffer, format=format)
buffer.seek(0)
image_bytes = buffer.getvalue()
logger.info(f"페이지 {page_number + 1} 이미지 바이트 변환 완료 (크기: {len(image_bytes)} 바이트)")
return image_bytes
return None
except Exception as e:
logger.error(f"PDF 페이지 이미지 바이트 변환 중 오류 발생: {e}")
return None
def get_optimal_zoom_for_size(self, target_size: Tuple[int, int]) -> float:
"""목표 크기에 맞는 최적 줌 비율 계산"""
# 기본 PDF 페이지 크기 (A4: 595x842 points)
default_width, default_height = 595, 842
target_width, target_height = target_size
# 비율 계산
width_ratio = target_width / default_width
height_ratio = target_height / default_height
# 작은 비율을 선택하여 전체 페이지가 들어가도록 함
zoom = min(width_ratio, height_ratio)
logger.info(f"최적 줌 비율 계산: {zoom:.2f}")
return zoom
def extract_text_with_coordinates(self, file_path: str, page_number: int = 0) -> List[Dict[str, Any]]:
"""PDF 페이지에서 텍스트와 좌표를 추출합니다."""
text_blocks = []
try:
doc = fitz.open(file_path)
if page_number >= len(doc):
logger.error(f"페이지 번호가 범위를 벗어남: {page_number}")
doc.close()
return []
page = doc.load_page(page_number)
# 'dict' 옵션은 블록, 라인, 스팬에 대한 상세 정보를 제공합니다.
blocks = page.get_text("dict")["blocks"]
for b in blocks: # 블록 반복
if b['type'] == 0: # 텍스트 블록
for l in b["lines"]: # 라인 반복
for s in l["spans"]: # 스팬(텍스트 조각) 반복
text_blocks.append({
"text": s["text"],
"bbox": s["bbox"], # (x0, y0, x1, y1)
"font": s["font"],
"size": s["size"]
})
doc.close()
logger.info(f"페이지 {page_number + 1}에서 {len(text_blocks)}개의 텍스트 블록 추출 완료")
return text_blocks
except Exception as e:
logger.error(f"PDF 텍스트 및 좌표 추출 중 오류 발생: {e}")
return []
def convert_to_images(
self,
file_path: str,
zoom: float = 2.0,
max_pages: int = 10
) -> List[Image.Image]:
"""PDF의 모든 페이지(또는 지정된 수까지)를 PIL Image 리스트로 변환"""
images = []
try:
doc = fitz.open(file_path)
page_count = min(len(doc), max_pages) # 최대 페이지 수 제한
logger.info(f"PDF 변환 시작: {page_count}페이지")
for page_num in range(page_count):
page = doc.load_page(page_num)
# 이미지 변환을 위한 매트릭스 설정
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
# PIL Image로 변환
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
images.append(img)
logger.info(f"페이지 {page_num + 1}/{page_count} 변환 완료: {img.size}")
doc.close()
logger.info(f"PDF 전체 변환 완료: {len(images)}개 이미지")
return images
except Exception as e:
logger.error(f"PDF 다중 페이지 변환 중 오류 발생: {e}")
return []
def image_to_bytes(self, image: Image.Image, format: str = 'PNG') -> bytes:
"""
PIL Image를 바이트 데이터로 변환합니다.
Args:
image: PIL Image 객체
format: 이미지 포맷 ('PNG', 'JPEG' 등)
Returns:
이미지 바이트 데이터
"""
try:
buffer = io.BytesIO()
image.save(buffer, format=format)
image_bytes = buffer.getvalue()
buffer.close()
logger.info(f"이미지를 {format} 바이트로 변환: {len(image_bytes)} bytes")
return image_bytes
except Exception as e:
logger.error(f"이미지 바이트 변환 중 오류 발생: {e}")
return b''
# 사용 예시
if __name__ == "__main__":
processor = PDFProcessor()
# 테스트용 코드 (실제 PDF 파일 경로로 변경 필요)
test_pdf = "test.pdf"
if processor.validate_pdf_file(test_pdf):
info = processor.get_pdf_info(test_pdf)
print(f"PDF 정보: {info}")
# 첫 번째 페이지를 base64로 변환
base64_data = processor.pdf_page_to_base64(test_pdf, 0)
if base64_data:
print(f"Base64 변환 성공: {len(base64_data)} 문자")
else:
print("PDF 파일 검증 실패")

1022
project_plan.md Normal file

File diff suppressed because it is too large Load Diff

38
requirements.txt Normal file
View File

@@ -0,0 +1,38 @@
# Flet 기반 PDF 이미지 분석기 - 필수 라이브러리
# UI 프레임워크
flet>=0.25.1
# Google Generative AI SDK
google-genai>=1.0.0
# PDF 처리 라이브러리 (둘 중 하나 선택)
PyMuPDF>=1.26.3
pdf2image>=1.17.0
# 이미지 처리
Pillow>=10.0.0
# DXF 파일 처리 (NEW)
ezdxf>=1.4.2
# 수치 계산 (NEW)
numpy>=1.24.0
# 환경 변수 관리
python-dotenv>=1.0.0
# 추가 유틸리티
requests>=2.31.0
# 데이터 처리 (NEW - 다중 파일 CSV 출력용)
pandas>=2.0.0
# Flet Material Design (선택 사항)
flet-material>=0.3.3
# 개발 도구 (선택 사항)
# black>=23.0.0
# flake8>=6.0.0
# pytest>=7.0.0
# mypy>=1.0.0

264
setup.py Normal file
View File

@@ -0,0 +1,264 @@
"""
설치 및 설정 가이드 스크립트
프로젝트 설치와 초기 설정을 도와주는 스크립트입니다.
"""
import os
import sys
import subprocess
import shutil
from pathlib import Path
def print_header(title):
"""헤더 출력"""
print("\n" + "=" * 60)
print(f" {title}")
print("=" * 60)
def print_step(step_num, title):
"""단계 출력"""
print(f"\n📋 단계 {step_num}: {title}")
print("-" * 40)
def check_python_version():
"""Python 버전 확인"""
print_step(1, "Python 버전 확인")
version = sys.version_info
print(f"현재 Python 버전: {version.major}.{version.minor}.{version.micro}")
if version.major < 3 or (version.major == 3 and version.minor < 9):
print("❌ Python 3.9 이상이 필요합니다.")
print("https://www.python.org/downloads/ 에서 최신 버전을 다운로드하세요.")
return False
else:
print("✅ Python 버전이 요구사항을 만족합니다.")
return True
def check_pip():
"""pip 확인 및 업그레이드"""
print_step(2, "pip 확인 및 업그레이드")
try:
# pip 버전 확인
result = subprocess.run([sys.executable, "-m", "pip", "--version"],
capture_output=True, text=True)
print(f"pip 버전: {result.stdout.strip()}")
# pip 업그레이드
print("pip를 최신 버전으로 업그레이드 중...")
subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "pip"],
check=True)
print("✅ pip 업그레이드 완료")
return True
except subprocess.CalledProcessError as e:
print(f"❌ pip 업그레이드 실패: {e}")
return False
def create_virtual_environment():
"""가상 환경 생성 (선택 사항)"""
print_step(3, "가상 환경 생성 (권장)")
venv_path = Path("venv")
if venv_path.exists():
print("⚠️ 가상 환경이 이미 존재합니다.")
response = input("기존 가상 환경을 삭제하고 새로 만드시겠습니까? (y/N): ")
if response.lower() == 'y':
shutil.rmtree(venv_path)
else:
print("기존 가상 환경을 사용합니다.")
return True
try:
print("가상 환경 생성 중...")
subprocess.run([sys.executable, "-m", "venv", "venv"], check=True)
print("✅ 가상 환경 생성 완료")
# 활성화 안내
if os.name == 'nt': # Windows
print("\n가상 환경 활성화 방법:")
print(" venv\\Scripts\\activate")
else: # macOS/Linux
print("\n가상 환경 활성화 방법:")
print(" source venv/bin/activate")
return True
except subprocess.CalledProcessError as e:
print(f"❌ 가상 환경 생성 실패: {e}")
return False
def install_dependencies():
"""의존성 설치"""
print_step(4, "의존성 설치")
requirements_file = Path("requirements.txt")
if not requirements_file.exists():
print("❌ requirements.txt 파일을 찾을 수 없습니다.")
return False
try:
print("의존성 패키지 설치 중...")
print("(이 과정은 몇 분 정도 걸릴 수 있습니다)")
subprocess.run([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
check=True)
print("✅ 모든 의존성 설치 완료")
return True
except subprocess.CalledProcessError as e:
print(f"❌ 의존성 설치 실패: {e}")
return False
def setup_environment_file():
"""환경 변수 파일 설정"""
print_step(5, "환경 변수 설정")
env_example = Path(".env.example")
env_file = Path(".env")
if not env_example.exists():
print("❌ .env.example 파일을 찾을 수 없습니다.")
return False
if env_file.exists():
print("⚠️ .env 파일이 이미 존재합니다.")
response = input("기존 .env 파일을 덮어쓰시겠습니까? (y/N): ")
if response.lower() != 'y':
print("기존 .env 파일을 사용합니다.")
return True
try:
# .env.example을 .env로 복사
shutil.copy2(env_example, env_file)
print("✅ .env 파일 생성 완료")
print("\n⚠️ 중요: Gemini API 키를 설정해야 합니다!")
print("1. Google AI Studio에서 API 키를 발급받으세요:")
print(" https://makersuite.google.com/app/apikey")
print("2. .env 파일을 열어 GEMINI_API_KEY를 설정하세요:")
print(" GEMINI_API_KEY=your_actual_api_key_here")
return True
except Exception as e:
print(f"❌ .env 파일 설정 실패: {e}")
return False
def create_directories():
"""필요한 디렉토리 생성"""
print_step(6, "필요한 디렉토리 생성")
directories = ["uploads", "results", "assets", "docs"]
for dir_name in directories:
dir_path = Path(dir_name)
if not dir_path.exists():
dir_path.mkdir(parents=True, exist_ok=True)
print(f"{dir_name}/ 디렉토리 생성")
else:
print(f"{dir_name}/ 디렉토리 이미 존재")
return True
def run_tests():
"""테스트 실행"""
print_step(7, "설치 확인 테스트")
test_script = Path("test_project.py")
if not test_script.exists():
print("❌ 테스트 스크립트를 찾을 수 없습니다.")
return False
try:
print("설치 상태를 확인하는 테스트를 실행합니다...")
result = subprocess.run([sys.executable, "test_project.py"],
capture_output=True, text=True)
print(result.stdout)
if result.stderr:
print("오류:", result.stderr)
return result.returncode == 0
except Exception as e:
print(f"❌ 테스트 실행 실패: {e}")
return False
def print_final_instructions():
"""최종 안내사항 출력"""
print_header("설치 완료!")
print("🎉 PDF 도면 분석기 설치가 완료되었습니다!")
print("\n다음 단계:")
print("1. .env 파일을 열어 Gemini API 키를 설정하세요")
print("2. 가상 환경을 활성화하세요 (사용하는 경우)")
print("3. 애플리케이션을 실행하세요:")
print(" python main.py")
print("\n📚 추가 정보:")
print("- README.md: 사용법 및 문제 해결")
print("- project_plan.md: 프로젝트 계획 및 진행 상황")
print("- test_project.py: 테스트 스크립트")
print("\n🔧 문제가 발생하면:")
print("1. python test_project.py 를 실행하여 문제를 확인하세요")
print("2. .env 파일의 API 키 설정을 확인하세요")
print("3. 가상 환경이 활성화되어 있는지 확인하세요")
def main():
"""메인 설치 함수"""
print_header("PDF 도면 분석기 설치 스크립트")
print("이 스크립트는 PDF 도면 분석기 설치를 도와드립니다.")
print("각 단계를 순차적으로 진행합니다.")
response = input("\n설치를 시작하시겠습니까? (Y/n): ")
if response.lower() == 'n':
print("설치가 취소되었습니다.")
return
# 설치 단계들
steps = [
("Python 버전 확인", check_python_version),
("pip 확인 및 업그레이드", check_pip),
("가상 환경 생성", create_virtual_environment),
("의존성 설치", install_dependencies),
("환경 변수 설정", setup_environment_file),
("디렉토리 생성", create_directories),
("설치 확인 테스트", run_tests)
]
completed = 0
for step_name, step_func in steps:
try:
if step_func():
completed += 1
else:
print(f"\n{step_name} 단계에서 문제가 발생했습니다.")
response = input("계속 진행하시겠습니까? (y/N): ")
if response.lower() != 'y':
break
except KeyboardInterrupt:
print("\n\n설치가 중단되었습니다.")
return
except Exception as e:
print(f"\n{step_name} 단계에서 예상치 못한 오류가 발생했습니다: {e}")
response = input("계속 진행하시겠습니까? (y/N): ")
if response.lower() != 'y':
break
print(f"\n설치 진행률: {completed}/{len(steps)} 단계 완료")
if completed == len(steps):
print_final_instructions()
else:
print("\n⚠️ 설치가 완전히 완료되지 않았습니다.")
print("문제를 해결한 후 다시 실행하거나 수동으로 남은 단계를 진행하세요.")
if __name__ == "__main__":
main()

271
test_cross_tabulated_csv.py Normal file
View File

@@ -0,0 +1,271 @@
"""
Cross-Tabulated CSV 내보내기 기능 테스트 스크립트
Author: Claude Assistant
Created: 2025-07-15
Version: 1.0.0
"""
import os
from datetime import datetime
from dataclasses import dataclass
from typing import Optional, List, Dict, Any
import json
# 프로젝트 모듈 임포트
from cross_tabulated_csv_exporter import CrossTabulatedCSVExporter, generate_cross_tabulated_csv_filename
@dataclass
class MockFileProcessingResult:
"""테스트용 파일 처리 결과 모의 객체"""
file_path: str
file_name: str
file_type: str
file_size: int
processing_time: float
success: bool
error_message: Optional[str] = None
# PDF 분석 결과
pdf_analysis_result: Optional[str] = None
# DXF 분석 결과
dxf_title_blocks: Optional[List[Dict]] = None
dxf_total_attributes: Optional[int] = None
dxf_total_text_entities: Optional[int] = None
# 공통 메타데이터
processed_at: Optional[str] = None
def create_test_pdf_result() -> MockFileProcessingResult:
"""테스트용 PDF 분석 결과 생성"""
# 복잡한 중첩 구조의 JSON 분석 결과 시뮬레이션
analysis_result = {
"도면_정보": {
"제목": "황단면도",
"도면번호": "A-001",
"축척": "1:1000",
"위치": "533, 48" # 좌표 정보 포함
},
"건설_정보": {
"건설단계": "실시설계",
"건설분야": "토목",
"사업명": "봉담~송산 고속도로",
"좌표": "372, 802" # 좌표 정보 포함
},
"도면_속성": [
{"속성명": "작성자", "": "김상균", "위치": "150, 200"},
{"속성명": "작성일", "": "2016.10", "위치": "250, 200"},
{"속성명": "승인", "": "실시설계 승인신청", "위치": "350, 200"}
],
"메타데이터": {
"분석_시간": datetime.now().isoformat(),
"신뢰도": 0.95,
"처리_상태": "완료"
}
}
return MockFileProcessingResult(
file_path="/test/황단면도.pdf",
file_name="황단면도.pdf",
file_type="PDF",
file_size=2048000, # 2MB
processing_time=5.2,
success=True,
pdf_analysis_result=json.dumps(analysis_result, ensure_ascii=False),
processed_at=datetime.now().isoformat()
)
def create_test_dxf_result() -> MockFileProcessingResult:
"""테스트용 DXF 분석 결과 생성"""
title_blocks = [
{
'block_name': 'TITLE_BLOCK',
'block_position': '0.00, 0.00',
'attributes_count': 5,
'attributes': [
{
'tag': 'DWG_TITLE',
'text': '평면도',
'prompt': '도면제목',
'insert_x': 100.5,
'insert_y': 50.25
},
{
'tag': 'DWG_NO',
'text': 'B-002',
'prompt': '도면번호',
'insert_x': 200.75,
'insert_y': 50.25
},
{
'tag': 'SCALE',
'text': '1:500',
'prompt': '축척',
'insert_x': 300.0,
'insert_y': 50.25
},
{
'tag': 'DESIGNER',
'text': '이동훈',
'prompt': '설계자',
'insert_x': 400.25,
'insert_y': 50.25
},
{
'tag': 'DATE',
'text': '2025.07.15',
'prompt': '작성일',
'insert_x': 500.5,
'insert_y': 50.25
}
]
}
]
return MockFileProcessingResult(
file_path="/test/평면도.dxf",
file_name="평면도.dxf",
file_type="DXF",
file_size=1536000, # 1.5MB
processing_time=3.8,
success=True,
dxf_title_blocks=title_blocks,
dxf_total_attributes=5,
dxf_total_text_entities=25,
processed_at=datetime.now().isoformat()
)
def create_test_failed_result() -> MockFileProcessingResult:
"""테스트용 실패 결과 생성"""
return MockFileProcessingResult(
file_path="/test/손상된파일.pdf",
file_name="손상된파일.pdf",
file_type="PDF",
file_size=512000, # 0.5MB
processing_time=1.0,
success=False,
error_message="파일이 손상되어 분석할 수 없습니다",
processed_at=datetime.now().isoformat()
)
def test_cross_tabulated_csv_export():
"""Cross-tabulated CSV 내보내기 기능 테스트"""
print("Cross-Tabulated CSV 내보내기 기능 테스트 시작")
print("=" * 60)
# 테스트 데이터 생성
test_results = [
create_test_pdf_result(),
create_test_dxf_result(),
create_test_failed_result() # 실패 케이스도 포함
]
print(f"테스트 데이터: {len(test_results)}개 파일")
for i, result in enumerate(test_results, 1):
status = "성공" if result.success else "실패"
print(f" {i}. {result.file_name} ({result.file_type}) - {status}")
# 출력 디렉토리 생성
output_dir = os.path.join(os.getcwd(), "test_results")
os.makedirs(output_dir, exist_ok=True)
# CrossTabulatedCSVExporter 인스턴스 생성
exporter = CrossTabulatedCSVExporter()
# 테스트 1: 기본 내보내기 (좌표 포함)
print("\n테스트 1: 기본 내보내기 (좌표 포함)")
output_file_1 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_basic"))
success_1 = exporter.export_cross_tabulated_csv(
test_results,
output_file_1,
include_coordinates=True,
coordinate_source="auto"
)
if success_1:
print(f"성공: {output_file_1}")
print(f"파일 크기: {os.path.getsize(output_file_1)} bytes")
else:
print("실패")
# 테스트 2: 좌표 제외 내보내기
print("\n테스트 2: 좌표 제외 내보내기")
output_file_2 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_no_coords"))
success_2 = exporter.export_cross_tabulated_csv(
test_results,
output_file_2,
include_coordinates=False,
coordinate_source="none"
)
if success_2:
print(f"성공: {output_file_2}")
print(f"파일 크기: {os.path.getsize(output_file_2)} bytes")
else:
print("실패")
# 테스트 3: 성공한 파일만 내보내기
print("\n테스트 3: 성공한 파일만 내보내기")
success_only_results = [r for r in test_results if r.success]
output_file_3 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_success_only"))
success_3 = exporter.export_cross_tabulated_csv(
success_only_results,
output_file_3,
include_coordinates=True,
coordinate_source="auto"
)
if success_3:
print(f"성공: {output_file_3}")
print(f"파일 크기: {os.path.getsize(output_file_3)} bytes")
else:
print("실패")
# 결과 요약
print("\n" + "=" * 60)
print("테스트 결과 요약:")
test_results_summary = [
("기본 내보내기 (좌표 포함)", success_1),
("좌표 제외 내보내기", success_2),
("성공한 파일만 내보내기", success_3)
]
for test_name, success in test_results_summary:
status = "통과" if success else "실패"
print(f" - {test_name}: {status}")
total_success = sum(1 for _, success in test_results_summary if success)
print(f"\n전체 테스트 결과: {total_success}/{len(test_results_summary)} 통과")
if total_success == len(test_results_summary):
print("모든 테스트가 성공적으로 완료되었습니다!")
else:
print("일부 테스트가 실패했습니다. 로그를 확인해주세요.")
# 생성된 파일 목록 표시
print(f"\n생성된 테스트 파일들 ({output_dir}):")
if os.path.exists(output_dir):
for file in os.listdir(output_dir):
if file.endswith('.csv'):
file_path = os.path.join(output_dir, file)
file_size = os.path.getsize(file_path)
print(f" - {file} ({file_size} bytes)")
print("\n생성된 CSV 파일을 열어서 key-value 형태로 데이터가 올바르게 저장되었는지 확인해보세요.")
if __name__ == "__main__":
test_cross_tabulated_csv_export()

View File

@@ -0,0 +1,352 @@
"""
Cross-Tabulated CSV 내보내기 기능 테스트 스크립트 (수정 버전)
Author: Claude Assistant
Created: 2025-07-15
Updated: 2025-07-16 (디버깅 개선 버전)
Version: 1.1.0
"""
import os
from datetime import datetime
from dataclasses import dataclass
from typing import Optional, List, Dict, Any
import json
# 수정된 프로젝트 모듈 임포트
from cross_tabulated_csv_exporter_fixed import CrossTabulatedCSVExporter, generate_cross_tabulated_csv_filename
@dataclass
class MockFileProcessingResult:
"""테스트용 파일 처리 결과 모의 객체 (real structure)"""
file_path: str
file_name: str
file_type: str
file_size: int
processing_time: float
success: bool
error_message: Optional[str] = None
# PDF 분석 결과
pdf_analysis_result: Optional[str] = None
# DXF 분석 결과
dxf_title_blocks: Optional[List[Dict]] = None
dxf_total_attributes: Optional[int] = None
dxf_total_text_entities: Optional[int] = None
# 공통 메타데이터
processed_at: Optional[str] = None
def create_test_pdf_result() -> MockFileProcessingResult:
"""테스트용 PDF 분석 결과 생성"""
# 복잡한 중첩 구조의 JSON 분석 결과 시뮬레이션
analysis_result = {
"도면_정보": {
"제목": "황단면도",
"도면번호": "A-001",
"축척": "1:1000",
"위치": "533, 48" # 좌표 정보 포함
},
"건설_정보": {
"건설단계": "실시설계",
"건설분야": "토목",
"사업명": "봉담~송산 고속도로",
"좌표": "372, 802" # 좌표 정보 포함
},
"도면_속성": [
{"속성명": "작성자", "": "김상균", "위치": "150, 200"},
{"속성명": "작성일", "": "2016.10", "위치": "250, 200"},
{"속성명": "승인", "": "실시설계 승인신청", "위치": "350, 200"}
],
"메타데이터": {
"분석_시간": datetime.now().isoformat(),
"신뢰도": 0.95,
"처리_상태": "완료"
}
}
return MockFileProcessingResult(
file_path="/test/황단면도.pdf",
file_name="황단면도.pdf",
file_type="PDF",
file_size=2048000, # 2MB
processing_time=5.2,
success=True,
pdf_analysis_result=json.dumps(analysis_result, ensure_ascii=False),
processed_at=datetime.now().isoformat()
)
def create_test_dxf_result() -> MockFileProcessingResult:
"""테스트용 DXF 분석 결과 생성"""
title_blocks = [
{
'block_name': 'TITLE_BLOCK',
'block_position': '0.00, 0.00',
'attributes_count': 5,
'attributes': [
{
'tag': 'DWG_TITLE',
'text': '평면도',
'prompt': '도면제목',
'insert_x': 100.5,
'insert_y': 50.25
},
{
'tag': 'DWG_NO',
'text': 'B-002',
'prompt': '도면번호',
'insert_x': 200.75,
'insert_y': 50.25
},
{
'tag': 'SCALE',
'text': '1:500',
'prompt': '축척',
'insert_x': 300.0,
'insert_y': 50.25
},
{
'tag': 'DESIGNER',
'text': '이동훈',
'prompt': '설계자',
'insert_x': 400.25,
'insert_y': 50.25
},
{
'tag': 'DATE',
'text': '2025.07.16',
'prompt': '작성일',
'insert_x': 500.5,
'insert_y': 50.25
}
]
}
]
return MockFileProcessingResult(
file_path="/test/평면도.dxf",
file_name="평면도.dxf",
file_type="DXF",
file_size=1536000, # 1.5MB
processing_time=3.8,
success=True,
dxf_title_blocks=title_blocks,
dxf_total_attributes=5,
dxf_total_text_entities=25,
processed_at=datetime.now().isoformat()
)
def create_test_failed_result() -> MockFileProcessingResult:
"""테스트용 실패 결과 생성"""
return MockFileProcessingResult(
file_path="/test/손상된파일.pdf",
file_name="손상된파일.pdf",
file_type="PDF",
file_size=512000, # 0.5MB
processing_time=1.0,
success=False,
error_message="파일이 손상되어 분석할 수 없습니다",
processed_at=datetime.now().isoformat()
)
def create_test_empty_pdf_result() -> MockFileProcessingResult:
"""테스트용 빈 PDF 분석 결과 생성"""
return MockFileProcessingResult(
file_path="/test/빈PDF.pdf",
file_name="빈PDF.pdf",
file_type="PDF",
file_size=100000, # 0.1MB
processing_time=2.0,
success=True,
pdf_analysis_result=None, # 빈 분석 결과
processed_at=datetime.now().isoformat()
)
def create_test_empty_dxf_result() -> MockFileProcessingResult:
"""테스트용 빈 DXF 분석 결과 생성"""
return MockFileProcessingResult(
file_path="/test/빈DXF.dxf",
file_name="빈DXF.dxf",
file_type="DXF",
file_size=50000, # 0.05MB
processing_time=1.5,
success=True,
dxf_title_blocks=None, # 빈 타이틀블록
dxf_total_attributes=0,
dxf_total_text_entities=0,
processed_at=datetime.now().isoformat()
)
def test_cross_tabulated_csv_export_fixed():
"""Cross-tabulated CSV 내보내기 기능 테스트 (수정 버전)"""
print("Cross-Tabulated CSV 내보내기 기능 테스트 시작 (수정 버전)")
print("=" * 70)
# 테스트 데이터 생성 (다양한 케이스 포함)
test_results = [
create_test_pdf_result(), # 정상 PDF
create_test_dxf_result(), # 정상 DXF
create_test_failed_result(), # 실패 케이스
create_test_empty_pdf_result(), # 빈 PDF 분석 결과
create_test_empty_dxf_result(), # 빈 DXF 분석 결과
]
print(f"테스트 데이터: {len(test_results)}개 파일")
for i, result in enumerate(test_results, 1):
status = "성공" if result.success else "실패"
has_data = ""
if result.success:
if result.file_type.lower() == 'pdf':
has_data = f" (분석결과: {'있음' if result.pdf_analysis_result else '없음'})"
elif result.file_type.lower() == 'dxf':
has_data = f" (타이틀블록: {'있음' if result.dxf_title_blocks else '없음'})"
print(f" {i}. {result.file_name} ({result.file_type}) - {status}{has_data}")
# 출력 디렉토리 생성
output_dir = os.path.join(os.getcwd(), "test_results_fixed")
os.makedirs(output_dir, exist_ok=True)
# CrossTabulatedCSVExporter 인스턴스 생성 (수정 버전)
exporter = CrossTabulatedCSVExporter()
# 테스트 1: 모든 데이터 (빈 데이터 포함) 내보내기
print("\n테스트 1: 모든 데이터 내보내기 (빈 데이터 포함)")
output_file_1 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_all_data"))
success_1 = exporter.export_cross_tabulated_csv(
test_results,
output_file_1,
include_coordinates=True,
coordinate_source="auto"
)
if success_1:
print(f"성공: {output_file_1}")
print(f"파일 크기: {os.path.getsize(output_file_1)} bytes")
else:
print("실패 - 이것은 예상된 결과일 수 있습니다 (빈 데이터가 많음)")
# 테스트 2: 유효한 데이터만 내보내기 (성공하고 데이터가 있는 것만)
print("\n테스트 2: 유효한 데이터만 내보내기")
valid_results = []
for result in test_results:
if result.success:
if (result.file_type.lower() == 'pdf' and result.pdf_analysis_result) or \
(result.file_type.lower() == 'dxf' and result.dxf_title_blocks):
valid_results.append(result)
print(f"유효한 결과: {len(valid_results)}")
output_file_2 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_valid_only"))
success_2 = exporter.export_cross_tabulated_csv(
valid_results,
output_file_2,
include_coordinates=True,
coordinate_source="auto"
)
if success_2:
print(f"성공: {output_file_2}")
print(f"파일 크기: {os.path.getsize(output_file_2)} bytes")
else:
print("실패")
# 테스트 3: 좌표 제외 내보내기
print("\n테스트 3: 좌표 제외 내보내기")
output_file_3 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_no_coords"))
success_3 = exporter.export_cross_tabulated_csv(
valid_results,
output_file_3,
include_coordinates=False,
coordinate_source="none"
)
if success_3:
print(f"성공: {output_file_3}")
print(f"파일 크기: {os.path.getsize(output_file_3)} bytes")
else:
print("실패")
# 테스트 4: 빈 리스트 테스트
print("\n테스트 4: 빈 리스트 테스트")
output_file_4 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_empty_list"))
success_4 = exporter.export_cross_tabulated_csv(
[], # 빈 리스트
output_file_4,
include_coordinates=True,
coordinate_source="auto"
)
if success_4:
print(f"성공: {output_file_4}")
else:
print("실패 - 예상된 결과 (빈 리스트)")
# 결과 요약
print("\n" + "=" * 70)
print("테스트 결과 요약:")
test_results_summary = [
("모든 데이터 내보내기", success_1),
("유효한 데이터만 내보내기", success_2),
("좌표 제외 내보내기", success_3),
("빈 리스트 테스트", success_4)
]
for test_name, success in test_results_summary:
status = "통과" if success else "실패"
print(f" - {test_name}: {status}")
total_success = sum(1 for _, success in test_results_summary if success)
print(f"\n전체 테스트 결과: {total_success}/{len(test_results_summary)} 통과")
# 생성된 파일 목록 표시
print(f"\n생성된 테스트 파일들 ({output_dir}):")
if os.path.exists(output_dir):
for file in os.listdir(output_dir):
if file.endswith('.csv'):
file_path = os.path.join(output_dir, file)
file_size = os.path.getsize(file_path)
print(f" - {file} ({file_size} bytes)")
# 성공한 CSV 파일 중 하나 미리보기
successful_files = [f for f in [output_file_1, output_file_2, output_file_3]
if os.path.exists(f) and os.path.getsize(f) > 0]
if successful_files:
preview_file = successful_files[0]
print(f"\n미리보기 ({os.path.basename(preview_file)}):")
print("-" * 50)
try:
with open(preview_file, 'r', encoding='utf-8-sig') as f:
lines = f.readlines()
for i, line in enumerate(lines[:10]): # 처음 10줄만 표시
print(f"{i+1:2d}: {line.rstrip()}")
if len(lines) > 10:
print(f"... (총 {len(lines)}줄)")
except Exception as e:
print(f"미리보기 오류: {e}")
print("\n생성된 CSV 파일을 열어서 key-value 형태로 데이터가 올바르게 저장되었는지 확인해보세요.")
print("수정된 버전에서는 더 자세한 디버깅 정보가 로그에 출력됩니다.")
if __name__ == "__main__":
test_cross_tabulated_csv_export_fixed()

243
test_key_integration.py Normal file
View File

@@ -0,0 +1,243 @@
"""
Cross-Tabulated CSV 내보내기 개선 테스트 스크립트
키 통합 기능 테스트
Author: Claude Assistant
Created: 2025-07-16
Version: 1.0.0
"""
import sys
import os
from datetime import datetime
import json
# 프로젝트 루트 디렉토리를 Python 경로에 추가
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from cross_tabulated_csv_exporter import CrossTabulatedCSVExporter
class MockResult:
"""테스트용 모의 결과 객체"""
def __init__(self, file_name, file_type, success=True, **kwargs):
self.file_name = file_name
self.file_type = file_type
self.success = success
# PDF 관련 속성
if 'pdf_analysis_result' in kwargs:
self.pdf_analysis_result = kwargs['pdf_analysis_result']
# DXF 관련 속성
if 'dxf_title_blocks' in kwargs:
self.dxf_title_blocks = kwargs['dxf_title_blocks']
# 오류 메시지
if 'error_message' in kwargs:
self.error_message = kwargs['error_message']
def test_key_integration():
"""키 통합 기능 테스트"""
print("=== Cross-Tabulated CSV 키 통합 기능 테스트 ===\n")
# 테스트용 CSV 내보내기 객체 생성
exporter = CrossTabulatedCSVExporter()
# 테스트 케이스 1: PDF 분석 결과 (키 분리 형태)
print("테스트 케이스 1: PDF 분석 결과 (키 분리 → 통합)")
pdf_analysis_result = {
"사업명_value": "고속국도 제30호선 대산~당진 고속도로 건설공사",
"사업명_x": "40",
"사업명_y": "130",
"시설_공구_value": "제2공구 : 온산~사성",
"시설_공구_x": "41",
"시설_공구_y": "139",
"건설분야_value": "토목",
"건설분야_x": "199",
"건설분야_y": "1069",
"건설단계_value": "실시설계",
"건설단계_x": "263",
"건설단계_y": "1069",
"계절자수_value": "1",
"계절자수_x": "309",
"계절자수_y": "1066",
"작성일자_value": "2023. 03",
"작성일자_x": "332",
"작성일자_y": "1069"
}
mock_pdf_result = MockResult(
"황단면도.pdf",
"PDF",
success=True,
pdf_analysis_result=json.dumps(pdf_analysis_result)
)
# 테스트 케이스 2: DXF 분석 결과
print("테스트 케이스 2: DXF 분석 결과")
dxf_title_blocks = [
{
"block_name": "TITLE_BLOCK",
"attributes": [
{
"tag": "DWG_NO",
"text": "A-001",
"insert_x": 150,
"insert_y": 25
},
{
"tag": "TITLE",
"text": "도면제목",
"insert_x": 200,
"insert_y": 50
},
{
"tag": "SCALE",
"text": "1:100",
"insert_x": 250,
"insert_y": 75
}
]
}
]
mock_dxf_result = MockResult(
"도면001.dxf",
"DXF",
success=True,
dxf_title_blocks=dxf_title_blocks
)
# 테스트 케이스 3: 실패한 결과
mock_failed_result = MockResult(
"오류파일.pdf",
"PDF",
success=False,
error_message="분석 실패"
)
# 모든 테스트 결과를 리스트로 구성
test_results = [mock_pdf_result, mock_dxf_result, mock_failed_result]
# 출력 디렉토리 생성
output_dir = "test_results_integrated"
os.makedirs(output_dir, exist_ok=True)
# 테스트 실행: 통합된 CSV 저장
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = os.path.join(output_dir, f"integrated_test_results_{timestamp}.csv")
print(f"CSV 저장 위치: {output_path}")
success = exporter.export_cross_tabulated_csv(
test_results,
output_path,
include_coordinates=True,
coordinate_source="auto"
)
if success:
print(f"✅ 테스트 성공: 통합된 CSV 파일이 저장되었습니다.")
print(f"📁 파일 위치: {output_path}")
# 저장된 CSV 파일 내용 미리보기
try:
with open(output_path, 'r', encoding='utf-8-sig') as f:
lines = f.readlines()
print(f"\n📋 CSV 파일 미리보기 (상위 10행):")
for i, line in enumerate(lines[:10]):
print(f"{i+1:2d}: {line.rstrip()}")
if len(lines) > 10:
print(f" ... 총 {len(lines)}")
except Exception as e:
print(f"⚠️ 파일 읽기 오류: {e}")
else:
print("❌ 테스트 실패: CSV 저장에 실패했습니다.")
return success
def test_key_extraction():
"""키 추출 및 그룹화 기능 단위 테스트"""
print("\n=== 키 추출 및 그룹화 단위 테스트 ===\n")
exporter = CrossTabulatedCSVExporter()
# 테스트 케이스: 다양한 키 형태
test_keys = [
"사업명_value",
"사업명_x",
"사업명_y",
"시설_공구_val",
"시설_공구_x_coord",
"시설_공구_y_coord",
"건설분야_text",
"건설분야_left",
"건설분야_top",
"일반키",
"축척_content",
"축척_x_position",
"축척_y_position"
]
print("🔍 키 분석 결과:")
for key in test_keys:
base_key = exporter._extract_base_key(key)
key_type = exporter._determine_key_type(key)
print(f" {key:20s} → 기본키: '{base_key:12s}' 타입: {key_type}")
print("\n✅ 키 추출 및 그룹화 단위 테스트 완료")
def test_expected_output():
"""예상 출력 형태 테스트"""
print("\n=== 예상 출력 형태 확인 ===\n")
print("📊 기대하는 CSV 출력 형태:")
print("file_name,file_type,key,value,x,y")
print("황단면도.pdf,PDF,사업명,고속국도 제30호선 대산~당진 고속도로 건설공사,40,130")
print("황단면도.pdf,PDF,시설_공구,제2공구 : 온산~사성,41,139")
print("황단면도.pdf,PDF,건설분야,토목,199,1069")
print("황단면도.pdf,PDF,건설단계,실시설계,263,1069")
print("도면001.dxf,DXF,DWG_NO,A-001,150,25")
print("도면001.dxf,DXF,TITLE,도면제목,200,50")
print("도면001.dxf,DXF,SCALE,1:100,250,75")
print("\n🎯 개선사항:")
print("- 기존: 사업명_value, 사업명_x, 사업명_y가 별도 행으로 분리")
print("- 개선: 사업명 하나의 행에 value, x, y 정보 통합")
if __name__ == "__main__":
try:
print("Cross-Tabulated CSV 키 통합 기능 테스트 시작\n")
# 1. 키 추출 및 그룹화 단위 테스트
test_key_extraction()
# 2. 예상 출력 형태 확인
test_expected_output()
# 3. 실제 통합 기능 테스트
success = test_key_integration()
print(f"\n{'='*60}")
if success:
print("🎉 모든 테스트가 성공적으로 완료되었습니다!")
print("✨ 키 통합 기능이 정상적으로 동작합니다.")
else:
print("😞 테스트 중 일부가 실패했습니다.")
print("🔧 코드를 점검해주세요.")
print(f"{'='*60}")
except Exception as e:
print(f"❌ 테스트 실행 중 오류 발생: {e}")
import traceback
traceback.print_exc()

View File

@@ -0,0 +1,218 @@
"""
Cross-Tabulated CSV 내보내기 개선 테스트 스크립트 (Simple 버전)
키 통합 기능 테스트
Author: Claude Assistant
Created: 2025-07-16
Version: 1.0.0
"""
import sys
import os
from datetime import datetime
import json
# 프로젝트 루트 디렉토리를 Python 경로에 추가
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from cross_tabulated_csv_exporter import CrossTabulatedCSVExporter
class MockResult:
"""테스트용 모의 결과 객체"""
def __init__(self, file_name, file_type, success=True, **kwargs):
self.file_name = file_name
self.file_type = file_type
self.success = success
# PDF 관련 속성
if 'pdf_analysis_result' in kwargs:
self.pdf_analysis_result = kwargs['pdf_analysis_result']
# DXF 관련 속성
if 'dxf_title_blocks' in kwargs:
self.dxf_title_blocks = kwargs['dxf_title_blocks']
# 오류 메시지
if 'error_message' in kwargs:
self.error_message = kwargs['error_message']
def test_key_integration():
"""키 통합 기능 테스트"""
print("=== Cross-Tabulated CSV 키 통합 기능 테스트 ===")
# 테스트용 CSV 내보내기 객체 생성
exporter = CrossTabulatedCSVExporter()
# 테스트 케이스 1: PDF 분석 결과 (키 분리 형태)
print("\n테스트 케이스 1: PDF 분석 결과 (키 분리 -> 통합)")
pdf_analysis_result = {
"사업명_value": "고속국도 제30호선 대산~당진 고속도로 건설공사",
"사업명_x": "40",
"사업명_y": "130",
"시설_공구_value": "제2공구 : 온산~사성",
"시설_공구_x": "41",
"시설_공구_y": "139",
"건설분야_value": "토목",
"건설분야_x": "199",
"건설분야_y": "1069",
"건설단계_value": "실시설계",
"건설단계_x": "263",
"건설단계_y": "1069"
}
mock_pdf_result = MockResult(
"황단면도.pdf",
"PDF",
success=True,
pdf_analysis_result=json.dumps(pdf_analysis_result)
)
# 테스트 케이스 2: DXF 분석 결과
print("테스트 케이스 2: DXF 분석 결과")
dxf_title_blocks = [
{
"block_name": "TITLE_BLOCK",
"attributes": [
{
"tag": "DWG_NO",
"text": "A-001",
"insert_x": 150,
"insert_y": 25
},
{
"tag": "TITLE",
"text": "도면제목",
"insert_x": 200,
"insert_y": 50
}
]
}
]
mock_dxf_result = MockResult(
"도면001.dxf",
"DXF",
success=True,
dxf_title_blocks=dxf_title_blocks
)
# 모든 테스트 결과를 리스트로 구성
test_results = [mock_pdf_result, mock_dxf_result]
# 출력 디렉토리 생성
output_dir = "test_results_integrated"
os.makedirs(output_dir, exist_ok=True)
# 테스트 실행: 통합된 CSV 저장
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = os.path.join(output_dir, f"integrated_test_results_{timestamp}.csv")
print(f"\nCSV 저장 위치: {output_path}")
success = exporter.export_cross_tabulated_csv(
test_results,
output_path,
include_coordinates=True,
coordinate_source="auto"
)
if success:
print(f"SUCCESS: 통합된 CSV 파일이 저장되었습니다.")
print(f"파일 위치: {output_path}")
# 저장된 CSV 파일 내용 미리보기
try:
with open(output_path, 'r', encoding='utf-8-sig') as f:
lines = f.readlines()
print(f"\nCSV 파일 미리보기 (상위 10행):")
for i, line in enumerate(lines[:10]):
print(f"{i+1:2d}: {line.rstrip()}")
if len(lines) > 10:
print(f" ... 총 {len(lines)}")
except Exception as e:
print(f"WARNING: 파일 읽기 오류: {e}")
else:
print("ERROR: CSV 저장에 실패했습니다.")
return success
def test_key_extraction():
"""키 추출 및 그룹화 기능 단위 테스트"""
print("\n=== 키 추출 및 그룹화 단위 테스트 ===")
exporter = CrossTabulatedCSVExporter()
# 테스트 케이스: 다양한 키 형태
test_keys = [
"사업명_value",
"사업명_x",
"사업명_y",
"시설_공구_val",
"시설_공구_x_coord",
"시설_공구_y_coord",
"건설분야_text",
"건설분야_left",
"건설분야_top",
"일반키"
]
print("\n키 분석 결과:")
for key in test_keys:
base_key = exporter._extract_base_key(key)
key_type = exporter._determine_key_type(key)
print(f" {key:20s} -> 기본키: '{base_key:12s}' 타입: {key_type}")
print("\n키 추출 및 그룹화 단위 테스트 완료")
def test_expected_output():
"""예상 출력 형태 테스트"""
print("\n=== 예상 출력 형태 확인 ===")
print("\n기대하는 CSV 출력 형태:")
print("file_name,file_type,key,value,x,y")
print("황단면도.pdf,PDF,사업명,고속국도 제30호선 대산~당진 고속도로 건설공사,40,130")
print("황단면도.pdf,PDF,시설_공구,제2공구 : 온산~사성,41,139")
print("황단면도.pdf,PDF,건설분야,토목,199,1069")
print("도면001.dxf,DXF,DWG_NO,A-001,150,25")
print("도면001.dxf,DXF,TITLE,도면제목,200,50")
print("\n개선사항:")
print("- 기존: 사업명_value, 사업명_x, 사업명_y가 별도 행으로 분리")
print("- 개선: 사업명 하나의 행에 value, x, y 정보 통합")
if __name__ == "__main__":
try:
print("Cross-Tabulated CSV 키 통합 기능 테스트 시작")
# 1. 키 추출 및 그룹화 단위 테스트
test_key_extraction()
# 2. 예상 출력 형태 확인
test_expected_output()
# 3. 실제 통합 기능 테스트
success = test_key_integration()
print(f"\n{'='*60}")
if success:
print("SUCCESS: 모든 테스트가 성공적으로 완료되었습니다!")
print("키 통합 기능이 정상적으로 동작합니다.")
else:
print("ERROR: 테스트 중 일부가 실패했습니다.")
print("코드를 점검해주세요.")
print(f"{'='*60}")
except Exception as e:
print(f"ERROR: 테스트 실행 중 오류 발생: {e}")
import traceback
traceback.print_exc()

BIN
testsample/1.pdf Normal file

Binary file not shown.

BIN
testsample/2.pdf Normal file

Binary file not shown.

3826
testsample/test_drawing.dxf Normal file

File diff suppressed because it is too large Load Diff

2398
ui_components.py Normal file

File diff suppressed because it is too large Load Diff

288
utils.py Normal file
View File

@@ -0,0 +1,288 @@
"""
유틸리티 함수 모듈
공통으로 사용되는 유틸리티 함수들을 제공합니다.
"""
import os
import json
import datetime
from pathlib import Path
from typing import Dict, Any, Optional, List
import logging
logger = logging.getLogger(__name__)
class FileUtils:
"""파일 관련 유틸리티 클래스"""
@staticmethod
def ensure_directory_exists(directory_path: str) -> bool:
"""디렉토리가 존재하지 않으면 생성"""
try:
Path(directory_path).mkdir(parents=True, exist_ok=True)
return True
except Exception as e:
logger.error(f"디렉토리 생성 실패 {directory_path}: {e}")
return False
@staticmethod
def get_safe_filename(filename: str) -> str:
"""안전한 파일명 생성 (특수문자 제거)"""
import re
# 특수문자를 언더스코어로 치환
safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename)
# 연속된 언더스코어를 하나로 축약
safe_name = re.sub(r'_+', '_', safe_name)
# 앞뒤 언더스코어 제거
return safe_name.strip('_')
@staticmethod
def get_file_size_mb(file_path: str) -> float:
"""파일 크기를 MB 단위로 반환"""
try:
size_bytes = os.path.getsize(file_path)
return round(size_bytes / (1024 * 1024), 2)
except Exception:
return 0.0
@staticmethod
def save_text_file(file_path: str, content: str, encoding: str = 'utf-8') -> bool:
"""텍스트 파일 저장"""
try:
with open(file_path, 'w', encoding=encoding) as f:
f.write(content)
logger.info(f"텍스트 파일 저장 완료: {file_path}")
return True
except Exception as e:
logger.error(f"텍스트 파일 저장 실패 {file_path}: {e}")
return False
@staticmethod
def save_json_file(file_path: str, data: Dict[str, Any], indent: int = 2) -> bool:
"""JSON 파일 저장"""
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=indent)
logger.info(f"JSON 파일 저장 완료: {file_path}")
return True
except Exception as e:
logger.error(f"JSON 파일 저장 실패 {file_path}: {e}")
return False
class AnalysisResultSaver:
"""분석 결과 저장 클래스"""
def __init__(self, output_dir: str = "results"):
self.output_dir = Path(output_dir)
FileUtils.ensure_directory_exists(str(self.output_dir))
def save_analysis_results(
self,
pdf_filename: str,
analysis_results: Dict[int, str],
pdf_info: Dict[str, Any],
analysis_settings: Dict[str, Any]
) -> Optional[str]:
"""분석 결과를 파일로 저장"""
try:
# 안전한 파일명 생성
safe_filename = FileUtils.get_safe_filename(
Path(pdf_filename).stem
)
# 타임스탬프 추가
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
result_filename = f"{safe_filename}_analysis_{timestamp}.txt"
result_path = self.output_dir / result_filename
# 결과 텍스트 구성
content = self._format_analysis_content(
pdf_filename, analysis_results, pdf_info, analysis_settings
)
# 파일 저장
if FileUtils.save_text_file(str(result_path), content):
return str(result_path)
else:
return None
except Exception as e:
logger.error(f"분석 결과 저장 중 오류: {e}")
return None
def save_analysis_json(
self,
pdf_filename: str,
analysis_results: Dict[int, str],
pdf_info: Dict[str, Any],
analysis_settings: Dict[str, Any]
) -> Optional[str]:
"""분석 결과를 JSON으로 저장"""
try:
safe_filename = FileUtils.get_safe_filename(
Path(pdf_filename).stem
)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
json_filename = f"{safe_filename}_analysis_{timestamp}.json"
json_path = self.output_dir / json_filename
# JSON 데이터 구성
json_data = {
"analysis_info": {
"pdf_filename": pdf_filename,
"analysis_date": datetime.datetime.now().isoformat(),
"total_pages_analyzed": len(analysis_results)
},
"pdf_info": pdf_info,
"analysis_settings": analysis_settings,
"results": {
f"page_{page_num + 1}": result
for page_num, result in analysis_results.items()
}
}
if FileUtils.save_json_file(str(json_path), json_data):
return str(json_path)
else:
return None
except Exception as e:
logger.error(f"JSON 결과 저장 중 오류: {e}")
return None
def _format_analysis_content(
self,
pdf_filename: str,
analysis_results: Dict[int, str],
pdf_info: Dict[str, Any],
analysis_settings: Dict[str, Any]
) -> str:
"""분석 결과를 텍스트 형식으로 포맷팅"""
content = []
# 헤더
content.append("=" * 80)
content.append("PDF 도면 분석 결과 보고서")
content.append("=" * 80)
content.append("")
# 기본 정보
content.append("📄 파일 정보")
content.append("-" * 40)
content.append(f"파일명: {pdf_filename}")
content.append(f"총 페이지 수: {pdf_info.get('page_count', 'N/A')}")
content.append(f"파일 크기: {pdf_info.get('file_size', 'N/A')} bytes")
content.append(f"분석 일시: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
content.append("")
# 분석 설정
content.append("⚙️ 분석 설정")
content.append("-" * 40)
for key, value in analysis_settings.items():
content.append(f"{key}: {value}")
content.append("")
# 분석 결과
content.append("🔍 분석 결과")
content.append("-" * 40)
for page_num, result in analysis_results.items():
content.append(f"\n📋 페이지 {page_num + 1} 분석 결과:")
content.append("=" * 50)
content.append(result)
content.append("")
# 푸터
content.append("=" * 80)
content.append("분석 완료")
content.append("=" * 80)
return "\n".join(content)
class DateTimeUtils:
"""날짜/시간 관련 유틸리티 클래스"""
@staticmethod
def get_timestamp() -> str:
"""현재 타임스탬프 반환 (YYYY-MM-DD HH:MM:SS 형식)"""
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@staticmethod
def get_filename_timestamp() -> str:
"""파일명용 타임스탬프 반환 (YYYYMMDD_HHMMSS 형식)"""
return datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
@staticmethod
def format_duration(seconds: float) -> str:
"""초를 읽기 쉬운 형식으로 변환"""
if seconds < 60:
return f"{seconds:.1f}"
elif seconds < 3600:
minutes = seconds / 60
return f"{minutes:.1f}"
else:
hours = seconds / 3600
return f"{hours:.1f}시간"
class TextUtils:
"""텍스트 처리 유틸리티 클래스"""
@staticmethod
def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
"""텍스트를 지정된 길이로 자르기"""
if len(text) <= max_length:
return text
return text[:max_length - len(suffix)] + suffix
@staticmethod
def clean_text(text: str) -> str:
"""텍스트 정리 (불필요한 공백 제거 등)"""
import re
# 연속된 공백을 하나로 축약
text = re.sub(r'\s+', ' ', text)
# 앞뒤 공백 제거
return text.strip()
@staticmethod
def word_count(text: str) -> int:
"""단어 수 계산"""
return len(text.split())
@staticmethod
def char_count(text: str, exclude_spaces: bool = False) -> int:
"""문자 수 계산"""
if exclude_spaces:
return len(text.replace(' ', ''))
return len(text)
class ValidationUtils:
"""검증 관련 유틸리티 클래스"""
@staticmethod
def is_valid_api_key(api_key: str) -> bool:
"""API 키 형식 검증"""
if not api_key or not isinstance(api_key, str):
return False
# 기본적인 형식 검증 (실제 검증은 API 호출 시 수행)
return len(api_key.strip()) > 10
@staticmethod
def is_valid_file_size(file_size_bytes: int, max_size_mb: int) -> bool:
"""파일 크기 검증"""
max_size_bytes = max_size_mb * 1024 * 1024
return file_size_bytes <= max_size_bytes
@staticmethod
def is_valid_pdf_extension(filename: str) -> bool:
"""PDF 파일 확장자 검증"""
return Path(filename).suffix.lower() == '.pdf'
# 사용 예시
if __name__ == "__main__":
# 파일 유틸리티 테스트
print("유틸리티 함수 테스트:")
print(f"타임스탬프: {DateTimeUtils.get_timestamp()}")
print(f"파일명 타임스탬프: {DateTimeUtils.get_filename_timestamp()}")
print(f"안전한 파일명: {FileUtils.get_safe_filename('test<file>name.pdf')}")
print(f"텍스트 축약: {TextUtils.truncate_text('이것은 긴 텍스트입니다' * 10, 50)}")