1201 lines
46 KiB
Python
1201 lines
46 KiB
Python
"""
|
||
PDF/DXF 도면 분석기 - 통합 애플리케이션 (탭 기반 인터페이스)
|
||
단일 파일 처리와 다중 파일 배치 처리를 탭으로 분리
|
||
|
||
Tab 1: 단일 파일 분석 (기존 기능)
|
||
Tab 2: 다중 파일 배치 처리 (새로운 기능)
|
||
|
||
Author: Claude Assistant
|
||
Updated: 2025-07-14
|
||
Version: 2.0.0
|
||
"""
|
||
|
||
import flet as ft
|
||
import logging
|
||
import threading
|
||
import base64
|
||
from typing import Optional
|
||
import time
|
||
|
||
# 프로젝트 모듈 임포트
|
||
from config import Config
|
||
from pdf_processor import PDFProcessor
|
||
from dxf_processor_fixed import FixedDXFProcessor as DXFProcessor
|
||
from comprehensive_text_extractor import ComprehensiveTextExtractor
|
||
from gemini_analyzer import GeminiAnalyzer
|
||
from ui_components import UIComponents
|
||
from utils import AnalysisResultSaver, DateTimeUtils
|
||
from csv_exporter import TitleBlockCSVExporter
|
||
from multi_file_main import MultiFileApp
|
||
|
||
# 로깅 설정
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class SingleFileAnalyzerApp:
|
||
"""단일 파일 분석기 애플리케이션 클래스 (기존 기능)"""
|
||
|
||
def __init__(self, page: ft.Page):
|
||
self.page = page
|
||
self.pdf_processor = PDFProcessor()
|
||
self.dxf_processor = DXFProcessor()
|
||
self.text_extractor = ComprehensiveTextExtractor()
|
||
self.csv_exporter = TitleBlockCSVExporter()
|
||
self.gemini_analyzer = None
|
||
self.current_file_path = None
|
||
self.current_file_type = None
|
||
self.current_pdf_info = None
|
||
self.current_title_block_info = None
|
||
self.current_text_extraction_result = None
|
||
self.analysis_results = {}
|
||
self.result_saver = AnalysisResultSaver("results")
|
||
self.analysis_start_time = None
|
||
self.current_page_index = 0
|
||
|
||
# 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_text_button = None
|
||
self.save_json_button = None
|
||
self.save_csv_button = None
|
||
self.title_block_table = None
|
||
self.comprehensive_text_display = None
|
||
self.organization_selector = None
|
||
self.page_selector = None
|
||
self.analysis_mode = None
|
||
self.custom_prompt = None
|
||
self.pdf_viewer_dialog = None
|
||
self.pdf_preview_button = None
|
||
|
||
# 초기화
|
||
self.init_gemini_analyzer()
|
||
|
||
def init_gemini_analyzer(self):
|
||
"""Gemini 분석기 초기화"""
|
||
try:
|
||
config_errors = Config.validate_config()
|
||
if config_errors:
|
||
logger.error(f"설정 오류: {config_errors}")
|
||
return
|
||
|
||
self.gemini_analyzer = GeminiAnalyzer()
|
||
logger.info("Gemini 분석기 초기화 완료")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Gemini 분석기 초기화 실패: {e}")
|
||
|
||
def build_ui(self) -> ft.Column:
|
||
"""단일 파일 분석 UI 구성 (기존 좌우 분할 레이아웃)"""
|
||
|
||
# 좌측 컨트롤 패널 (4/12 columns)
|
||
left_panel = self.create_left_control_panel()
|
||
|
||
# 우측 결과 패널 (8/12 columns)
|
||
right_panel = self.create_right_results_panel()
|
||
|
||
# ResponsiveRow를 사용한 좌우 분할 레이아웃
|
||
main_layout = ft.ResponsiveRow([
|
||
ft.Container(
|
||
content=left_panel,
|
||
col={"sm": 12, "md": 5, "lg": 4},
|
||
padding=10,
|
||
),
|
||
ft.Container(
|
||
content=right_panel,
|
||
col={"sm": 12, "md": 7, "lg": 8},
|
||
padding=10,
|
||
),
|
||
])
|
||
|
||
# PDF 뷰어 다이얼로그 초기화
|
||
self.init_pdf_viewer_dialog()
|
||
|
||
return ft.Column([
|
||
main_layout
|
||
], expand=True, scroll=ft.ScrollMode.AUTO)
|
||
|
||
def create_left_control_panel(self) -> ft.Column:
|
||
"""좌측 컨트롤 패널 생성"""
|
||
|
||
# 파일 업로드 섹션
|
||
upload_section = self.create_file_upload_section()
|
||
|
||
# 분석 설정 섹션
|
||
settings_section = self.create_analysis_settings_section()
|
||
|
||
# 진행률 섹션
|
||
progress_section = self.create_progress_section()
|
||
|
||
# 분석 시작 버튼 (크게)
|
||
start_analysis_button = ft.Container(
|
||
content=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,
|
||
),
|
||
width=300,
|
||
height=50,
|
||
),
|
||
alignment=ft.alignment.center,
|
||
margin=ft.margin.symmetric(vertical=10),
|
||
)
|
||
self.upload_button = start_analysis_button.content
|
||
|
||
# PDF 미리보기 버튼
|
||
preview_button = ft.Container(
|
||
content=ft.ElevatedButton(
|
||
text="📄 PDF 미리보기",
|
||
icon=ft.Icons.VISIBILITY,
|
||
on_click=self.on_pdf_preview_click,
|
||
disabled=True,
|
||
style=ft.ButtonStyle(
|
||
bgcolor=ft.Colors.BLUE_100,
|
||
color=ft.Colors.BLUE_800,
|
||
),
|
||
width=300,
|
||
height=40,
|
||
),
|
||
alignment=ft.alignment.center,
|
||
margin=ft.margin.symmetric(vertical=5),
|
||
)
|
||
self.pdf_preview_button = preview_button.content
|
||
|
||
return ft.Column([
|
||
upload_section,
|
||
ft.Divider(height=20),
|
||
settings_section,
|
||
ft.Divider(height=20),
|
||
progress_section,
|
||
ft.Divider(height=20),
|
||
start_analysis_button,
|
||
preview_button,
|
||
], expand=True, scroll=ft.ScrollMode.AUTO)
|
||
|
||
def create_right_results_panel(self) -> ft.Column:
|
||
"""우측 결과 패널 생성"""
|
||
|
||
# 결과 텍스트
|
||
self.results_text = ft.Text(
|
||
"분석 결과가 여기에 표시됩니다.\n\n좌측에서 PDF/DXF 파일을 선택하고 분석을 시작하세요.",
|
||
size=14,
|
||
selectable=True,
|
||
)
|
||
|
||
# 결과 컨테이너
|
||
self.results_container = ft.Container(
|
||
content=ft.Column([
|
||
self.results_text,
|
||
], scroll=ft.ScrollMode.AUTO),
|
||
padding=20,
|
||
bgcolor=ft.Colors.GREY_50,
|
||
border_radius=10,
|
||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||
expand=True,
|
||
)
|
||
|
||
# 저장 버튼들
|
||
self.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,
|
||
)
|
||
)
|
||
|
||
self.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,
|
||
)
|
||
)
|
||
|
||
# CSV 저장 버튼 (DXF 전용)
|
||
self.save_csv_button = ft.ElevatedButton(
|
||
text="📊 CSV 저장",
|
||
icon=ft.Icons.TABLE_CHART,
|
||
disabled=True,
|
||
visible=False,
|
||
on_click=self.on_save_csv_click,
|
||
style=ft.ButtonStyle(
|
||
bgcolor=ft.Colors.ORANGE_100,
|
||
color=ft.Colors.ORANGE_800,
|
||
)
|
||
)
|
||
|
||
# 헤더와 버튼들
|
||
header_row = ft.Row([
|
||
ft.Text(
|
||
"📋 분석 결과",
|
||
size=20,
|
||
weight=ft.FontWeight.BOLD,
|
||
color=ft.Colors.GREEN_800
|
||
),
|
||
ft.Row([
|
||
self.save_text_button,
|
||
self.save_json_button,
|
||
self.save_csv_button,
|
||
]),
|
||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN)
|
||
|
||
return ft.Column([
|
||
ft.Container(
|
||
content=ft.Column([
|
||
header_row,
|
||
ft.Divider(),
|
||
self.results_container,
|
||
]),
|
||
padding=20,
|
||
bgcolor=ft.Colors.WHITE,
|
||
border_radius=10,
|
||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||
expand=True,
|
||
)
|
||
], expand=True)
|
||
|
||
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=12,
|
||
color=ft.Colors.GREY_600
|
||
)
|
||
|
||
# 파일 선택 버튼
|
||
select_button = ft.ElevatedButton(
|
||
text="📁 PDF/DXF 파일 선택",
|
||
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,
|
||
),
|
||
width=280,
|
||
)
|
||
|
||
return ft.Container(
|
||
content=ft.Column([
|
||
ft.Text(
|
||
"📄 PDF/DXF 파일 업로드",
|
||
size=16,
|
||
weight=ft.FontWeight.BOLD,
|
||
color=ft.Colors.BLUE_800
|
||
),
|
||
ft.Divider(),
|
||
select_button,
|
||
self.selected_file_text,
|
||
]),
|
||
padding=15,
|
||
bgcolor=ft.Colors.WHITE,
|
||
border_radius=8,
|
||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||
)
|
||
|
||
def create_analysis_settings_section(self) -> ft.Container:
|
||
"""분석 설정 섹션 생성"""
|
||
|
||
# 조직 선택
|
||
self.organization_selector = ft.Dropdown(
|
||
label="분석 스키마",
|
||
options=[
|
||
ft.dropdown.Option("국토교통부", "국토교통부 - 일반 건설/토목 도면"),
|
||
ft.dropdown.Option("한국도로공사", "한국도로공사 - 고속도로 전용 도면"),
|
||
],
|
||
value="국토교통부",
|
||
width=280,
|
||
on_change=self.on_organization_change,
|
||
)
|
||
|
||
# 페이지 선택
|
||
self.page_selector = ft.Dropdown(
|
||
label="분석할 페이지",
|
||
options=[
|
||
ft.dropdown.Option("첫 번째 페이지"),
|
||
ft.dropdown.Option("모든 페이지"),
|
||
],
|
||
value="첫 번째 페이지",
|
||
width=280,
|
||
)
|
||
|
||
# 분석 모드
|
||
self.analysis_mode = ft.Dropdown(
|
||
label="분석 모드",
|
||
options=[
|
||
ft.dropdown.Option("basic", "기본 분석"),
|
||
ft.dropdown.Option("detailed", "상세 분석"),
|
||
ft.dropdown.Option("custom", "사용자 정의"),
|
||
],
|
||
value="basic",
|
||
width=280,
|
||
on_change=self.on_analysis_mode_change,
|
||
)
|
||
|
||
# 사용자 정의 프롬프트
|
||
self.custom_prompt = ft.TextField(
|
||
label="사용자 정의 프롬프트",
|
||
multiline=True,
|
||
min_lines=3,
|
||
max_lines=5,
|
||
width=280,
|
||
visible=False,
|
||
)
|
||
|
||
return ft.Container(
|
||
content=ft.Column([
|
||
ft.Text(
|
||
"⚙️ 분석 설정",
|
||
size=16,
|
||
weight=ft.FontWeight.BOLD,
|
||
color=ft.Colors.PURPLE_800
|
||
),
|
||
ft.Divider(),
|
||
self.organization_selector,
|
||
self.page_selector,
|
||
self.analysis_mode,
|
||
self.custom_prompt,
|
||
]),
|
||
padding=15,
|
||
bgcolor=ft.Colors.WHITE,
|
||
border_radius=8,
|
||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||
)
|
||
|
||
def create_progress_section(self) -> ft.Container:
|
||
"""진행률 섹션 생성"""
|
||
|
||
# 진행률 바
|
||
self.progress_bar = ft.ProgressBar(
|
||
width=280,
|
||
color=ft.Colors.BLUE_600,
|
||
bgcolor=ft.Colors.GREY_300,
|
||
visible=False,
|
||
)
|
||
|
||
# 상태 텍스트
|
||
self.status_text = ft.Text(
|
||
"대기 중...",
|
||
size=12,
|
||
color=ft.Colors.GREY_600
|
||
)
|
||
|
||
# 진행률 링
|
||
self.progress_ring = ft.ProgressRing(
|
||
width=30,
|
||
height=30,
|
||
stroke_width=3,
|
||
visible=False,
|
||
)
|
||
|
||
return ft.Container(
|
||
content=ft.Column([
|
||
ft.Text(
|
||
"📊 분석 진행 상황",
|
||
size=16,
|
||
weight=ft.FontWeight.BOLD,
|
||
color=ft.Colors.ORANGE_800
|
||
),
|
||
ft.Divider(),
|
||
ft.Row([
|
||
self.progress_ring,
|
||
ft.Column([
|
||
self.status_text,
|
||
self.progress_bar,
|
||
], expand=1),
|
||
], alignment=ft.MainAxisAlignment.START),
|
||
]),
|
||
padding=15,
|
||
bgcolor=ft.Colors.WHITE,
|
||
border_radius=8,
|
||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||
)
|
||
|
||
def init_pdf_viewer_dialog(self):
|
||
"""PDF 뷰어 다이얼로그 초기화"""
|
||
|
||
# PDF 이미지 컨테이너
|
||
self.pdf_image_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=600,
|
||
height=700,
|
||
bgcolor=ft.Colors.GREY_100,
|
||
border_radius=8,
|
||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||
alignment=ft.alignment.center,
|
||
)
|
||
|
||
# 페이지 네비게이션
|
||
self.prev_page_button = ft.IconButton(
|
||
icon=ft.Icons.ARROW_BACK,
|
||
disabled=True,
|
||
on_click=self.on_prev_page_click,
|
||
)
|
||
|
||
self.page_info_text = ft.Text("1 / 1", size=14)
|
||
|
||
self.next_page_button = ft.IconButton(
|
||
icon=ft.Icons.ARROW_FORWARD,
|
||
disabled=True,
|
||
on_click=self.on_next_page_click,
|
||
)
|
||
|
||
page_nav = ft.Row([
|
||
self.prev_page_button,
|
||
self.page_info_text,
|
||
self.next_page_button,
|
||
], alignment=ft.MainAxisAlignment.CENTER)
|
||
|
||
# PDF 뷰어 다이얼로그
|
||
self.pdf_viewer_dialog = ft.AlertDialog(
|
||
modal=True,
|
||
title=ft.Text("PDF 미리보기"),
|
||
content=ft.Column([
|
||
self.pdf_image_container,
|
||
page_nav,
|
||
], height=750, width=650),
|
||
actions=[
|
||
ft.TextButton("닫기", on_click=self.close_pdf_viewer)
|
||
],
|
||
actions_alignment=ft.MainAxisAlignment.END,
|
||
)
|
||
|
||
# 기존 이벤트 핸들러들 (기존 main.py에서 복사)
|
||
# ... (이벤트 핸들러 코드들을 여기에 복사)
|
||
|
||
def on_select_file_click(self, e):
|
||
"""파일 선택 버튼 클릭 핸들러"""
|
||
self.file_picker.pick_files(
|
||
allowed_extensions=["pdf", "dxf"],
|
||
allow_multiple=False
|
||
)
|
||
|
||
def on_file_selected(self, e: ft.FilePickerResultEvent):
|
||
"""파일 선택 결과 핸들러"""
|
||
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):
|
||
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):
|
||
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
|
||
|
||
self.page_info_text.value = "DXF 파일"
|
||
self.current_page_index = 0
|
||
self.current_pdf_info = None
|
||
|
||
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
|
||
self.current_title_block_info = None
|
||
|
||
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_pdf_preview_click(self, e):
|
||
"""PDF 미리보기 버튼 클릭 핸들러"""
|
||
if self.current_file_path and self.current_file_type == 'pdf':
|
||
self.load_pdf_preview()
|
||
self.page.dialog = self.pdf_viewer_dialog
|
||
self.pdf_viewer_dialog.open = True
|
||
self.page.update()
|
||
|
||
def load_pdf_preview(self):
|
||
"""PDF 미리보기 로드"""
|
||
try:
|
||
image_data = self.pdf_processor.pdf_page_to_image_bytes(
|
||
self.current_file_path,
|
||
self.current_page_index
|
||
)
|
||
|
||
if image_data:
|
||
base64_data = base64.b64encode(image_data).decode()
|
||
|
||
self.pdf_image_container.content = ft.Image(
|
||
src_base64=base64_data,
|
||
width=600,
|
||
height=700,
|
||
fit=ft.ImageFit.CONTAIN,
|
||
)
|
||
|
||
self.prev_page_button.disabled = self.current_page_index == 0
|
||
self.next_page_button.disabled = self.current_page_index >= self.current_pdf_info['page_count'] - 1
|
||
self.page_info_text.value = f"{self.current_page_index + 1} / {self.current_pdf_info['page_count']}"
|
||
else:
|
||
self.pdf_image_container.content = ft.Text(
|
||
"PDF 페이지 로드 실패",
|
||
color=ft.Colors.RED_600
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"PDF 미리보기 로드 오류: {e}")
|
||
self.pdf_image_container.content = ft.Text(
|
||
f"미리보기 오류: {str(e)}",
|
||
color=ft.Colors.RED_600
|
||
)
|
||
|
||
def on_prev_page_click(self, e):
|
||
"""이전 페이지 버튼 클릭"""
|
||
if self.current_page_index > 0:
|
||
self.current_page_index -= 1
|
||
self.load_pdf_preview()
|
||
self.page.update()
|
||
|
||
def on_next_page_click(self, e):
|
||
"""다음 페이지 버튼 클릭"""
|
||
if self.current_page_index < self.current_pdf_info['page_count'] - 1:
|
||
self.current_page_index += 1
|
||
self.load_pdf_preview()
|
||
self.page.update()
|
||
|
||
def close_pdf_viewer(self, e):
|
||
"""PDF 뷰어 닫기"""
|
||
self.pdf_viewer_dialog.open = False
|
||
self.page.update()
|
||
|
||
def on_analysis_start_click(self, e):
|
||
"""분석 시작 버튼 클릭 핸들러"""
|
||
if not self.current_file_path or not self.current_file_type:
|
||
return
|
||
|
||
if self.current_file_type == 'pdf' and 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 on_save_csv_click(self, e):
|
||
"""CSV 저장 버튼 클릭 핸들러"""
|
||
if not self.current_title_block_info:
|
||
self.show_error_dialog("저장 오류", "저장할 타이틀블럭 속성 정보가 없습니다.")
|
||
return
|
||
|
||
try:
|
||
import os
|
||
filename = f"title_block_attributes_{os.path.basename(self.current_file_path).replace('.dxf', '')}"
|
||
saved_path = self.csv_exporter.export_title_block_attributes(
|
||
self.current_title_block_info,
|
||
filename
|
||
)
|
||
|
||
if saved_path:
|
||
self.show_info_dialog(
|
||
"CSV 저장 완료",
|
||
f"타이틀블럭 속성 정보가 CSV 파일로 저장되었습니다:\n\n{saved_path}"
|
||
)
|
||
else:
|
||
self.show_error_dialog("저장 실패", "CSV 파일 저장 중 오류가 발생했습니다.")
|
||
|
||
except Exception as e:
|
||
logger.error(f"CSV 저장 중 오류: {e}")
|
||
self.show_error_dialog("저장 오류", f"CSV 저장 중 오류가 발생했습니다:\n{str(e)}")
|
||
|
||
def _save_results(self, format_type: str):
|
||
"""결과 저장 공통 함수"""
|
||
if not self.analysis_results:
|
||
self.show_error_dialog("저장 오류", "저장할 분석 결과가 없습니다.")
|
||
return
|
||
|
||
try:
|
||
analysis_settings = {
|
||
"조직_유형": self.organization_selector.value,
|
||
"페이지_선택": 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'] if self.current_pdf_info else "dxf_file",
|
||
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":
|
||
saved_path = self.result_saver.save_analysis_json(
|
||
pdf_filename=self.current_pdf_info['filename'] if self.current_pdf_info else "dxf_file",
|
||
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()
|
||
|
||
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 = "expressway" if self.organization_selector.value == "한국도로공사" else "transportation"
|
||
logger.info(f"선택된 조직 유형: {organization_type}")
|
||
|
||
pages_to_analyze = list(range(self.current_pdf_info['page_count'])) if self.page_selector.value == "모든 페이지" else [0]
|
||
|
||
if self.analysis_mode.value == "custom":
|
||
prompt = self.custom_prompt.value or Config.DEFAULT_PROMPT
|
||
else:
|
||
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}/{total_pages} 처리 중...", progress)
|
||
|
||
# 텍스트와 좌표 추출
|
||
self.update_progress_ui(True, f"페이지 {page_num + 1}: 텍스트 추출 중...", progress)
|
||
text_blocks = self.pdf_processor.extract_text_with_coordinates(self.current_file_path, page_num)
|
||
if not text_blocks:
|
||
logger.warning(f"페이지 {page_num + 1}에서 텍스트를 추출하지 못했습니다.")
|
||
|
||
# 이미지를 Base64로 변환
|
||
self.update_progress_ui(True, f"페이지 {page_num + 1}: 이미지 변환 중...", progress)
|
||
base64_data = self.pdf_processor.pdf_page_to_base64(self.current_file_path, page_num)
|
||
|
||
if base64_data:
|
||
# Gemini API로 분석
|
||
self.update_progress_ui(True, f"페이지 {page_num + 1}: AI 분석 중...", progress)
|
||
result = self.gemini_analyzer.analyze_pdf_page(
|
||
base64_data=base64_data,
|
||
text_blocks=text_blocks,
|
||
prompt=prompt,
|
||
organization_type=organization_type
|
||
)
|
||
self.analysis_results[page_num] = result or f"페이지 {page_num + 1} 분석 실패"
|
||
else:
|
||
self.analysis_results[page_num] = f"페이지 {page_num + 1} 이미지 변환 실패"
|
||
|
||
self.display_analysis_results()
|
||
|
||
duration_str = DateTimeUtils.format_duration(time.time() - self.analysis_start_time)
|
||
self.update_progress_ui(False, f"✅ PDF 분석 완료! (소요시간: {duration_str})", 1.0)
|
||
|
||
def _run_dxf_analysis(self):
|
||
"""DXF 파일 분석 실행"""
|
||
self.update_progress_ui(True, "DXF 파일 분석 중...")
|
||
|
||
try:
|
||
result = self.dxf_processor.process_dxf_file_comprehensive(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 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()
|
||
|
||
self.page.run_thread(update)
|
||
|
||
def display_analysis_results(self):
|
||
"""분석 결과 표시"""
|
||
def update_results():
|
||
if not self.analysis_results:
|
||
self.results_text.value = "❌ 분석 결과가 없습니다."
|
||
self.save_text_button.disabled = True
|
||
self.save_json_button.disabled = True
|
||
self.save_csv_button.visible = False
|
||
self.page.update()
|
||
return
|
||
|
||
import json
|
||
result_text = f"🎯 분석 요약 (총 {len(self.analysis_results)}페이지)\n"
|
||
result_text += f"⏰ 완료 시간: {DateTimeUtils.get_timestamp()}\n"
|
||
result_text += f"🏢 조직 스키마: {self.organization_selector.value}\n"
|
||
result_text += "=" * 60 + "\n\n"
|
||
|
||
for page_num, result_json in self.analysis_results.items():
|
||
result_text += f"📋 페이지 {page_num + 1} 분석 결과\n"
|
||
result_text += "-" * 40 + "\n"
|
||
|
||
try:
|
||
data = json.loads(result_json)
|
||
for key, item in data.items():
|
||
if isinstance(item, dict) and 'value' in item:
|
||
val = item.get('value', 'N/A')
|
||
x = item.get('x', -1)
|
||
y = item.get('y', -1)
|
||
result_text += f"- {key}: {val} (좌표: {x:.0f}, {y:.0f})\n"
|
||
else:
|
||
result_text += f"- {key}: {item}\n"
|
||
except (json.JSONDecodeError, TypeError):
|
||
result_text += str(result_json)
|
||
|
||
result_text += "\n" + "=" * 60 + "\n\n"
|
||
|
||
self.results_text.value = result_text.strip()
|
||
self.save_text_button.disabled = False
|
||
self.save_json_button.disabled = False
|
||
self.save_csv_button.visible = False
|
||
self.page.update()
|
||
|
||
self.page.run_thread(update_results)
|
||
|
||
def display_dxf_analysis_results(self, dxf_result):
|
||
"""DXF 분석 결과 표시"""
|
||
def update_results():
|
||
if dxf_result and dxf_result['success']:
|
||
self.current_title_block_info = dxf_result.get('title_block')
|
||
|
||
import os
|
||
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 += "\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"
|
||
|
||
if title_block.get('all_attributes'):
|
||
result_text += "\n\n📊 타이틀블럭 속성 상세 정보\n"
|
||
result_text += "-" * 60 + "\n"
|
||
|
||
table_data = self.csv_exporter.create_attribute_table_data(title_block)
|
||
|
||
if table_data:
|
||
result_text += f"{'No.':<4} {'Tag':<15} {'Text':<25} {'Prompt':<20} {'X':<8} {'Y':<8} {'Layer':<8}\n"
|
||
result_text += "-" * 100 + "\n"
|
||
|
||
for i, row in enumerate(table_data[:10]):
|
||
result_text += f"{row['No.']:<4} {row['Tag'][:14]:<15} {row['Text'][:24]:<25} "
|
||
result_text += f"{row['Prompt'][:19]:<20} {row['X']:<8} {row['Y']:<8} {row['Layer'][:7]:<8}\n"
|
||
|
||
if len(table_data) > 10:
|
||
result_text += f"... 외 {len(table_data) - 10}개 속성\n"
|
||
|
||
result_text += f"\n💡 전체 {len(table_data)}개 속성을 CSV 파일로 저장할 수 있습니다.\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]):
|
||
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
|
||
|
||
if self.current_title_block_info and self.current_title_block_info.get('all_attributes'):
|
||
self.save_csv_button.visible = True
|
||
self.save_csv_button.disabled = False
|
||
else:
|
||
self.save_csv_button.visible = False
|
||
self.save_csv_button.disabled = True
|
||
|
||
else:
|
||
self.results_text.value = "❌ DXF 분석 결과가 없습니다."
|
||
self.save_text_button.disabled = True
|
||
self.save_json_button.disabled = True
|
||
self.save_csv_button.visible = False
|
||
self.save_csv_button.disabled = True
|
||
self.current_title_block_info = None
|
||
|
||
self.page.update()
|
||
|
||
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()
|
||
|
||
|
||
class TabbedDocumentAnalyzerApp:
|
||
"""탭 기반 통합 문서 분석기 애플리케이션"""
|
||
|
||
def __init__(self, page: ft.Page):
|
||
self.page = page
|
||
self.setup_page()
|
||
|
||
# 앱 인스턴스
|
||
self.single_file_app = None
|
||
self.multi_file_app = None
|
||
|
||
def setup_page(self):
|
||
"""페이지 기본 설정"""
|
||
self.page.title = "PDF/DXF 도면 분석기 v2.0"
|
||
self.page.theme_mode = ft.ThemeMode.LIGHT
|
||
self.page.padding = 0
|
||
self.page.bgcolor = ft.Colors.GREY_100
|
||
|
||
# 윈도우 크기 설정
|
||
self.page.window.width = 1400
|
||
self.page.window.height = 1000
|
||
self.page.window.min_width = 1200
|
||
self.page.window.min_height = 800
|
||
|
||
logger.info("탭 기반 애플리케이션 페이지 설정 완료")
|
||
|
||
def build_ui(self):
|
||
"""탭 기반 UI 구성"""
|
||
|
||
# 앱바
|
||
app_bar = ft.AppBar(
|
||
title=ft.Text(
|
||
"📄 PDF/DXF 도면 분석기 v2.0",
|
||
size=20,
|
||
weight=ft.FontWeight.BOLD
|
||
),
|
||
center_title=True,
|
||
bgcolor=ft.Colors.BLUE_600,
|
||
color=ft.Colors.WHITE,
|
||
automatically_imply_leading=False,
|
||
)
|
||
self.page.appbar = app_bar
|
||
|
||
# 탭 생성
|
||
tabs = ft.Tabs(
|
||
selected_index=0,
|
||
animation_duration=300,
|
||
divider_color=ft.Colors.BLUE_200,
|
||
indicator_color=ft.Colors.BLUE_600,
|
||
label_color=ft.Colors.BLUE_800,
|
||
unselected_label_color=ft.Colors.GREY_600,
|
||
# overlay_color 제거 - Flet 버전 호환성 개선
|
||
on_change=self.on_tab_change,
|
||
expand=True,
|
||
tabs=[
|
||
ft.Tab(
|
||
icon=ft.Icons.DESCRIPTION,
|
||
text="단일 파일 분석",
|
||
content=self.create_single_file_tab()
|
||
),
|
||
ft.Tab(
|
||
icon=ft.Icons.BATCH_PREDICTION,
|
||
text="다중 파일 배치 처리",
|
||
content=self.create_multi_file_tab()
|
||
),
|
||
],
|
||
)
|
||
|
||
# 메인 컨테이너
|
||
main_container = ft.Container(
|
||
content=tabs,
|
||
expand=True,
|
||
padding=5,
|
||
)
|
||
|
||
# 페이지에 추가
|
||
self.page.add(main_container)
|
||
|
||
logger.info("탭 기반 UI 구성 완료")
|
||
|
||
def create_single_file_tab(self) -> ft.Column:
|
||
"""단일 파일 분석 탭 생성"""
|
||
|
||
# 단일 파일 앱 인스턴스 생성
|
||
self.single_file_app = SingleFileAnalyzerApp(self.page)
|
||
|
||
return self.single_file_app.build_ui()
|
||
|
||
def create_multi_file_tab(self) -> ft.Column:
|
||
"""다중 파일 배치 처리 탭 생성"""
|
||
|
||
# 다중 파일 앱 인스턴스 생성
|
||
self.multi_file_app = MultiFileApp(self.page)
|
||
|
||
return self.multi_file_app.build_ui()
|
||
|
||
def on_tab_change(self, e):
|
||
"""탭 변경 이벤트 핸들러"""
|
||
selected_index = e.control.selected_index
|
||
|
||
if selected_index == 0:
|
||
logger.info("단일 파일 분석 탭 선택")
|
||
elif selected_index == 1:
|
||
logger.info("다중 파일 배치 처리 탭 선택")
|
||
|
||
self.page.update()
|
||
|
||
|
||
def main(page: ft.Page):
|
||
"""메인 함수"""
|
||
try:
|
||
# 탭 기반 애플리케이션 초기화
|
||
app = TabbedDocumentAnalyzerApp(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",
|
||
)
|