diff --git a/.gitignore b/.gitignore index 9491a2f..9266283 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +.venv/ +venv/ diff --git a/DwgExtractorManual.csproj b/DwgExtractorManual.csproj index 503a2ac..b4fa5d0 100644 --- a/DwgExtractorManual.csproj +++ b/DwgExtractorManual.csproj @@ -7,6 +7,7 @@ enable enable + 9 @@ -21,13 +22,85 @@ + - - ..\..\..\..\GitNet8\trunk\DLL\Teigha\vc16_amd64dll_23.12SP2\TD_Mgd_23.12_16.dll + D:\dev_Net8_git\trunk\DLL\Teigha\vc16_amd64dll_23.12SP2\TD_Mgd_23.12_16.dll + true + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MainWindow.xaml b/MainWindow.xaml index 92c4843..44a20ef 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -100,24 +100,60 @@ Text="준비됨" FontSize="12" HorizontalAlignment="Center" Margin="0,5"/> - - + + + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index d317268..e71d007 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -3,6 +3,15 @@ using System.Windows; using System.Diagnostics; using System.Windows.Threading; using DwgExtractorManual.Models; +using System.Linq; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; +using System.Text; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace DwgExtractorManual { @@ -23,8 +32,8 @@ namespace DwgExtractorManual private void InitializeDefaultPaths() { // 기본 경로 설정 - 실제 환경에 맞게 수정 - txtSourceFolder.Text = @"D:\MyProjects\AI_TaskForce\AI_도면_dwg_pdf\대산당진_2공구_02도서성과품_01_설계도면\002_토공\01_본선\01_평면 및 종단면도"; - txtResultFolder.Text = @"D:\MyProjects\AI_TaskForce\AI_도면_dwg_pdf\대산당진_2공구_02도서성과품_01_설계도면"; + txtSourceFolder.Text = @"D:\dwgpdfcompare\test2"; + txtResultFolder.Text = @"D:\dwgpdfcompare\result"; // 경로가 존재하지 않으면 기본값으로 설정 if (!Directory.Exists(txtSourceFolder.Text)) @@ -94,29 +103,50 @@ namespace DwgExtractorManual try { var targetDir = new DirectoryInfo(folderPath); - var files = targetDir.GetFiles("*.dwg", SearchOption.AllDirectories); + var dwgFiles = targetDir.GetFiles("*.dwg", SearchOption.AllDirectories); + var pdfFiles = targetDir.GetFiles("*.pdf", SearchOption.AllDirectories); - txtFileCount.Text = $"파일: {files.Length}개"; + txtFileCount.Text = $"DWG: {dwgFiles.Length}개, PDF: {pdfFiles.Length}개"; - if (files.Length > 0) + if (dwgFiles.Length > 0) { - LogMessage($"✅ 총 {files.Length}개의 DWG 파일을 발견했습니다."); + LogMessage($"✅ 총 {dwgFiles.Length}개의 DWG 파일을 발견했습니다."); // 처음 몇 개 파일명 로깅 - int showCount = Math.Min(5, files.Length); + int showCount = Math.Min(3, dwgFiles.Length); for (int i = 0; i < showCount; i++) { - LogMessage($" 📄 {files[i].Name}"); + LogMessage($" 📄 {dwgFiles[i].Name}"); } - if (files.Length > 5) + if (dwgFiles.Length > 3) { - LogMessage($" ... 외 {files.Length - 5}개 파일"); + LogMessage($" ... 외 {dwgFiles.Length - 3}개 DWG 파일"); } } else { LogMessage("⚠️ 선택한 폴더에 DWG 파일이 없습니다."); } + + if (pdfFiles.Length > 0) + { + LogMessage($"✅ 총 {pdfFiles.Length}개의 PDF 파일을 발견했습니다."); + + // 처음 몇 개 파일명 로깅 + int showCount = Math.Min(3, pdfFiles.Length); + for (int i = 0; i < showCount; i++) + { + LogMessage($" 📄 {pdfFiles[i].Name}"); + } + if (pdfFiles.Length > 3) + { + LogMessage($" ... 외 {pdfFiles.Length - 3}개 PDF 파일"); + } + } + else + { + LogMessage("⚠️ 선택한 폴더에 PDF 파일이 없습니다."); + } } catch (Exception ex) { @@ -174,6 +204,8 @@ namespace DwgExtractorManual // UI 비활성화 btnExtract.IsEnabled = false; + btnPdfExtract.IsEnabled = false; + btnMerge.IsEnabled = false; btnBrowseSource.IsEnabled = false; btnBrowseResult.IsEnabled = false; rbExcel.IsEnabled = false; @@ -200,9 +232,11 @@ namespace DwgExtractorManual { // UI 활성화 btnExtract.IsEnabled = true; + btnPdfExtract.IsEnabled = true; + btnMerge.IsEnabled = true; btnBrowseSource.IsEnabled = true; btnBrowseResult.IsEnabled = true; - rbExcel.IsEnabled = true; + rbExcel.IsChecked = true; rbDatabase.IsEnabled = true; progressBar.Value = 100; LogMessage(new string('=', 50)); @@ -308,11 +342,18 @@ namespace DwgExtractorManual await Task.Delay(10); } - // Excel 파일 저장 - var excelFileName = Path.Combine(txtResultFolder.Text, - $"{DateTime.Now:yyyyMMdd_HHmmss}_DwgToExcel.xlsx"); + // Excel 파일 및 매핑 데이터 저장 + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var excelFileName = Path.Combine(txtResultFolder.Text, $"{timestamp}_DwgToExcel.xlsx"); + var mappingDataFile = Path.Combine(txtResultFolder.Text, $"{timestamp}_mapping_data.json"); - LogMessage("💾 Excel 파일을 저장합니다..."); + LogMessage("💾 Excel 파일과 매핑 데이터를 저장합니다..."); + + // 매핑 딕셔너리를 JSON 파일로 저장 (PDF 데이터 병합용) + _exportExcel.SaveMappingDictionary(mappingDataFile); + LogMessage($"✅ 매핑 데이터 저장 완료: {Path.GetFileName(mappingDataFile)}"); + + // Excel 파일 저장 _exportExcel.SaveAndCloseExcel(excelFileName); LogMessage($"✅ Excel 파일 저장 완료: {Path.GetFileName(excelFileName)}"); } @@ -429,6 +470,290 @@ namespace DwgExtractorManual "완료", MessageBoxButton.OK, MessageBoxImage.Information); } + private async void BtnPdfExtract_Click(object sender, RoutedEventArgs e) + { + // 입력 유효성 검사 + if (string.IsNullOrEmpty(txtSourceFolder.Text) || !Directory.Exists(txtSourceFolder.Text)) + { + System.Windows.MessageBox.Show("유효한 변환할 폴더를 선택하세요.", "오류", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + if (string.IsNullOrEmpty(txtResultFolder.Text) || !Directory.Exists(txtResultFolder.Text)) + { + System.Windows.MessageBox.Show("유효한 결과 저장 폴더를 선택하세요.", "오류", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + // PDF 파일 존재 여부 확인 + var targetDir = new DirectoryInfo(txtSourceFolder.Text); + var pdfFiles = targetDir.GetFiles("*.pdf", SearchOption.AllDirectories); + + if (pdfFiles.Length == 0) + { + System.Windows.MessageBox.Show("선택한 폴더에 PDF 파일이 없습니다.", "정보", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + // UI 비활성화 + btnExtract.IsEnabled = false; + btnPdfExtract.IsEnabled = false; + btnMerge.IsEnabled = false; + btnBrowseSource.IsEnabled = false; + btnBrowseResult.IsEnabled = false; + rbExcel.IsEnabled = false; + rbDatabase.IsEnabled = false; + progressBar.Value = 0; + + UpdateStatus("📄 PDF 추출 작업을 시작합니다..."); + LogMessage(new string('=', 50)); + LogMessage("📄 PDF 정보 추출 작업 시작"); + LogMessage(new string('=', 50)); + + try + { + await ProcessPdfFiles(pdfFiles); + } + catch (Exception ex) + { + LogMessage($"❌ PDF 추출 중 치명적 오류 발생: {ex.Message}"); + UpdateStatus("PDF 추출 중 오류가 발생했습니다."); + System.Windows.MessageBox.Show($"PDF 추출 중 오류가 발생했습니다:\n\n{ex.Message}", + "오류", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + // UI 활성화 + btnExtract.IsEnabled = true; + btnPdfExtract.IsEnabled = true; + btnMerge.IsEnabled = true; + btnBrowseSource.IsEnabled = true; + btnBrowseResult.IsEnabled = true; + rbExcel.IsEnabled = true; + rbDatabase.IsEnabled = true; + progressBar.Value = 100; + LogMessage(new string('=', 50)); + LogMessage("🏁 PDF 추출 작업 완료"); + LogMessage(new string('=', 50)); + } + } + + private async Task ProcessPdfFiles(FileInfo[] pdfFiles) + { + var stopwatch = Stopwatch.StartNew(); + + UpdateStatus($"총 {pdfFiles.Length}개의 PDF 파일을 처리합니다..."); + LogMessage($"📊 처리할 PDF 파일 수: {pdfFiles.Length}개"); + LogMessage($"📂 소스 폴더: {txtSourceFolder.Text}"); + LogMessage($"💾 결과 폴더: {txtResultFolder.Text}"); + + // 임시 파일 리스트 생성 (긴 명령줄 방지) + string tempFileList = Path.Combine(Path.GetTempPath(), $"pdf_files_{Guid.NewGuid():N}.txt"); + + try + { + // PDF 파일 경로를 임시 파일에 저장 + await File.WriteAllLinesAsync(tempFileList, pdfFiles.Select(f => f.FullName)); + LogMessage($"📄 임시 파일 리스트 생성됨: {pdfFiles.Length}개 파일"); + + // Python 스크립트 실행 설정 - 가상환경의 Python 사용 + string baseDirectory = AppDomain.CurrentDomain.BaseDirectory; + string fletAnalysisPath = Path.Combine(baseDirectory, "fletimageanalysis"); + string pythonPath = Path.Combine(fletAnalysisPath, "venv", "Scripts", "python.exe"); + string scriptPath = Path.Combine(fletAnalysisPath, "batch_cli.py"); + + string schema = "한국도로공사"; // Valid schema option + int concurrent = 3; + bool batchMode = true; + bool saveIntermediate = false; + bool includeErrors = true; + string outputPath = Path.Combine(txtResultFolder.Text, $"{DateTime.Now:yyyyMMdd_HHmmss}_PdfExtraction.csv"); + + // 파일 리스트 방식으로 변경 (긴 명령줄 방지) + string arguments = $"--file-list \"{tempFileList}\" --schema \"{schema}\" --concurrent {concurrent} " + + $"--batch-mode {batchMode.ToString().ToLower()} " + + $"--save-intermediate {saveIntermediate.ToString().ToLower()} " + + $"--include-errors {includeErrors.ToString().ToLower()} " + + $"--output \"{outputPath}\""; + + LogMessage($"🐍 Python 스크립트 실행 준비..."); + LogMessage($"📁 Python 경로: {pythonPath}"); + LogMessage($"📁 스크립트 경로: {scriptPath}"); + LogMessage($"📄 파일 리스트: {Path.GetFileName(tempFileList)}"); + + // 가상환경 Python 확인 + if (!File.Exists(pythonPath)) + { + throw new Exception($"가상환경 Python을 찾을 수 없습니다: {pythonPath}\n\n" + + "cleanup_and_setup.bat을 실행하여 가상환경을 설정하세요."); + } + + // 스크립트 파일 존재 확인 + if (!File.Exists(scriptPath)) + { + throw new Exception($"Python 스크립트를 찾을 수 없습니다: {scriptPath}"); + } + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = pythonPath, // 가상환경의 Python 사용 + Arguments = $"\"{scriptPath}\" {arguments}", + WorkingDirectory = fletAnalysisPath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using (Process process = Process.Start(startInfo)) + { + if (process == null) + { + throw new Exception("Python 프로세스를 시작할 수 없습니다."); + } + + LogMessage("🚀 Python 스크립트 실행 시작"); + + // 비동기로 출력 읽기 + var outputTask = ReadProcessOutputAsync(process); + + // 프로세스 완료 대기 + await Task.Run(() => process.WaitForExit()); + + var (output, errors) = await outputTask; + + stopwatch.Stop(); + + if (process.ExitCode == 0) + { + LogMessage("✅ PDF 추출 완료!"); + LogMessage($"📄 결과 파일: {Path.GetFileName(outputPath)}"); + LogMessage($"🕐 총 소요 시간: {stopwatch.ElapsedMilliseconds}ms"); + + UpdateStatus("PDF 추출 완료!"); + + // 결과 파일 존재 확인 + if (File.Exists(outputPath)) + { + LogMessage($"✅ 결과 파일 생성 확인됨: {outputPath}"); + } + else + { + LogMessage($"⚠️ 결과 파일이 예상 위치에 없습니다: {outputPath}"); + } + + // 자동 Excel 매핑 업데이트 (새로운 효율적 방식) + await AutoUpdateExcelMappingWithPdfResults(outputPath); + + System.Windows.MessageBox.Show( + $"PDF 추출이 완료되었습니다!\n\n" + + $"📄 처리된 파일: {pdfFiles.Length}개\n" + + $"⏱️ 총 소요시간: {stopwatch.Elapsed:mm\\:ss}\n\n" + + $"📊 결과 파일: {Path.GetFileName(outputPath)}", + "완료", MessageBoxButton.OK, MessageBoxImage.Information); + } + else + { + LogMessage($"❌ PDF 추출 실패 (Exit Code: {process.ExitCode})"); + if (!string.IsNullOrEmpty(errors)) + { + LogMessage($"❌ 오류 내용: {errors}"); + } + + throw new Exception($"Python 스크립트 실행 실패 (Exit Code: {process.ExitCode})\n\n{errors}"); + } + } + } + finally + { + // 임시 파일 정리 + try + { + if (File.Exists(tempFileList)) + { + File.Delete(tempFileList); + LogMessage("🗑️ 임시 파일 리스트 정리 완료"); + } + } + catch (Exception ex) + { + LogMessage($"⚠️ 임시 파일 정리 중 오류: {ex.Message}"); + } + } + } + + private async Task<(string output, string errors)> ReadProcessOutputAsync(Process process) + { + var outputBuilder = new System.Text.StringBuilder(); + var errorBuilder = new System.Text.StringBuilder(); + + var outputTask = Task.Run(async () => + { + string line; + while ((line = await process.StandardOutput.ReadLineAsync()) != null) + { + outputBuilder.AppendLine(line); + + // 진행 상태 업데이트 처리 + if (line.StartsWith("PROGRESS:")) + { + try + { + var progressPart = line.Substring("PROGRESS:".Length).Trim(); + var parts = progressPart.Split('/'); + if (parts.Length == 2 && + int.TryParse(parts[0], out int current) && + int.TryParse(parts[1], out int total)) + { + var percentage = (current * 100.0) / total; + await Dispatcher.InvokeAsync(() => + { + progressBar.Value = percentage; + UpdateStatus($"PDF 처리 중: {current}/{total} ({percentage:F1}%)"); + }); + } + } + catch + { + // 파싱 오류 무시 + } + } + else if (line.StartsWith("START:")) + { + var message = line.Substring("START:".Length).Trim(); + await Dispatcher.InvokeAsync(() => LogMessage($"🔄 {message}")); + } + else if (line.StartsWith("COMPLETED:")) + { + var message = line.Substring("COMPLETED:".Length).Trim(); + await Dispatcher.InvokeAsync(() => LogMessage($"✅ {message}")); + } + else if (line.StartsWith("ERROR:")) + { + var errorInfo = line.Substring("ERROR:".Length).Trim(); + await Dispatcher.InvokeAsync(() => LogMessage($"❌ 오류: {errorInfo}")); + } + } + }); + + var errorTask = Task.Run(async () => + { + string line; + while ((line = await process.StandardError.ReadLineAsync()) != null) + { + errorBuilder.AppendLine(line); + await Dispatcher.InvokeAsync(() => LogMessage($"⚠️ {line}")); + } + }); + + await Task.WhenAll(outputTask, errorTask); + + return (outputBuilder.ToString(), errorBuilder.ToString()); + } + private void UpdateStatus(string message) { txtStatus.Text = message; @@ -442,6 +767,505 @@ namespace DwgExtractorManual txtLog.ScrollToEnd(); } + /// + /// PDF 추출 결과를 사용하여 Excel 매핑을 효율적으로 업데이트합니다 (새로운 방식). + /// + /// CSV 결과 파일 경로 + private async Task AutoUpdateExcelMappingWithPdfResults(string csvFilePath) + { + try + { + LogMessage("🔄 Excel 매핑 자동 업데이트 시작 (효율적 방식)..."); + + // JSON 파일 경로 구성 + string jsonFilePath = csvFilePath.Replace(".csv", ".json"); + + if (!File.Exists(jsonFilePath)) + { + LogMessage($"⚠️ JSON 파일이 없습니다: {Path.GetFileName(jsonFilePath)}"); + return; + } + + LogMessage($"📄 JSON 파일 확인됨: {Path.GetFileName(jsonFilePath)}"); + + // 최신 매핑 데이터 파일 찾기 + string resultDir = Path.GetDirectoryName(csvFilePath) ?? txtResultFolder.Text; + var mappingDataFiles = Directory.GetFiles(resultDir, "*_mapping_data.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(f => File.GetCreationTime(f)) + .ToArray(); + + if (mappingDataFiles.Length == 0) + { + LogMessage("⚠️ 매핑 데이터 파일이 없습니다. DWG 파일을 먼저 처리하세요."); + LogMessage("💡 'DWG 정보 추출' 버튼을 먼저 실행하여 매핑 데이터를 생성하세요."); + return; + } + + string latestMappingDataFile = mappingDataFiles[0]; + LogMessage($"📊 최신 매핑 데이터 파일 발견: {Path.GetFileName(latestMappingDataFile)}"); + + // 새로운 ExportExcel 인스턴스 생성 및 데이터 로드 + ExportExcel? exportExcel = null; + + try + { + LogMessage("🔄 매핑 데이터 로드 및 PDF 값 업데이트 중..."); + + exportExcel = new ExportExcel(); + + // 기존 매핑 딕셔너리 로드 + exportExcel.LoadMappingDictionary(latestMappingDataFile); + + // PDF 데이터로 업데이트 + exportExcel.UpdateWithPdfData(jsonFilePath); + + // 완전한 Excel 파일 생성 (DWG + PDF 데이터) + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string completeExcelPath = Path.Combine(resultDir, $"{timestamp}_Complete_Mapping.xlsx"); + + LogMessage("📊 통합 Excel 파일 생성 중..."); + + // ⭐ 중요: 매핑 데이터를 Excel 시트에 기록 + exportExcel.WriteCompleteMapping(); + + // 매핑 워크북만 저장 (완전한 매핑 데이터용) + exportExcel.SaveMappingWorkbookOnly(completeExcelPath); + + // 업데이트된 매핑 데이터 저장 + exportExcel.SaveMappingDictionary(latestMappingDataFile); + + // Excel 객체 정리 + exportExcel.CloseExcelObjectsWithoutSaving(); + + LogMessage($"✅ Excel 매핑 자동 업데이트 완료: {Path.GetFileName(completeExcelPath)}"); + LogMessage("📊 DWG 값과 PDF 값이 모두 포함된 통합 Excel 파일이 생성되었습니다."); + + } + catch (Exception ex) + { + LogMessage($"❌ Excel 매핑 자동 업데이트 중 오류: {ex.Message}"); + Debug.WriteLine($"Excel 매핑 자동 업데이트 오류: {ex}"); + } + } + catch (Exception ex) + { + LogMessage($"❌ Excel 매핑 자동 업데이트 중 오류: {ex.Message}"); + Debug.WriteLine($"Excel 매핑 자동 업데이트 오류: {ex}"); + } + } + + /// + /// PDF 추출 결과 JSON 파일을 사용하여 Excel 매핑 시트를 업데이트합니다 (기존 방식 - 사용 안함). + /// + /// CSV 결과 파일 경로 + [Obsolete("이 메서드는 비효율적입니다. AutoUpdateExcelMappingWithPdfResults를 사용하세요.")] + private async Task UpdateExcelMappingWithPdfResults(string csvFilePath) + { + try + { + LogMessage("🔄 Excel 매핑 시트 업데이트 시작..."); + + // JSON 파일 경로 구성 + string jsonFilePath = csvFilePath.Replace(".csv", ".json"); + + if (!File.Exists(jsonFilePath)) + { + LogMessage($"⚠️ JSON 파일이 없습니다: {Path.GetFileName(jsonFilePath)}"); + return; + } + + LogMessage($"📄 JSON 파일 확인됨: {Path.GetFileName(jsonFilePath)}"); + + // 기존 Excel 매핑 파일 검색 (임시 파일 제외) + string resultDir = Path.GetDirectoryName(csvFilePath) ?? txtResultFolder.Text; + var allExcelFiles = Directory.GetFiles(resultDir, "*_Mapping.xlsx", SearchOption.TopDirectoryOnly); + + // 임시 파일(~$로 시작하는 파일) 필터링 + var excelFiles = allExcelFiles + .Where(f => !Path.GetFileName(f).StartsWith("~$")) + .ToArray(); + + ExportExcel? exportExcel = null; + string excelFilePath = ""; + + LogMessage($"🔍 Excel 파일 검색 결과: 전체 {allExcelFiles.Length}개, 유효 {excelFiles.Length}개"); + + if (excelFiles.Length > 0) + { + // 기존 매핑 파일이 있는 경우 (가장 최근 파일 선택) + excelFilePath = excelFiles.OrderByDescending(f => File.GetCreationTime(f)).First(); + LogMessage($"📊 기존 Excel 매핑 파일 발견: {Path.GetFileName(excelFilePath)}"); + + // 파일이 다른 프로그램에서 열려 있는지 확인 + if (IsFileInUse(excelFilePath)) + { + LogMessage("⚠️ Excel 파일이 다른 프로그램에서 열려 있습니다. 파일을 닫고 다시 시도하세요."); + return; + } + + // 기존 파일을 복사하여 새 버전 생성 + string newExcelPath = Path.Combine(resultDir, + $"{DateTime.Now:yyyyMMdd_HHmmss}_Mapping_Updated.xlsx"); + File.Copy(excelFilePath, newExcelPath, true); + excelFilePath = newExcelPath; + + LogMessage($"📋 Excel 파일 복사됨: {Path.GetFileName(newExcelPath)}"); + } + else + { + LogMessage("⚠️ 기존 Excel 매핑 파일이 없습니다. DWG 파일을 먼저 처리하세요."); + LogMessage("💡 'DWG 정보 추출' 버튼을 먼저 실행하여 매핑 파일을 생성하세요."); + return; + } + + // ExportExcel 클래스로 JSON 데이터 업데이트 + LogMessage("🔄 PDF 값으로 Excel 업데이트 중..."); + + exportExcel = new ExportExcel(); + + // 기존 Excel 파일을 열어 JSON 값으로 업데이트 + bool success = exportExcel.UpdateExistingExcelWithJson(excelFilePath, jsonFilePath); + + if (success) + { + // 업데이트된 Excel 파일 저장 + bool saveSuccess = exportExcel.SaveExcel(); + if (saveSuccess) + { + LogMessage($"✅ Excel 매핑 시트 업데이트 완료: {Path.GetFileName(excelFilePath)}"); + LogMessage("📊 PDF 값이 Excel 매핑 시트의 'Pdf_value' 컬럼에 추가되었습니다."); + } + else + { + LogMessage("❌ Excel 파일 저장 실패"); + } + } + else + { + LogMessage("❌ Excel 매핑 시트 업데이트 실패"); + } + + exportExcel?.Dispose(); + } + catch (Exception ex) + { + LogMessage($"❌ Excel 매핑 업데이트 중 오류: {ex.Message}"); + Debug.WriteLine($"Excel 매핑 업데이트 오류: {ex}"); + } + } + + /// + /// 파일이 다른 프로세스에서 사용 중인지 확인합니다. + /// + /// 확인할 파일 경로 + /// 사용 중이면 true, 아니면 false + private bool IsFileInUse(string filePath) + { + try + { + using (FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + // 파일을 열 수 있으면 사용 중이 아님 + return false; + } + } + catch (IOException) + { + // 파일을 열 수 없으면 사용 중 + return true; + } + catch (Exception) + { + // 다른 오류가 발생하면 안전하게 사용 중으로 간주 + return true; + } + } + + /// + /// 가장 최근의 매핑 데이터 파일을 찾습니다. + /// + /// 검색할 디렉토리 + /// 최신 매핑 데이터 파일 경로 또는 null + private string? FindLatestMappingDataFile(string resultDir) + { + try + { + var mappingDataFiles = Directory.GetFiles(resultDir, "*_mapping_data.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(f => File.GetCreationTime(f)) + .ToArray(); + + return mappingDataFiles.Length > 0 ? mappingDataFiles[0] : null; + } + catch (Exception ex) + { + LogMessage($"❌ 매핑 데이터 파일 검색 중 오류: {ex.Message}"); + return null; + } + } + + private async void BtnMerge_Click(object sender, RoutedEventArgs e) + { + // 입력 유효성 검사 + if (string.IsNullOrEmpty(txtResultFolder.Text) || !Directory.Exists(txtResultFolder.Text)) + { + System.Windows.MessageBox.Show("유효한 결과 저장 폴더를 선택하세요.", "오류", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + // UI 비활성화 + btnExtract.IsEnabled = false; + btnPdfExtract.IsEnabled = false; + btnMerge.IsEnabled = false; + btnBrowseSource.IsEnabled = false; + btnBrowseResult.IsEnabled = false; + rbExcel.IsEnabled = false; + rbDatabase.IsEnabled = false; + progressBar.Value = 0; + + UpdateStatus("🔗 Excel 매핑 병합 작업을 시작합니다..."); + LogMessage(new string('=', 50)); + LogMessage("🔗 Excel 매핑 병합 작업 시작"); + LogMessage(new string('=', 50)); + + try + { + await MergeLatestPdfResults(); + } + catch (Exception ex) + { + LogMessage($"❌ 매핑 병합 중 치명적 오류 발생: {ex.Message}"); + UpdateStatus("매핑 병합 중 오류가 발생했습니다."); + System.Windows.MessageBox.Show($"매핑 병합 중 오류가 발생했습니다:\n\n{ex.Message}", + "오류", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + // UI 활성화 + btnExtract.IsEnabled = true; + btnPdfExtract.IsEnabled = true; + btnMerge.IsEnabled = true; + btnBrowseSource.IsEnabled = true; + btnBrowseResult.IsEnabled = true; + rbExcel.IsEnabled = true; + rbDatabase.IsEnabled = true; + progressBar.Value = 100; + LogMessage(new string('=', 50)); + LogMessage("🏁 매핑 병합 작업 완료"); + LogMessage(new string('=', 50)); + } + } + + /// + /// 가장 최근의 PDF 추출 JSON 파일을 찾아 Excel 매핑을 효율적으로 업데이트합니다. + /// + private async Task MergeLatestPdfResults() + { + try + { + LogMessage("🔍 최신 PDF 추출 결과 파일 검색 중..."); + + string resultDir = txtResultFolder.Text; + + // 가장 최근의 PDF 추출 JSON 파일 찾기 + var jsonFiles = Directory.GetFiles(resultDir, "*_PdfExtraction.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(f => File.GetCreationTime(f)) + .ToArray(); + + if (jsonFiles.Length == 0) + { + LogMessage("❌ PDF 추출 JSON 파일을 찾을 수 없습니다."); + LogMessage("💡 먼저 'PDF 추출' 버튼을 실행하여 JSON 파일을 생성하세요."); + + System.Windows.MessageBox.Show( + "PDF 추출 JSON 파일을 찾을 수 없습니다.\n\n" + + "먼저 'PDF 추출' 버튼을 실행하여 JSON 파일을 생성하세요.", + "파일 없음", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + // 최신 매핑 데이터 파일 찾기 + var mappingDataFiles = Directory.GetFiles(resultDir, "*_mapping_data.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(f => File.GetCreationTime(f)) + .ToArray(); + + if (mappingDataFiles.Length == 0) + { + LogMessage("❌ 매핑 데이터 파일을 찾을 수 없습니다."); + LogMessage("💡 먼저 'DWG 정보 추출' 버튼을 실행하여 매핑 데이터를 생성하세요."); + + System.Windows.MessageBox.Show( + "매핑 데이터 파일을 찾을 수 없습니다.\n\n" + + "먼저 'DWG 정보 추출' 버튼을 실행하여 매핑 데이터를 생성하세요.", + "파일 없음", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + string latestJsonFile = jsonFiles[0]; + string latestMappingDataFile = mappingDataFiles[0]; + + LogMessage($"✅ 최신 JSON 파일 발견: {Path.GetFileName(latestJsonFile)}"); + LogMessage($"📅 생성일시: {File.GetCreationTime(latestJsonFile):yyyy-MM-dd HH:mm:ss}"); + LogMessage($"📊 최신 매핑 데이터 파일: {Path.GetFileName(latestMappingDataFile)}"); + + // JSON 파일 내용 미리보기 + await ShowJsonPreview(latestJsonFile); + + // 사용자 확인 + var result = System.Windows.MessageBox.Show( + $"다음 파일들로 Excel 매핑을 업데이트하시겠습니까?\n\n" + + $"📄 PDF JSON: {Path.GetFileName(latestJsonFile)}\n" + + $"📊 매핑 데이터: {Path.GetFileName(latestMappingDataFile)}\n" + + $"📅 생성: {File.GetCreationTime(latestJsonFile):yyyy-MM-dd HH:mm:ss}\n" + + $"📏 크기: {new FileInfo(latestJsonFile).Length / 1024:N0} KB", + "매핑 업데이트 확인", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (result != MessageBoxResult.Yes) + { + LogMessage("❌ 사용자에 의해 매핑 업데이트가 취소되었습니다."); + UpdateStatus("매핑 업데이트가 취소되었습니다."); + return; + } + + // 효율적인 Excel 매핑 업데이트 실행 + UpdateStatus("🔄 Excel 매핑 업데이트 중 (효율적 방식)..."); + progressBar.Value = 25; + + ExportExcel? exportExcel = null; + + try + { + LogMessage("🔄 매핑 데이터 로드 및 PDF 값 업데이트 중..."); + + exportExcel = new ExportExcel(); + + progressBar.Value = 40; + + // 기존 매핑 딕셔너리 로드 + exportExcel.LoadMappingDictionary(latestMappingDataFile); + + progressBar.Value = 60; + + // PDF 데이터로 업데이트 + exportExcel.UpdateWithPdfData(latestJsonFile); + + progressBar.Value = 80; + + // 완전한 Excel 파일 생성 (DWG + PDF 데이터) + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string completeExcelPath = Path.Combine(resultDir, $"{timestamp}_Complete_Mapping_Merged.xlsx"); + + LogMessage("📊 통합 Excel 파일 생성 중..."); + + // ⭐ 중요: 매핑 데이터를 Excel 시트에 기록 + exportExcel.WriteCompleteMapping(); + + // 매핑 워크북만 저장 (완전한 매핑 데이터용) + exportExcel.SaveMappingWorkbookOnly(completeExcelPath); + + // 업데이트된 매핑 데이터 저장 + exportExcel.SaveMappingDictionary(latestMappingDataFile); + + // Excel 객체 정리 + exportExcel.CloseExcelObjectsWithoutSaving(); + + progressBar.Value = 100; + UpdateStatus("✅ 매핑 병합 완료!"); + + LogMessage($"✅ Excel 매핑 병합 완료: {Path.GetFileName(completeExcelPath)}"); + LogMessage("📊 DWG 값과 PDF 값이 모두 포함된 통합 Excel 파일이 생성되었습니다."); + + System.Windows.MessageBox.Show( + $"Excel 매핑 병합이 완료되었습니다!\n\n" + + $"📄 사용된 JSON: {Path.GetFileName(latestJsonFile)}\n" + + $"📊 생성된 파일: {Path.GetFileName(completeExcelPath)}\n" + + $"✅ DWG 값과 PDF 값이 모두 포함된 통합 Excel 파일", + "완료", MessageBoxButton.OK, MessageBoxImage.Information); + + } + catch (Exception ex) + { + LogMessage($"❌ 매핑 병합 중 오류: {ex.Message}"); + throw; + } + + } + catch (Exception ex) + { + LogMessage($"❌ 매핑 병합 중 오류: {ex.Message}"); + throw; + } + } + + /// + /// JSON 파일의 내용을 미리보기로 보여줍니다. + /// + /// JSON 파일 경로 + private async Task ShowJsonPreview(string jsonFilePath) + { + try + { + LogMessage("📋 JSON 파일 내용 미리보기:"); + + string jsonContent = await File.ReadAllTextAsync(jsonFilePath); + using var jsonDocument = JsonDocument.Parse(jsonContent); + var root = jsonDocument.RootElement; + + // 메타데이터 정보 표시 + if (root.TryGetProperty("metadata", out var metadata)) + { + if (metadata.TryGetProperty("total_files", out var totalFiles)) + { + LogMessage($" 📊 총 파일 수: {totalFiles}"); + } + if (metadata.TryGetProperty("success_files", out var successFiles)) + { + LogMessage($" ✅ 성공 파일: {successFiles}"); + } + if (metadata.TryGetProperty("failed_files", out var failedFiles)) + { + LogMessage($" ❌ 실패 파일: {failedFiles}"); + } + if (metadata.TryGetProperty("generated_at", out var generatedAt)) + { + LogMessage($" 📅 생성시간: {generatedAt}"); + } + } + + // 결과 파일 수 표시 + if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) + { + var resultsArray = results.EnumerateArray().ToArray(); + LogMessage($" 📄 분석 결과: {resultsArray.Length}개 파일"); + + // 처음 몇 개 파일 이름 표시 + int showCount = Math.Min(3, resultsArray.Length); + for (int i = 0; i < showCount; i++) + { + if (resultsArray[i].TryGetProperty("file_info", out var fileInfo) && + fileInfo.TryGetProperty("name", out var fileName)) + { + LogMessage($" - {fileName.GetString()}"); + } + } + + if (resultsArray.Length > 3) + { + LogMessage($" ... 외 {resultsArray.Length - 3}개 파일"); + } + } + + LogMessage("📋 JSON 미리보기 완료"); + + } + catch (Exception ex) + { + LogMessage($"⚠️ JSON 미리보기 중 오류: {ex.Message}"); + } + } + protected override void OnClosed(EventArgs e) { _timer?.Stop(); diff --git a/Models/ExportExcel.cs b/Models/ExportExcel.cs index 4bf0861..5f1e922 100644 --- a/Models/ExportExcel.cs +++ b/Models/ExportExcel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; // COM 객체 해제를 위해 필요 @@ -9,6 +10,8 @@ using Teigha.DatabaseServices; using Teigha.Geometry; using Teigha.Runtime; using Excel = Microsoft.Office.Interop.Excel; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace DwgExtractorManual.Models { @@ -26,18 +29,70 @@ namespace DwgExtractorManual.Models private Excel.Workbook workbook1; private Excel.Worksheet titleBlockSheet; // Title Block용 시트 private Excel.Worksheet textEntitiesSheet; // Text Entities용 시트 + private Excel.Workbook mappingWorkbook; // 매핑 데이터용 워크북 + private Excel.Worksheet mappingSheet; // 매핑 데이터용 시트 + + readonly List MapKeys; + // 각 시트의 현재 행 번호 private int titleBlockCurrentRow = 2; // 헤더가 1행이므로 데이터는 2행부터 시작 private int textEntitiesCurrentRow = 2; // 헤더가 1행이므로 데이터는 2행부터 시작 + private int mappingDataCurrentRow = 2; // 헤더가 1행이므로 데이터는 2행부터 시작 + // 매핑 데이터 저장용 딕셔너리 - (AILabel, DwgTag, DwgValue, PdfValue) + private Dictionary> FileToMapkeyToLabelTagValuePdf = new Dictionary>(); + + private FieldMapper fieldMapper; // 생성자: ODA 및 Excel 초기화 public ExportExcel() { + try + { + Debug.WriteLine("🔄 FieldMapper 로딩 중: mapping_table_json.json..."); + fieldMapper = FieldMapper.LoadFromFile("fletimageanalysis/mapping_table_json.json"); + Debug.WriteLine("✅ FieldMapper 로딩 성공"); + MapKeys = fieldMapper.GetAllDocAiKeys(); + Debug.WriteLine($"📊 총 DocAI 키 개수: {MapKeys?.Count ?? 0}"); + + // 매핑 테스트 (디버깅용) + TestFieldMapper(); + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ FieldMapper 로딩 오류:"); + Debug.WriteLine($" 메시지: {ex.Message}"); + Debug.WriteLine($" 예외 타입: {ex.GetType().Name}"); + if (ex.InnerException != null) + { + Debug.WriteLine($" 내부 예외: {ex.InnerException.Message}"); + } + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); + throw; + } + + Debug.WriteLine("🔄 ODA 초기화 중..."); ActivateAndInitializeODA(); + Debug.WriteLine("🔄 Excel 초기화 중..."); InitializeExcel(); } + // 필드 매퍼 테스트 + private void TestFieldMapper() + { + Debug.WriteLine("[DEBUG] Testing field mapper..."); + + // 몇 가지 알려진 expressway 필드로 테스트 + var testFields = new[] { "TD_DNAME_MAIN", "TD_DWGNO", "TD_DWGCODE", "TB_MTITIL"}; + + foreach (var field in testFields) + { + var aiLabel = fieldMapper.ExpresswayToAilabel(field); + var docAiKey = fieldMapper.AilabelToDocAiKey(aiLabel); + Debug.WriteLine($"[DEBUG] Field: {field} -> AILabel: {aiLabel} -> DocAiKey: {docAiKey}"); + } + } + // ODA 제품 활성화 및 초기화 private void ActivateAndInitializeODA() { @@ -57,20 +112,26 @@ namespace DwgExtractorManual.Models excelApplication.Visible = false; // WPF에서는 숨김 처리 Excel.Workbook workbook = excelApp.Workbooks.Add(); workbook1 = workbook; - + // Title Block Sheet 설정 (기본 Sheet1) titleBlockSheet = (Excel.Worksheet)workbook.Sheets[1]; titleBlockSheet.Name = "Title Block"; SetupTitleBlockHeaders(); - + // Text Entities Sheet 추가 textEntitiesSheet = (Excel.Worksheet)workbook.Sheets.Add(); textEntitiesSheet.Name = "Text Entities"; SetupTextEntitiesHeaders(); + + // 매핑 데이터용 워크북 및 시트 생성 + mappingWorkbook = excelApp.Workbooks.Add(); + mappingSheet = (Excel.Worksheet)mappingWorkbook.Sheets[1]; + mappingSheet.Name = "Mapping Data"; + SetupMappingHeaders(); } catch (System.Exception ex) { - Console.WriteLine($"Excel 초기화 중 오류 발생: {ex.Message}"); + Debug.WriteLine($"Excel 초기화 중 오류 발생: {ex.Message}"); ReleaseExcelObjects(); throw; } @@ -108,6 +169,22 @@ namespace DwgExtractorManual.Models headerRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightGreen); } + // 매핑 데이터 시트 헤더 설정 + private void SetupMappingHeaders() + { + mappingSheet.Cells[1, 1] = "FileName"; // 파일 이름 + mappingSheet.Cells[1, 2] = "MapKey"; // 매핑 키 + mappingSheet.Cells[1, 3] = "AILabel"; // AI 라벨 + mappingSheet.Cells[1, 4] = "DwgTag"; // DWG Tag + mappingSheet.Cells[1, 5] = "Att_value"; // DWG 값 + mappingSheet.Cells[1, 6] = "Pdf_value"; // PDF 값 (현재는 빈 값) + + // 헤더 행 스타일 + Excel.Range headerRange = mappingSheet.Range["A1:F1"]; + headerRange.Font.Bold = true; + headerRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightYellow); + } + /// /// 단일 DWG 파일에서 AttributeReference/AttributeDefinition 데이터를 추출하여 /// 초기화된 Excel 워크시트에 추가합니다. @@ -118,31 +195,48 @@ namespace DwgExtractorManual.Models /// 성공 시 true, 실패 시 false 반환 public bool ExportDwgToExcel(string filePath, IProgress progress = null, CancellationToken cancellationToken = default) { + Debug.WriteLine($"[DEBUG] ExportDwgToExcel 시작: {filePath}"); + if (excelApplication == null) { - Console.WriteLine("Excel이 초기화되지 않았습니다."); + Debug.WriteLine("❌ Excel이 초기화되지 않았습니다."); + return false; + } + + if (!File.Exists(filePath)) + { + Debug.WriteLine($"❌ 파일이 존재하지 않습니다: {filePath}"); return false; } try { + Debug.WriteLine("[DEBUG] 진행률 0% 보고"); progress?.Report(0); cancellationToken.ThrowIfCancellationRequested(); + Debug.WriteLine("[DEBUG] ODA Database 객체 생성 중..."); // ODA Database 객체 생성 및 DWG 파일 읽기 using (var database = new Database(false, true)) { + Debug.WriteLine($"[DEBUG] DWG 파일 읽기 시도: {filePath}"); database.ReadDwgFile(filePath, FileOpenMode.OpenForReadAndWriteNoShare, false, null); + Debug.WriteLine("[DEBUG] DWG 파일 읽기 성공"); cancellationToken.ThrowIfCancellationRequested(); + Debug.WriteLine("[DEBUG] 진행률 10% 보고"); progress?.Report(10); + Debug.WriteLine("[DEBUG] 트랜잭션 시작 중..."); using (var tran = database.TransactionManager.StartTransaction()) { + Debug.WriteLine("[DEBUG] BlockTable 접근 중..."); var bt = tran.GetObject(database.BlockTableId, OpenMode.ForRead) as BlockTable; + Debug.WriteLine("[DEBUG] ModelSpace 접근 중..."); using (var btr = tran.GetObject(bt[BlockTableRecord.ModelSpace], OpenMode.ForRead) as BlockTableRecord) { int totalEntities = btr.Cast().Count(); + Debug.WriteLine($"[DEBUG] 총 엔티티 수: {totalEntities}"); int processedCount = 0; foreach (ObjectId entId in btr) @@ -153,7 +247,21 @@ namespace DwgExtractorManual.Models { // Layer 이름 가져오기 (공통) string layerName = GetLayerName(ent.LayerId, tran, database); - + var fileName = Path.GetFileNameWithoutExtension(database.Filename); + + // 파일명 유효성 검사 + if (string.IsNullOrEmpty(fileName)) + { + fileName = "Unknown_File"; + Debug.WriteLine($"[DEBUG] Using default filename: {fileName}"); + } + + // 파일별 매핑 딕셔너리 초기화 + if (!FileToMapkeyToLabelTagValuePdf.ContainsKey(fileName)) + { + FileToMapkeyToLabelTagValuePdf[fileName] = new Dictionary(); + } + // AttributeDefinition 추출 if (ent is AttributeDefinition attDef) { @@ -165,6 +273,35 @@ namespace DwgExtractorManual.Models titleBlockSheet.Cells[titleBlockCurrentRow, 6] = database.Filename; titleBlockSheet.Cells[titleBlockCurrentRow, 7] = Path.GetFileName(database.Filename); titleBlockCurrentRow++; + + var tag = attDef.Tag; + var aiLabel = fieldMapper.ExpresswayToAilabel(tag); + var mapKey = fieldMapper.AilabelToDocAiKey(aiLabel); + var attValue = attDef.TextString; + Debug.WriteLine($"[DEBUG] AttributeDefinition - Tag: {tag}, AILabel: {aiLabel}, MapKey: {mapKey}"); + + // 매핑 데이터 저장 + if (!string.IsNullOrEmpty(aiLabel)) + { + // mapKey가 null이면 aiLabel을 mapKey로 사용 + var finalMapKey = mapKey ?? aiLabel; + FileToMapkeyToLabelTagValuePdf[fileName][finalMapKey] = (aiLabel, tag, attValue, ""); + Debug.WriteLine($"[DEBUG] Added mapping: {tag} -> {aiLabel} (MapKey: {finalMapKey})"); + } + else + { + // aiLabel이 null이면 tag를 사용하여 저장 + var finalMapKey = mapKey ?? tag; + if (!string.IsNullOrEmpty(finalMapKey)) + { + FileToMapkeyToLabelTagValuePdf[fileName][finalMapKey] = (tag, tag, attValue, ""); + Debug.WriteLine($"[DEBUG] Added unmapped tag: {tag} -> {tag} (MapKey: {finalMapKey})"); + } + else + { + Debug.WriteLine($"[DEBUG] Skipped empty tag for AttributeDefinition"); + } + } } // BlockReference 및 그 안의 AttributeReference 추출 else if (ent is BlockReference blr) @@ -175,7 +312,7 @@ namespace DwgExtractorManual.Models using (var attRef = tran.GetObject(attId, OpenMode.ForRead) as AttributeReference) { - if (attRef != null && attRef.TextString.Trim() !="") + if (attRef != null && attRef.TextString.Trim() != "") { titleBlockSheet.Cells[titleBlockCurrentRow, 1] = attRef.GetType().Name; titleBlockSheet.Cells[titleBlockCurrentRow, 2] = blr.Name; @@ -186,6 +323,37 @@ namespace DwgExtractorManual.Models titleBlockSheet.Cells[titleBlockCurrentRow, 6] = database.Filename; titleBlockSheet.Cells[titleBlockCurrentRow, 7] = Path.GetFileName(database.Filename); titleBlockCurrentRow++; + + var tag = attRef.Tag; + var aiLabel = fieldMapper.ExpresswayToAilabel(tag); + if (aiLabel == null) continue; + var mapKey = fieldMapper.AilabelToDocAiKey(aiLabel); + var attValue = attRef.TextString; + + Debug.WriteLine($"[DEBUG] AttributeReference - Tag: {tag}, AILabel: {aiLabel}, MapKey: {mapKey}"); + + // 매핑 데이터 저장 + if (!string.IsNullOrEmpty(aiLabel)) + { + // mapKey가 null이면 aiLabel을 mapKey로 사용 + var finalMapKey = mapKey ?? aiLabel; + FileToMapkeyToLabelTagValuePdf[fileName][finalMapKey] = (aiLabel, tag, attValue, ""); + Debug.WriteLine($"[DEBUG] Added mapping: {tag} -> {aiLabel} (MapKey: {finalMapKey})"); + } + else + { + // aiLabel이 null이면 tag를 사용하여 저장 + var finalMapKey = mapKey ?? tag; + if (!string.IsNullOrEmpty(finalMapKey)) + { + FileToMapkeyToLabelTagValuePdf[fileName][finalMapKey] = (tag, tag, attValue, ""); + Debug.WriteLine($"[DEBUG] Added unmapped tag: {tag} -> {tag} (MapKey: {finalMapKey})"); + } + else + { + Debug.WriteLine($"[DEBUG] Skipped empty tag for AttributeReference"); + } + } } } } @@ -212,6 +380,8 @@ namespace DwgExtractorManual.Models } } + // write values in new excel file with FileToMapkeyToLabelTag + processedCount++; double currentProgress = 10.0 + (double)processedCount / totalEntities * 80.0; progress?.Report(Math.Min(currentProgress, 90.0)); @@ -220,6 +390,13 @@ namespace DwgExtractorManual.Models tran.Commit(); } + + Debug.WriteLine($"[DEBUG] Transaction committed. Total mapping entries collected: {FileToMapkeyToLabelTagValuePdf.Sum(f => f.Value.Count)}"); + + // 매핑 데이터를 Excel 시트에 기록 + Debug.WriteLine("[DEBUG] 매핑 데이터를 Excel에 기록 중..."); + WriteMappingDataToExcel(); + Debug.WriteLine("[DEBUG] 매핑 데이터 Excel 기록 완료"); } progress?.Report(100); @@ -227,29 +404,330 @@ namespace DwgExtractorManual.Models } catch (OperationCanceledException) { + Debug.WriteLine("❌ 작업이 취소되었습니다."); progress?.Report(0); return false; } catch (Teigha.Runtime.Exception ex) { progress?.Report(0); - Console.WriteLine($"DWG 파일 처리 중 오류 발생: {ex.Message}"); + Debug.WriteLine($"❌ DWG 파일 처리 중 Teigha 오류 발생:"); + Debug.WriteLine($" 메시지: {ex.Message}"); + Debug.WriteLine($" ErrorStatus: {ex.ErrorStatus}"); + if (ex.InnerException != null) + { + Debug.WriteLine($" 내부 예외: {ex.InnerException.Message}"); + } + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); return false; } catch (System.Exception ex) { progress?.Report(0); - Console.WriteLine($"일반 오류 발생: {ex.Message}"); + Debug.WriteLine($"❌ 일반 오류 발생:"); + Debug.WriteLine($" 메시지: {ex.Message}"); + Debug.WriteLine($" 예외 타입: {ex.GetType().Name}"); + if (ex.InnerException != null) + { + Debug.WriteLine($" 내부 예외: {ex.InnerException.Message}"); + } + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); return false; } } + + // 매핑 데이터를 Excel 시트에 기록 + private void WriteMappingDataToExcel() + { + try + { + int currentRow = 2; // 헤더 다음 행부터 시작 + + Debug.WriteLine($"[DEBUG] Writing mapping data to Excel. Total files: {FileToMapkeyToLabelTagValuePdf.Count}"); + Debug.WriteLine($"[DEBUG] 시작 행: {currentRow}"); + + foreach (var fileEntry in FileToMapkeyToLabelTagValuePdf) + { + string fileName = fileEntry.Key; + var mappingData = fileEntry.Value; + + Debug.WriteLine($"[DEBUG] Processing file: {fileName}, entries: {mappingData.Count}"); + + foreach (var mapEntry in mappingData) + { + string mapKey = mapEntry.Key; + (string aiLabel, string dwgTag, string attValue, string pdfValue) = mapEntry.Value; + + // null 값 방지 + if (string.IsNullOrEmpty(fileName) || string.IsNullOrEmpty(mapKey)) + { + Debug.WriteLine($"[DEBUG] Skipping entry with null/empty values: fileName={fileName}, mapKey={mapKey}"); + continue; + } + + Debug.WriteLine($"[DEBUG] Writing row {currentRow}: {fileName} | {mapKey} | {aiLabel} | {dwgTag} | PDF: {pdfValue}"); + + try + { + mappingSheet.Cells[currentRow, 1] = fileName; // FileName + mappingSheet.Cells[currentRow, 2] = mapKey; // MapKey + mappingSheet.Cells[currentRow, 3] = aiLabel ?? ""; // AILabel + mappingSheet.Cells[currentRow, 4] = dwgTag ?? ""; // DwgTag + mappingSheet.Cells[currentRow, 5] = attValue ?? ""; // DwgValue (Att_value) + mappingSheet.Cells[currentRow, 6] = pdfValue ?? ""; // PdfValue (Pdf_value) + + Debug.WriteLine($"[DEBUG] Row {currentRow} written successfully"); + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ Error writing row {currentRow}: {ex.Message}"); + } + + currentRow++; + } + } + + Debug.WriteLine($"[DEBUG] Mapping data written to Excel. Total rows: {currentRow - 2}"); + Debug.WriteLine($"[DEBUG] 최종 행 번호: {currentRow}"); + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ 매핑 데이터 Excel 기록 중 오류:"); + Debug.WriteLine($" 메시지: {ex.Message}"); + Debug.WriteLine($" 예외 타입: {ex.GetType().Name}"); + if (ex.InnerException != null) + { + Debug.WriteLine($" 내부 예외: {ex.InnerException.Message}"); + } + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); + throw; // 상위 메서드로 예외 전파 + } + } + + /// + /// 기존 Excel 파일을 열어 JSON 파일의 PDF 분석 결과로 매핑 시트를 업데이트합니다. + /// + /// 기존 Excel 파일 경로 + /// PDF 분석 결과 JSON 파일 경로 + /// 성공 시 true, 실패 시 false + public bool UpdateExistingExcelWithJson(string excelFilePath, string jsonFilePath) + { + try + { + Debug.WriteLine($"[DEBUG] 기존 Excel 파일 업데이트 시작: {excelFilePath}"); + + if (!File.Exists(excelFilePath)) + { + Debug.WriteLine($"❌ Excel 파일이 존재하지 않습니다: {excelFilePath}"); + return false; + } + + if (!File.Exists(jsonFilePath)) + { + Debug.WriteLine($"❌ JSON 파일이 존재하지 않습니다: {jsonFilePath}"); + return false; + } + + Debug.WriteLine($"[DEBUG] Excel 애플리케이션 초기화 중..."); + + // 기존 Excel 파일 열기 + if (excelApplication == null) + { + excelApplication = new Excel.Application(); + excelApplication.Visible = false; + Debug.WriteLine("[DEBUG] 새 Excel 애플리케이션 생성됨"); + } + + Debug.WriteLine($"[DEBUG] Excel 파일 열기 시도: {excelFilePath}"); + mappingWorkbook = excelApplication.Workbooks.Open(excelFilePath); + Debug.WriteLine("[DEBUG] Excel 파일 열기 성공"); + + Debug.WriteLine("[DEBUG] 'Mapping Data' 시트 찾는 중..."); + mappingSheet = (Excel.Worksheet)mappingWorkbook.Sheets["Mapping Data"]; + + if (mappingSheet == null) + { + Debug.WriteLine("❌ 'Mapping Data' 시트를 찾을 수 없습니다."); + // 사용 가능한 시트 목록 출력 + Debug.WriteLine("사용 가능한 시트:"); + foreach (Excel.Worksheet sheet in mappingWorkbook.Sheets) + { + Debug.WriteLine($" - {sheet.Name}"); + } + return false; + } + + Debug.WriteLine("✅ 기존 Excel 파일 열기 성공"); + + // JSON에서 PDF 값 업데이트 + Debug.WriteLine("[DEBUG] JSON 파싱 및 업데이트 시작"); + bool result = UpdateMappingSheetFromJson(jsonFilePath); + + Debug.WriteLine($"[DEBUG] 업데이트 결과: {result}"); + return result; + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ 기존 Excel 파일 업데이트 중 오류:"); + Debug.WriteLine($" 메시지: {ex.Message}"); + Debug.WriteLine($" 예외 타입: {ex.GetType().Name}"); + if (ex.InnerException != null) + { + Debug.WriteLine($" 내부 예외: {ex.InnerException.Message}"); + } + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); + return false; + } + } + + /// + /// JSON 파일에서 PDF 분석 결과를 읽어 Excel 매핑 시트의 Pdf_value 컬럼을 업데이트합니다. + /// + /// PDF 분석 결과 JSON 파일 경로 + /// 성공 시 true, 실패 시 false + public bool UpdateMappingSheetFromJson(string jsonFilePath) + { + try + { + Debug.WriteLine($"[DEBUG] JSON 파일에서 PDF 값 업데이트 시작: {jsonFilePath}"); + + if (!File.Exists(jsonFilePath)) + { + Debug.WriteLine($"❌ JSON 파일이 존재하지 않습니다: {jsonFilePath}"); + return false; + } + + if (mappingSheet == null) + { + Debug.WriteLine("❌ 매핑 시트가 초기화되지 않았습니다."); + return false; + } + + // JSON 파일 읽기 + string jsonContent = File.ReadAllText(jsonFilePath); + JObject jsonData = JObject.Parse(jsonContent); + + var results = jsonData["results"] as JArray; + if (results == null) + { + Debug.WriteLine("❌ JSON에서 'results' 배열을 찾을 수 없습니다."); + return false; + } + + int updatedCount = 0; + int totalEntries = 0; + + foreach (JObject result in results) + { + var fileInfo = result["file_info"]; + var pdfAnalysis = result["pdf_analysis"]; + + if (fileInfo == null || pdfAnalysis == null) continue; + + string fileName = fileInfo["name"]?.ToString(); + if (string.IsNullOrEmpty(fileName)) continue; + + // 파일 확장자 제거 + string fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName); + Debug.WriteLine($"[DEBUG] Processing PDF file: {fileNameWithoutExt}"); + + // PDF 분석 결과의 각 필드 처리 + foreach (var property in pdfAnalysis.Cast()) + { + string aiLabel = property.Name; // 예: "설계공구_Station_col1" + var valueObj = property.Value as JObject; + + if (valueObj == null) continue; + + string pdfValue = valueObj["value"]?.ToString(); + if (string.IsNullOrEmpty(pdfValue)) continue; + + totalEntries++; + Debug.WriteLine($"[DEBUG] Searching for match: FileName={fileNameWithoutExt}, AILabel={aiLabel}, Value={pdfValue}"); + + // Excel 시트에서 매칭되는 행 찾기 및 업데이트 + if (UpdateExcelRow(fileNameWithoutExt, aiLabel, pdfValue)) + { + updatedCount++; + Debug.WriteLine($"✅ Updated: {fileNameWithoutExt} -> {aiLabel} = {pdfValue}"); + } + else + { + Debug.WriteLine($"⚠️ No match found: {fileNameWithoutExt} -> {aiLabel}"); + } + } + } + + Debug.WriteLine($"[DEBUG] PDF 값 업데이트 완료: {updatedCount}/{totalEntries} 업데이트됨"); + return true; + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ JSON에서 PDF 값 업데이트 중 오류:"); + Debug.WriteLine($" 메시지: {ex.Message}"); + Debug.WriteLine($" 예외 타입: {ex.GetType().Name}"); + if (ex.InnerException != null) + { + Debug.WriteLine($" 내부 예외: {ex.InnerException.Message}"); + } + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); + return false; + } + } + + /// + /// Excel 매핑 시트에서 FileName과 AILabel이 매칭되는 행을 찾아 Pdf_value를 업데이트합니다. + /// + /// 파일명 (확장자 제외) + /// AI 라벨 (예: "설계공구_Station_col1") + /// PDF에서 추출된 값 + /// 업데이트 성공 시 true + private bool UpdateExcelRow(string fileName, string aiLabel, string pdfValue) + { + try + { + // Excel 시트의 마지막 사용된 행 찾기 + Excel.Range usedRange = mappingSheet.UsedRange; + if (usedRange == null) return false; + + int lastRow = usedRange.Rows.Count; + + // 2행부터 검색 (1행은 헤더) + for (int row = 2; row <= lastRow; row++) + { + // Column 1: FileName, Column 3: AILabel 확인 + var cellFileName = mappingSheet.Cells[row, 1]?.Value?.ToString() ?? ""; + var cellAiLabel = mappingSheet.Cells[row, 3]?.Value?.ToString() ?? ""; + + Debug.WriteLine($"[DEBUG] Row {row}: FileName='{cellFileName}', AILabel='{cellAiLabel}'"); + + // 매칭 확인 (대소문자 구분 없이) + if (string.Equals(cellFileName.Trim(), fileName.Trim(), StringComparison.OrdinalIgnoreCase) && + string.Equals(cellAiLabel.Trim(), aiLabel.Trim(), StringComparison.OrdinalIgnoreCase)) + { + // Column 6: Pdf_value에 값 기록 + mappingSheet.Cells[row, 6] = pdfValue; + Debug.WriteLine($"[DEBUG] Updated row {row}, column 6 with value: {pdfValue}"); + return true; + } + } + + return false; // 매칭되는 행을 찾지 못함 + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ Excel 행 업데이트 중 오류: {ex.Message}"); + return false; + } + } + // Paste the helper function from above here public string GetPromptFromAttributeReference(Transaction tr, BlockReference blockref, string tag) { - + string prompt = null; - + BlockTableRecord blockDef = tr.GetObject(blockref.BlockTableRecord, OpenMode.ForRead) as BlockTableRecord; if (blockDef == null) return null; @@ -265,10 +743,80 @@ namespace DwgExtractorManual.Models } } } - + return prompt; } + + /// + /// 현재 열려있는 Excel 워크북을 저장합니다. + /// + /// 성공 시 true, 실패 시 false + public bool SaveExcel() + { + try + { + if (mappingWorkbook != null) + { + mappingWorkbook.Save(); + Debug.WriteLine("✅ Excel 파일 저장 완료"); + return true; + } + + if (workbook1 != null) + { + workbook1.Save(); + Debug.WriteLine("✅ Excel 파일 저장 완료"); + return true; + } + + Debug.WriteLine("❌ 저장할 워크북이 없습니다."); + return false; + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ Excel 파일 저장 중 오류: {ex.Message}"); + return false; + } + } + + /// + /// 매핑 워크북만 저장하고 닫습니다 (완전한 매핑용). + /// + /// 저장할 파일 경로 + public void SaveMappingWorkbookOnly(string savePath) + { + try + { + Debug.WriteLine($"[DEBUG] 매핑 워크북 저장 시작: {savePath}"); + + if (mappingWorkbook == null) + { + Debug.WriteLine("❌ 매핑 워크북이 초기화되지 않았습니다."); + return; + } + + string directory = Path.GetDirectoryName(savePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // 매핑 워크북만 저장 (Excel 2007+ 형식으로) + mappingWorkbook.SaveAs(savePath, + FileFormat: Excel.XlFileFormat.xlOpenXMLWorkbook, + AccessMode: Excel.XlSaveAsAccessMode.xlNoChange); + + Debug.WriteLine($"✅ 매핑 워크북 저장 완료: {Path.GetFileName(savePath)}"); + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ 매핑 워크북 저장 중 오류: {ex.Message}"); + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); + throw; + } + } + /// /// 현재 Excel 워크북을 지정된 경로에 저장하고 Excel 애플리케이션을 종료합니다. /// @@ -286,10 +834,17 @@ namespace DwgExtractorManual.Models } workbook1.SaveAs(savePath, AccessMode: Excel.XlSaveAsAccessMode.xlNoChange); + + // 매핑 데이터 워크북도 저장 + if (mappingWorkbook != null) + { + string mappingPath = Path.Combine(directory, Path.GetFileNameWithoutExtension(savePath) + "_Mapping.xlsx"); + mappingWorkbook.SaveAs(mappingPath, AccessMode: Excel.XlSaveAsAccessMode.xlNoChange); + } } catch (System.Exception ex) { - Console.WriteLine($"Excel 파일 저장 중 오류 발생: {ex.Message}"); + Debug.WriteLine($"Excel 파일 저장 중 오류 발생: {ex.Message}"); } finally { @@ -297,6 +852,41 @@ namespace DwgExtractorManual.Models } } + /// + /// Excel 객체들을 닫습니다 (저장하지 않음). + /// + public void CloseExcelObjectsWithoutSaving() + { + try + { + Debug.WriteLine("[DEBUG] Excel 객체 정리 시작"); + + if (workbook1 != null) + { + try { workbook1.Close(false); } + catch { } + } + if (mappingWorkbook != null) + { + try { mappingWorkbook.Close(false); } + catch { } + } + if (excelApplication != null) + { + try { excelApplication.Quit(); } + catch { } + } + + ReleaseExcelObjects(); + + Debug.WriteLine("✅ Excel 객체 정리 완료"); + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ Excel 객체 정리 중 오류: {ex.Message}"); + } + } + private void CloseExcelObjects() { if (workbook1 != null) @@ -304,6 +894,11 @@ namespace DwgExtractorManual.Models try { workbook1.Close(false); } catch { } } + if (mappingWorkbook != null) + { + try { mappingWorkbook.Close(false); } + catch { } + } if (excelApplication != null) { try { excelApplication.Quit(); } @@ -317,12 +912,16 @@ namespace DwgExtractorManual.Models { ReleaseComObject(titleBlockSheet); ReleaseComObject(textEntitiesSheet); + ReleaseComObject(mappingSheet); ReleaseComObject(workbook1); + ReleaseComObject(mappingWorkbook); ReleaseComObject(excelApplication); titleBlockSheet = null; textEntitiesSheet = null; + mappingSheet = null; workbook1 = null; + mappingWorkbook = null; excelApplication = null; } @@ -363,11 +962,273 @@ namespace DwgExtractorManual.Models } catch (System.Exception ex) { - Console.WriteLine($"Layer 이름 가져오기 오류: {ex.Message}"); + Debug.WriteLine($"Layer 이름 가져오기 오류: {ex.Message}"); return ""; } } + /// + /// 매핑 딕셔너리를 JSON 파일로 저장합니다. + /// + /// 저장할 JSON 파일 경로 + public void SaveMappingDictionary(string filePath) + { + try + { + Debug.WriteLine($"[DEBUG] 매핑 딕셔너리 저장 시작: {filePath}"); + + // 딕셔너리를 직렬화 가능한 형태로 변환 + var serializableData = new Dictionary>(); + + foreach (var fileEntry in FileToMapkeyToLabelTagValuePdf) + { + var fileData = new Dictionary(); + foreach (var mapEntry in fileEntry.Value) + { + fileData[mapEntry.Key] = new + { + AILabel = mapEntry.Value.Item1, + DwgTag = mapEntry.Value.Item2, + DwgValue = mapEntry.Value.Item3, + PdfValue = mapEntry.Value.Item4 + }; + } + serializableData[fileEntry.Key] = fileData; + } + + // JSON으로 직렬화 + string jsonContent = JsonConvert.SerializeObject(serializableData, Formatting.Indented); + File.WriteAllText(filePath, jsonContent, System.Text.Encoding.UTF8); + + Debug.WriteLine($"✅ 매핑 딕셔너리 저장 완료: {Path.GetFileName(filePath)}"); + Debug.WriteLine($"📊 저장된 파일 수: {FileToMapkeyToLabelTagValuePdf.Count}"); + + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ 매핑 딕셔너리 저장 중 오류:"); + Debug.WriteLine($" 메시지: {ex.Message}"); + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); + throw; + } + } + + /// + /// JSON 파일에서 매핑 딕셔너리를 로드합니다. + /// + /// 로드할 JSON 파일 경로 + public void LoadMappingDictionary(string filePath) + { + try + { + Debug.WriteLine($"[DEBUG] 매핑 딕셔너리 로드 시작: {filePath}"); + + if (!File.Exists(filePath)) + { + Debug.WriteLine($"⚠️ 매핑 파일이 존재하지 않습니다: {filePath}"); + return; + } + + string jsonContent = File.ReadAllText(filePath, System.Text.Encoding.UTF8); + Debug.WriteLine($"[DEBUG] JSON 내용 길이: {jsonContent.Length}"); + + var deserializedData = JsonConvert.DeserializeObject>>(jsonContent); + + // 새로운 딕셔너리 초기화 + FileToMapkeyToLabelTagValuePdf.Clear(); + Debug.WriteLine("[DEBUG] 기존 딕셔너리 초기화됨"); + + if (deserializedData != null) + { + Debug.WriteLine($"[DEBUG] 역직렬화된 파일 수: {deserializedData.Count}"); + + foreach (var fileEntry in deserializedData) + { + Debug.WriteLine($"[DEBUG] 파일 처리 중: {fileEntry.Key}"); + var fileData = new Dictionary(); + + foreach (var mapEntry in fileEntry.Value) + { + var valueObj = mapEntry.Value; + string aiLabel = valueObj["AILabel"]?.ToString() ?? ""; + string dwgTag = valueObj["DwgTag"]?.ToString() ?? ""; + string dwgValue = valueObj["DwgValue"]?.ToString() ?? ""; + string pdfValue = valueObj["PdfValue"]?.ToString() ?? ""; + + fileData[mapEntry.Key] = (aiLabel, dwgTag, dwgValue, pdfValue); + Debug.WriteLine($"[DEBUG] 항목 로드: {mapEntry.Key} -> AI:{aiLabel}, DWG:{dwgTag}, PDF:{pdfValue}"); + } + + FileToMapkeyToLabelTagValuePdf[fileEntry.Key] = fileData; + Debug.WriteLine($"[DEBUG] 파일 {fileEntry.Key}: {fileData.Count}개 항목 로드됨"); + } + } + + Debug.WriteLine($"✅ 매핑 딕셔너리 로드 완료"); + Debug.WriteLine($"📊 로드된 파일 수: {FileToMapkeyToLabelTagValuePdf.Count}"); + Debug.WriteLine($"📊 총 항목 수: {FileToMapkeyToLabelTagValuePdf.Sum(f => f.Value.Count)}"); + + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ 매핑 딕셔너리 로드 중 오류:"); + Debug.WriteLine($" 메시지: {ex.Message}"); + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); + throw; + } + } + + /// + /// 매핑 딕셔너리 데이터를 Excel 시트에 기록합니다 (완전한 매핑용). + /// + public void WriteCompleteMapping() + { + try + { + Debug.WriteLine("[DEBUG] WriteCompleteMapping 시작"); + Debug.WriteLine($"[DEBUG] 매핑 딕셔너리 항목 수: {FileToMapkeyToLabelTagValuePdf.Count}"); + + if (FileToMapkeyToLabelTagValuePdf.Count == 0) + { + Debug.WriteLine("⚠️ 매핑 딕셔너리가 비어있습니다."); + return; + } + + if (mappingSheet == null) + { + Debug.WriteLine("❌ 매핑 시트가 초기화되지 않았습니다."); + return; + } + + Debug.WriteLine($"[DEBUG] 매핑 시트 이름: {mappingSheet.Name}"); + Debug.WriteLine($"[DEBUG] 전체 데이터 항목 수: {FileToMapkeyToLabelTagValuePdf.Sum(f => f.Value.Count)}"); + + // 샘플 데이터 출력 (처음 3개) + int sampleCount = 0; + foreach (var fileEntry in FileToMapkeyToLabelTagValuePdf.Take(2)) + { + Debug.WriteLine($"[DEBUG] 파일: {fileEntry.Key}"); + foreach (var mapEntry in fileEntry.Value.Take(3)) + { + var (aiLabel, dwgTag, dwgValue, pdfValue) = mapEntry.Value; + Debug.WriteLine($"[DEBUG] {mapEntry.Key} -> AI:{aiLabel}, DWG:{dwgTag}, DWGVAL:{dwgValue}, PDF:{pdfValue}"); + sampleCount++; + } + } + + // 기존 WriteMappingDataToExcel 메서드 호출 + WriteMappingDataToExcel(); + + Debug.WriteLine("✅ WriteCompleteMapping 완료"); + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ WriteCompleteMapping 중 오류:"); + Debug.WriteLine($" 메시지: {ex.Message}"); + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); + throw; + } + } + + /// + /// PDF 분석 결과 JSON으로 매핑 딕셔너리의 PdfValue를 업데이트합니다. + /// + /// PDF 분석 결과 JSON 파일 경로 + public void UpdateWithPdfData(string jsonFilePath) + { + try + { + Debug.WriteLine($"[DEBUG] PDF 데이터로 매핑 딕셔너리 업데이트 시작: {jsonFilePath}"); + + if (!File.Exists(jsonFilePath)) + { + Debug.WriteLine($"❌ JSON 파일이 존재하지 않습니다: {jsonFilePath}"); + return; + } + + // JSON 파일 읽기 + string jsonContent = File.ReadAllText(jsonFilePath); + JObject jsonData = JObject.Parse(jsonContent); + + var results = jsonData["results"] as JArray; + if (results == null) + { + Debug.WriteLine("❌ JSON에서 'results' 배열을 찾을 수 없습니다."); + return; + } + + int updatedCount = 0; + int totalEntries = 0; + + foreach (JObject result in results) + { + var fileInfo = result["file_info"]; + var pdfAnalysis = result["pdf_analysis"]; + + if (fileInfo == null || pdfAnalysis == null) continue; + + string fileName = fileInfo["name"]?.ToString(); + if (string.IsNullOrEmpty(fileName)) continue; + + // 파일 확장자 제거 + string fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName); + Debug.WriteLine($"[DEBUG] Processing PDF file: {fileNameWithoutExt}"); + + // 해당 파일의 매핑 데이터 확인 + if (!FileToMapkeyToLabelTagValuePdf.ContainsKey(fileNameWithoutExt)) + { + Debug.WriteLine($"⚠️ 매핑 데이터에 파일이 없습니다: {fileNameWithoutExt}"); + continue; + } + + // PDF 분석 결과의 각 필드 처리 + foreach (var property in pdfAnalysis.Cast()) + { + string aiLabel = property.Name; // 예: "설계공구_Station_col1" + var valueObj = property.Value as JObject; + + if (valueObj == null) continue; + + string pdfValue = valueObj["value"]?.ToString(); + if (string.IsNullOrEmpty(pdfValue)) continue; + + totalEntries++; + + // 매핑 딕셔너리에서 해당 항목 찾기 + var fileData = FileToMapkeyToLabelTagValuePdf[fileNameWithoutExt]; + + // AILabel로 매칭 찾기 + var matchingEntry = fileData.FirstOrDefault(kvp => + string.Equals(kvp.Value.Item1.Trim(), aiLabel.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrEmpty(matchingEntry.Key)) + { + // 기존 값 유지하면서 PdfValue만 업데이트 + var existingValue = matchingEntry.Value; + fileData[matchingEntry.Key] = (existingValue.Item1, existingValue.Item2, existingValue.Item3, pdfValue); + updatedCount++; + + Debug.WriteLine($"✅ Updated: {fileNameWithoutExt} -> {aiLabel} = {pdfValue}"); + } + else + { + Debug.WriteLine($"⚠️ No match found: {fileNameWithoutExt} -> {aiLabel}"); + } + } + } + + Debug.WriteLine($"[DEBUG] PDF 데이터 업데이트 완료: {updatedCount}/{totalEntries} 업데이트됨"); + + } + catch (System.Exception ex) + { + Debug.WriteLine($"❌ PDF 데이터 업데이트 중 오류:"); + Debug.WriteLine($" 메시지: {ex.Message}"); + Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}"); + throw; + } + } + public void Dispose() { if (excelApplication != null) diff --git a/cleanup_and_setup.bat b/cleanup_and_setup.bat new file mode 100644 index 0000000..402f204 --- /dev/null +++ b/cleanup_and_setup.bat @@ -0,0 +1,67 @@ +@echo off +echo Cleaning up fletimageanalysis folder for CLI-only processing... + +cd fletimageanalysis + +REM Delete unnecessary test files +del /q test_*.py 2>nul +del /q python_mapping_usage.py 2>nul + +REM Delete backup and alternative files +del /q *_backup.py 2>nul +del /q *_previous.py 2>nul +del /q *_fixed.py 2>nul +del /q cross_tabulated_csv_exporter_*.py 2>nul + +REM Delete documentation files +del /q *.md 2>nul +del /q LICENSE 2>nul +del /q .gitignore 2>nul + +REM Delete directories not needed for CLI +rmdir /s /q back_src 2>nul +rmdir /s /q docs 2>nul +rmdir /s /q testsample 2>nul +rmdir /s /q uploads 2>nul +rmdir /s /q assets 2>nul +rmdir /s /q results 2>nul +rmdir /s /q __pycache__ 2>nul +rmdir /s /q .vscode 2>nul +rmdir /s /q .git 2>nul +rmdir /s /q .gemini 2>nul +rmdir /s /q .venv 2>nul + +echo Essential files for CLI processing: +echo - batch_cli.py +echo - config.py +echo - multi_file_processor.py +echo - pdf_processor.py +echo - dxf_processor.py +echo - gemini_analyzer.py +echo - csv_exporter.py +echo - requirements.txt +echo - mapping_table_json.json +echo - .env + +echo. +echo Creating virtual environment... +python -m venv venv + +echo. +echo Activating virtual environment and installing packages... +call venv\Scripts\activate.bat +pip install --upgrade pip +pip install PyMuPDF google-genai Pillow ezdxf numpy python-dotenv pandas requests + +echo. +echo Testing installation... +python -c "import fitz; print('✓ PyMuPDF OK')" +python -c "import google.genai; print('✓ Gemini API OK')" +python -c "import pandas; print('✓ Pandas OK')" +python -c "import ezdxf; print('✓ EZDXF OK')" + +echo. +echo Setup complete! +echo Virtual environment created at: fletimageanalysis\venv\ +echo. +pause \ No newline at end of file diff --git a/csharp_mapping_usage.cs b/csharp_mapping_usage.cs index 282dd1a..8c427d0 100644 --- a/csharp_mapping_usage.cs +++ b/csharp_mapping_usage.cs @@ -79,6 +79,18 @@ public class FieldMapper return null; } + /// + /// AI 라벨을 DocAiKey 값으로 변환 + /// + public string AilabelToDocAiKey(string ailabel) + { + if (_mappingData.MappingTable.AilabelToSystems.TryGetValue(ailabel, out var systemFields)) + { + return systemFields.DocAiKey; + } + return null; + } + /// /// 고속도로공사 필드명을 교통부 필드명으로 변환 /// @@ -91,6 +103,46 @@ public class FieldMapper return null; } + /// + /// DocAiKey 값으로부터 해당하는 AI 라벨을 반환 + /// + public string DocAiKeyToAilabel(string docAiKey) + { + if (string.IsNullOrEmpty(docAiKey)) + { + return null; + } + + foreach (var kvp in _mappingData.MappingTable.AilabelToSystems) + { + if (kvp.Value.DocAiKey == docAiKey) + { + return kvp.Key; + } + } + return null; + } + + /// + /// Expressway 필드값으로부터 해당하는 AI 라벨을 반환 + /// + public string ExpresswayToAilabel(string expresswayField) + { + if (string.IsNullOrEmpty(expresswayField)) + { + return null; + } + + foreach (var kvp in _mappingData.MappingTable.AilabelToSystems) + { + if (kvp.Value.Expressway == expresswayField) + { + return kvp.Key; + } + } + return null; + } + /// /// AI 라벨 → 고속도로공사 → 교통부 순서로 변환 /// @@ -141,102 +193,121 @@ public class FieldMapper } return results; } + + /// + /// 매핑 테이블에서 모든 DocAiKey 값의 목록을 반환합니다. + /// + public List GetAllDocAiKeys() + { + var docAiKeys = new List(); + + foreach (var kvp in _mappingData.MappingTable.AilabelToSystems) + { + var docAiKey = kvp.Value.DocAiKey; + if (!string.IsNullOrEmpty(docAiKey)) + { + docAiKeys.Add(docAiKey); + } + } + + return docAiKeys; + } } // 사용 예제 프로그램 -class Program -{ - static void Main(string[] args) - { - try - { - // 매핑 테이블 로드 - var mapper = FieldMapper.LoadFromFile("mapping_table.json"); + +//class Program +//{ +// static void Main(string[] args) +// { +// try +// { +// // 매핑 테이블 로드 +// var mapper = FieldMapper.LoadFromFile("mapping_table.json"); - Console.WriteLine("=== AI 라벨 → 고속도로공사 필드명 변환 ==="); - var testLabels = new[] { "도면명", "편철번호", "도면번호", "Main Title", "계정번호" }; +// Console.WriteLine("=== AI 라벨 → 고속도로공사 필드명 변환 ==="); +// var testLabels = new[] { "도면명", "편철번호", "도면번호", "Main Title", "계정번호" }; - foreach (var label in testLabels) - { - var expresswayField = mapper.AilabelToExpressway(label); - Console.WriteLine($"{label} → {expresswayField ?? "N/A"}"); - } +// foreach (var label in testLabels) +// { +// var expresswayField = mapper.AilabelToExpressway(label); +// Console.WriteLine($"{label} → {expresswayField ?? "N/A"}"); +// } - Console.WriteLine("\n=== 고속도로공사 → 교통부 필드명 변환 ==="); - var expresswayFields = new[] { "TD_DNAME_MAIN", "TD_DWGNO", "TD_DWGCODE", "TR_RNUM1" }; +// Console.WriteLine("\n=== 고속도로공사 → 교통부 필드명 변환 ==="); +// var expresswayFields = new[] { "TD_DNAME_MAIN", "TD_DWGNO", "TD_DWGCODE", "TR_RNUM1" }; - foreach (var field in expresswayFields) - { - var transportationField = mapper.ExpresswayToTransportation(field); - Console.WriteLine($"{field} → {transportationField ?? "N/A"}"); - } +// foreach (var field in expresswayFields) +// { +// var transportationField = mapper.ExpresswayToTransportation(field); +// Console.WriteLine($"{field} → {transportationField ?? "N/A"}"); +// } - Console.WriteLine("\n=== AI 라벨 → 고속도로공사 → 교통부 (연속 변환) ==="); - foreach (var label in testLabels) - { - var expresswayField = mapper.AilabelToExpressway(label); - var transportationField = mapper.AilabelToTransportationViaExpressway(label); - Console.WriteLine($"{label} → {expresswayField ?? "N/A"} → {transportationField ?? "N/A"}"); - } +// Console.WriteLine("\n=== AI 라벨 → 고속도로공사 → 교통부 (연속 변환) ==="); +// foreach (var label in testLabels) +// { +// var expresswayField = mapper.AilabelToExpressway(label); +// var transportationField = mapper.AilabelToTransportationViaExpressway(label); +// Console.WriteLine($"{label} → {expresswayField ?? "N/A"} → {transportationField ?? "N/A"}"); +// } - Console.WriteLine("\n=== 특정 AI 라벨의 모든 시스템 필드명 ==="); - var allFields = mapper.GetAllSystemFields("도면명"); - if (allFields != null) - { - Console.WriteLine("도면명에 해당하는 모든 시스템 필드:"); - Console.WriteLine($" 국토교통부: {allFields.Molit}"); - Console.WriteLine($" 고속도로공사: {allFields.Expressway}"); - Console.WriteLine($" 국가철도공단: {allFields.Railway}"); - Console.WriteLine($" 문서AI키: {allFields.DocAiKey}"); - } +// Console.WriteLine("\n=== 특정 AI 라벨의 모든 시스템 필드명 ==="); +// var allFields = mapper.GetAllSystemFields("도면명"); +// if (allFields != null) +// { +// Console.WriteLine("도면명에 해당하는 모든 시스템 필드:"); +// Console.WriteLine($" 국토교통부: {allFields.Molit}"); +// Console.WriteLine($" 고속도로공사: {allFields.Expressway}"); +// Console.WriteLine($" 국가철도공단: {allFields.Railway}"); +// Console.WriteLine($" 문서AI키: {allFields.DocAiKey}"); +// } - Console.WriteLine("\n=== 배치 처리 예제 ==="); - var batchResults = mapper.BatchConvertAilabelToExpressway(testLabels); - foreach (var result in batchResults) - { - Console.WriteLine($"배치 변환: {result.Key} → {result.Value ?? "N/A"}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"오류 발생: {ex.Message}"); - } - } -} +// Console.WriteLine("\n=== 배치 처리 예제 ==="); +// var batchResults = mapper.BatchConvertAilabelToExpressway(testLabels); +// foreach (var result in batchResults) +// { +// Console.WriteLine($"배치 변환: {result.Key} → {result.Value ?? "N/A"}"); +// } +// } +// catch (Exception ex) +// { +// Console.WriteLine($"오류 발생: {ex.Message}"); +// } +// } // 확장 메서드 (선택사항) -public static class FieldMapperExtensions -{ - /// - /// 특정 시스템의 필드명을 다른 시스템으로 변환 - /// - public static string ConvertBetweenSystems(this FieldMapper mapper, string sourceField, string sourceSystem, string targetSystem) - { - // 역방향 조회를 위한 확장 메서드 - foreach (var kvp in mapper._mappingData.MappingTable.AilabelToSystems) - { - var systemFields = kvp.Value; - string sourceFieldValue = sourceSystem switch - { - "molit" => systemFields.Molit, - "expressway" => systemFields.Expressway, - "railway" => systemFields.Railway, - "docaikey" => systemFields.DocAiKey, - _ => null - }; +//public static class FieldMapperExtensions +//{ +// /// +// /// 특정 시스템의 필드명을 다른 시스템으로 변환 +// /// +// public static string ConvertBetweenSystems(this FieldMapper mapper, string sourceField, string sourceSystem, string targetSystem) +// { +// // 역방향 조회를 위한 확장 메서드 +// foreach (var kvp in mapper._mappingData.MappingTable.AilabelToSystems) +// { +// var systemFields = kvp.Value; +// string sourceFieldValue = sourceSystem switch +// { +// "molit" => systemFields.Molit, +// "expressway" => systemFields.Expressway, +// "railway" => systemFields.Railway, +// "docaikey" => systemFields.DocAiKey, +// _ => null +// }; - if (sourceFieldValue == sourceField) - { - return targetSystem switch - { - "molit" => systemFields.Molit, - "expressway" => systemFields.Expressway, - "railway" => systemFields.Railway, - "docaikey" => systemFields.DocAiKey, - _ => null - }; - } - } - return null; - } -} \ No newline at end of file +// if (sourceFieldValue == sourceField)LL +// { +// return targetSystem switch +// { +// "molit" => systemFields.Molit, +// "expressway" => systemFields.Expressway, +// "railway" => systemFields.Railway, +// "docaikey" => systemFields.DocAiKey, +// _ => null +// }; +// } +// } +// return null; +// } +//} \ No newline at end of file diff --git a/fletimageanalysis/.env b/fletimageanalysis/.env new file mode 100644 index 0000000..62c9452 --- /dev/null +++ b/fletimageanalysis/.env @@ -0,0 +1,19 @@ +# 환경 변수 설정 파일 +# 실제 사용 시 이 파일을 .env로 복사하고 실제 값으로 변경하세요 + +# Gemini API 키 (필수) +GEMINI_API_KEY=AIzaSyA4XUw9LJp5zQ5CkB3GVVAQfTL8z6BGVcs + +# 애플리케이션 설정 +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 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘. diff --git a/fletimageanalysis/.env.example b/fletimageanalysis/.env.example new file mode 100644 index 0000000..4d07871 --- /dev/null +++ b/fletimageanalysis/.env.example @@ -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 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘. diff --git a/fletimageanalysis/batch_cli.py b/fletimageanalysis/batch_cli.py new file mode 100644 index 0000000..1129b9a --- /dev/null +++ b/fletimageanalysis/batch_cli.py @@ -0,0 +1,216 @@ +""" +배치 처리 명령줄 인터페이스 +WPF 애플리케이션에서 호출 가능한 간단한 배치 처리 도구 + +Usage: + python batch_cli.py --files "file1.pdf,file2.dxf" --schema "한국도로공사" --concurrent 3 --batch-mode true --save-intermediate false --include-errors true --output "results.csv" +""" + +import argparse +import asyncio +import logging +import os +import sys +import time +from datetime import datetime +from typing import List + +# 프로젝트 모듈 임포트 +from config import Config +from multi_file_processor import MultiFileProcessor, BatchProcessingConfig, generate_default_csv_filename + +# 로깅 설정 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class BatchCLI: + """배치 처리 명령줄 인터페이스 클래스""" + + def __init__(self): + self.processor = None + self.start_time = None + + def setup_processor(self) -> bool: + """다중 파일 처리기 설정""" + try: + # 설정 검증 + config_errors = Config.validate_config() + if config_errors: + for error in config_errors: + print(f"ERROR: {error}") + return False + + # Gemini API 키 확인 + gemini_api_key = Config.get_gemini_api_key() + if not gemini_api_key: + print("ERROR: Gemini API 키가 설정되지 않았습니다") + return False + + # 처리기 초기화 + self.processor = MultiFileProcessor(gemini_api_key) + print("START: 배치 처리기 초기화 완료") + return True + + except Exception as e: + print(f"ERROR: 처리기 초기화 실패: {e}") + return False + + def parse_file_paths(self, files_arg: str) -> List[str]: + """파일 경로 문자열을 리스트로 파싱""" + if not files_arg: + return [] + + # 쉼표로 구분된 파일 경로들을 분리 + file_paths = [path.strip().strip('"\'') for path in files_arg.split(',')] + + # 파일 존재 여부 확인 + valid_paths = [] + for path in file_paths: + if os.path.exists(path): + valid_paths.append(path) + print(f"START: 파일 확인: {os.path.basename(path)}") + else: + print(f"ERROR: 파일을 찾을 수 없습니다: {path}") + + return valid_paths + + def parse_file_list_from_file(self, file_list_path: str) -> List[str]: + """파일 리스트 파일에서 파일 경로들을 읽어옴""" + if not file_list_path or not os.path.exists(file_list_path): + return [] + + valid_paths = [] + try: + with open(file_list_path, 'r', encoding='utf-8') as f: + for line in f: + path = line.strip().strip('"\'') + if path and os.path.exists(path): + valid_paths.append(path) + print(f"START: 파일 확인: {os.path.basename(path)}") + elif path: + print(f"ERROR: 파일을 찾을 수 없음: {path}") + + print(f"START: 총 {len(valid_paths)}개 파일 로드됨") + return valid_paths + + except Exception as e: + print(f"ERROR: 파일 리스트 읽기 실패: {e}") + return [] + + def create_batch_config(self, args) -> BatchProcessingConfig: + """명령줄 인수에서 배치 설정 생성""" + config = BatchProcessingConfig( + organization_type=args.schema, + enable_gemini_batch_mode=args.batch_mode, + max_concurrent_files=args.concurrent, + save_intermediate_results=args.save_intermediate, + output_csv_path=args.output, + include_error_files=args.include_errors + ) + return config + + def progress_callback(self, current: int, total: int, status: str): + """진행률 콜백 함수 - WPF가 기대하는 형식으로 출력""" + # WPF가 파싱할 수 있는 간단한 형식으로 출력 + print(f"PROGRESS: {current}/{total}") + print(f"COMPLETED: {status}") + + async def run_batch_processing(self, file_paths: List[str], config: BatchProcessingConfig) -> bool: + """배치 처리 실행""" + try: + self.start_time = time.time() + total_files = len(file_paths) + + print(f"START: 배치 처리 시작: {total_files}개 파일") + + # 처리 실행 + results = await self.processor.process_multiple_files( + file_paths, config, self.progress_callback + ) + + # 처리 완료 + end_time = time.time() + total_time = end_time - self.start_time + + # 요약 정보 + summary = self.processor.get_processing_summary() + + print(f"COMPLETED: 배치 처리 완료!") + print(f"COMPLETED: 총 처리 시간: {total_time:.1f}초") + print(f"COMPLETED: 성공: {summary['success_files']}개, 실패: {summary['failed_files']}개") + print(f"COMPLETED: CSV 결과 저장: {config.output_csv_path}") + print(f"COMPLETED: JSON 결과 저장: {config.output_csv_path.replace('.csv', '.json')}") + + return True + + except Exception as e: + print(f"ERROR: 배치 처리 중 오류: {e}") + return False + + +def str_to_bool(value: str) -> bool: + """문자열을 boolean으로 변환""" + return value.lower() in ["true", "1", "yes", "on"] + + +async def main(): + """메인 함수""" + parser = argparse.ArgumentParser(description="PDF/DXF 파일 배치 처리 도구") + + # 파일 입력 방식 (둘 중 하나 필수) + input_group = parser.add_mutually_exclusive_group(required=True) + input_group.add_argument("--files", "-f", help="처리할 파일 경로들 (쉼표로 구분)") + input_group.add_argument("--file-list", "-fl", help="처리할 파일 경로가 담긴 텍스트 파일") + + # 선택적 인수들 + parser.add_argument("--schema", "-s", default="한국도로공사", help="분석 스키마") + parser.add_argument("--concurrent", "-c", type=int, default=3, help="동시 처리할 파일 수") + parser.add_argument("--batch-mode", "-b", default="false", help="배치 모드 사용 여부") + parser.add_argument("--save-intermediate", "-i", default="true", help="중간 결과 저장 여부") + parser.add_argument("--include-errors", "-e", default="true", help="오류 파일 포함 여부") + parser.add_argument("--output", "-o", help="출력 CSV 파일 경로 (JSON 파일도 함께 생성됨)") + + args = parser.parse_args() + + # CLI 인스턴스 생성 + cli = BatchCLI() + + # 처리기 설정 + if not cli.setup_processor(): + sys.exit(1) + + # 파일 경로 파싱 + if args.files: + input_files = cli.parse_file_paths(args.files) + else: + input_files = cli.parse_file_list_from_file(args.file_list) + + if not input_files: + print("ERROR: 처리할 파일이 없습니다.") + sys.exit(1) + + # boolean 변환 + args.batch_mode = str_to_bool(args.batch_mode) + args.save_intermediate = str_to_bool(args.save_intermediate) + args.include_errors = str_to_bool(args.include_errors) + + # 배치 설정 생성 + config = cli.create_batch_config(args) + + # 배치 처리 실행 + success = await cli.run_batch_processing(input_files, config) + + if success: + print("SUCCESS: 배치 처리가 성공적으로 완료되었습니다.") + sys.exit(0) + else: + print("ERROR: 배치 처리 중 오류가 발생했습니다.") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/fletimageanalysis/config.py b/fletimageanalysis/config.py new file mode 100644 index 0000000..66e80aa --- /dev/null +++ b/fletimageanalysis/config.py @@ -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("설정이 올바르게 구성되었습니다.") diff --git a/fletimageanalysis/cross_tabulated_csv_exporter.py b/fletimageanalysis/cross_tabulated_csv_exporter.py new file mode 100644 index 0000000..886241d --- /dev/null +++ b/fletimageanalysis/cross_tabulated_csv_exporter.py @@ -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 내보내기 모듈 (통합 버전) 테스트 완료") diff --git a/fletimageanalysis/csv_exporter.py b/fletimageanalysis/csv_exporter.py new file mode 100644 index 0000000..a4bcce2 --- /dev/null +++ b/fletimageanalysis/csv_exporter.py @@ -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}") + diff --git a/fletimageanalysis/dxf_processor.py b/fletimageanalysis/dxf_processor.py new file mode 100644 index 0000000..33c743f --- /dev/null +++ b/fletimageanalysis/dxf_processor.py @@ -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': {} + } diff --git a/fletimageanalysis/gemini_analyzer.py b/fletimageanalysis/gemini_analyzer.py new file mode 100644 index 0000000..8a24dbd --- /dev/null +++ b/fletimageanalysis/gemini_analyzer.py @@ -0,0 +1,271 @@ +""" +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={ + "도면명_line0": ValueWithCoords, + "도면명_line1": ValueWithCoords, + "도면명_line2": ValueWithCoords, + "편철번호": ValueWithCoords, + "도면번호": ValueWithCoords, + "Main_Title": ValueWithCoords, + "Sub_Title": ValueWithCoords, + "수평_도면_축척": ValueWithCoords, + "수직_도면_축척": ValueWithCoords, + "적용표준버전": ValueWithCoords, + "사업명_top": ValueWithCoords, + "사업명_bot": ValueWithCoords, + "시설_공구": ValueWithCoords, + "설계공구_공구명": ValueWithCoords, + "설계공구_범위": ValueWithCoords, + "시공공구_공구명": ValueWithCoords, + "시공공구_범위": ValueWithCoords, + "건설분야": ValueWithCoords, + "건설단계": ValueWithCoords, + "설계사": ValueWithCoords, + "시공사": ValueWithCoords, + "노선이정": ValueWithCoords, + "개정번호_1": ValueWithCoords, + "개정날짜_1": ValueWithCoords, + "개정내용_1": ValueWithCoords, + "작성자_1": ValueWithCoords, + "검토자_1": ValueWithCoords, + "확인자_1": ValueWithCoords + }, +) + +SCHEMA_TRANSPORTATION = types.Schema( + type=types.Type.OBJECT, + properties={ + "도면명": ValueWithCoords, + "편철번호": ValueWithCoords, + "도면번호": ValueWithCoords, + "Main Title": ValueWithCoords, + "Sub Title": ValueWithCoords, + "수평축척": ValueWithCoords, + "수직축척": ValueWithCoords, + "적용표준": ValueWithCoords, + "사업명": ValueWithCoords, + "시설_공구": ValueWithCoords, + "건설분야": ValueWithCoords, + "건설단계": ValueWithCoords, + "개정차수": ValueWithCoords, + "개정일자": ValueWithCoords, + "과업책임자": ValueWithCoords, + "분야별책임자": ValueWithCoords, + "설계자": 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에 기입하세요." + "top은 주로 문서 상단에, bot은 주로 문서 하단입니다. " + "특히 설계공구과 시공공구의 경우, 여러 개의 컬럼(공구명, 범위)으로 나누어진 경우가 있습니다. " + "설계공구 | 설계공구_공구명 | 설계공구_범위" + "시공공구 | 시공공구_공구명 | 시공공구_범위" + "와 같은 구조입니다. 구분자 색은 항상 black이 아닐 수 있음에 주의하세요" + "Given an image with a row like '설계공구 | 제2-1공구 | 12780.00-15860.00', the output should be:" +"설계공구_공구명: '제2-1공구'" +"설계공구_범위: '12780.00-15860.00'" + "도면명_line{n}은 도면명에 해당하는 값 여러 줄을 위에서부터 0, 1, 2, ...라고 정의합니다." + "도면명에 해당하는 값이 두 줄인 경우 line0이 생략된 경우입니다." + "{ }_Title은 중앙 상단의 비교적 큰 폰트입니다. " + "사업명_top에 해당하는 텍스트 아랫줄은 '시설_공구' 항목입니다." + "개정번호_{n}의 n은 삼각형 내부의 숫자입니다." + "각각의 컬럼에 해당하는 값을 개별적으로 추출해주세요." + "해당하는 값이 없으면 빈 문자열을 사용하세요." + ) + + 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) + + # 디버깅: Gemini 응답 내용 로깅 + logger.info(f"=== Gemini 응답 디버깅 ===") + logger.info(f"조직 유형: {organization_type}") + logger.info(f"응답 필드 수: {len(parsed_json) if isinstance(parsed_json, dict) else 'N/A'}") + + if isinstance(parsed_json, dict): + # 새로운 필드들이 응답에 포함되었는지 확인 + new_fields = ["설계공구_Station_col1", "설계공구_Station_col2", "시공공구_Station_col1", "시공공구_Station_col2"] + old_fields = ["설계공구_Station", "시공공구_Station"] + + logger.info("=== 새 필드 확인 ===") + for field in new_fields: + if field in parsed_json: + field_data = parsed_json[field] + if isinstance(field_data, dict) and field_data.get('value'): + logger.info(f"✅ {field}: '{field_data.get('value')}' at ({field_data.get('x', 'N/A')}, {field_data.get('y', 'N/A')})") + else: + logger.info(f"⚠️ {field}: 빈 값 또는 잘못된 형식 - {field_data}") + else: + logger.info(f"❌ {field}: 응답에 없음") + + logger.info("=== 기존 필드 확인 ===") + for field in old_fields: + if field in parsed_json: + field_data = parsed_json[field] + if isinstance(field_data, dict) and field_data.get('value'): + logger.info(f"⚠️ {field}: '{field_data.get('value')}' (기존 필드가 여전히 존재)") + else: + logger.info(f"⚠️ {field}: 빈 값 - {field_data}") + else: + logger.info(f"✅ {field}: 응답에 없음 (예상됨)") + + logger.info("=== 전체 응답 필드 목록 ===") + for key in parsed_json.keys(): + value = parsed_json[key] + if isinstance(value, dict) and 'value' in value: + logger.info(f"필드: {key} = '{value.get('value', '')}' at ({value.get('x', 'N/A')}, {value.get('y', 'N/A')})") + else: + logger.info(f"필드: {key} = {type(value).__name__}") + + logger.info("=== 디버깅 끝 ===") + + 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 \ No newline at end of file diff --git a/mapping_table_json.json b/fletimageanalysis/mapping_table_json.json similarity index 70% rename from mapping_table_json.json rename to fletimageanalysis/mapping_table_json.json index 19fa197..d602067 100644 --- a/mapping_table_json.json +++ b/fletimageanalysis/mapping_table_json.json @@ -1,78 +1,96 @@ { "mapping_table": { "ailabel_to_systems": { - "도면명": { + "도면명_line0": { + "molit": "", + "expressway": "TD_DNAME_TOP", + "railway": "", + "docaikey": "DNAME_TOP" + }, + "도면명_line1": { "molit": "DI_TITLE", - "expressway": "TD_DNAME_MAIN", + "expressway": "TD_DNAME_MAIN", "railway": "TD_DNAME_MAIN", "docaikey": "DNAME_MAIN" }, - "편철번호": { + "도면명_line2": { "molit": "DI_SUBTITLE", "expressway": "TD_DNAME_BOT", - "railway": "TD_DNAME_BOT", + "railway": "TD_DNAME_BOT", "docaikey": "DNAME_BOT" }, - "도면번호": { + "편철번호": { "molit": "DA_PAGENO", "expressway": "TD_DWGNO", "railway": "TD_DWGNO", "docaikey": "DWGNO" }, - "Main Title": { + "도면번호": { "molit": "DI_DRWNO", "expressway": "TD_DWGCODE", "railway": "TD_DWGCODE", "docaikey": "DWGCODE" }, - "Sub Title": { + "Main_Title": { "molit": "UD_TITLE", "expressway": "TB_MTITIL", "railway": "TB_MTITIL", "docaikey": "MTITIL" }, - "수평축척": { + "Sub_Title": { "molit": "UD_SUBTITLE", "expressway": "TB_STITL", "railway": "TB_STITL", "docaikey": "STITL" }, - "수직축척": { - "molit": "", - "expressway": "TD_DWGCODE_PREV", - "railway": "", - "docaikey": "DWGCODE_PREV" - }, - "도면축척": { + "수평_도면_축척": { "molit": "DA_HSCALE", "expressway": "TD_HSCAL", "railway": "", "docaikey": "HSCAL" }, - "적용표준버전": { - "molit": "DA_STDNAME", - "expressway": "STDNAME", + "수직_도면_축척": { + "molit": "DA_VSCALE", + "expressway": "TD_VSCAL", "railway": "", - "docaikey": "" + "docaikey": "VSCAL" }, - "사업명": { + "적용표준버전": { "molit": "DA_STDVER", "expressway": "TD_VERSION", "railway": "TD_VERSION", "docaikey": "VERSION" }, - "시설_공구": { + "사업명_top": { "molit": "PI_CNAME", "expressway": "TB_CNAME", "railway": "", "docaikey": "TBCNAME" }, - "설계공구_Station": { + "시설_공구": { "molit": "UD_CDNAME", "expressway": "TB_CSCOP", "railway": "", "docaikey": "CSCOP" }, + "사업명_bot": { + "molit": "", + "expressway": "TD_CNAME", + "railway": "TD_CNAME", + "docaikey": "TDCNAME" + }, + "설계공구_공구명": { + "molit": "", + "expressway": "TD_DSECT", + "railway": "", + "docaikey": "DSECT" + }, + "시공공구_공구명": { + "molit": "", + "expressway": "TD_CSECT", + "railway": "", + "docaikey": "CSECT" + }, "건설분야": { "molit": "PA_CCLASS", "expressway": "TD_FIELD", @@ -86,54 +104,66 @@ "docaikey": "CSTEP" }, "설계사": { - "molit": "TD_DCOMP", + "molit": "", "expressway": "TD_DCOMP", - "railway": "", + "railway": "TD_DCOMP", "docaikey": "DCOMP" }, "시공사": { - "molit": "TD_CCOMP", + "molit": "", "expressway": "TD_CCOMP", - "railway": "", + "railway": "TD_CCOMP", "docaikey": "CCOMP" }, "노선이정": { - "molit": "TD_LNDST", - "expressway": "", + "molit": "", + "expressway": "TD_LNDST", "railway": "", "docaikey": "LNDST" }, - "계정번호": { + "설계공구_범위": { + "molit": "", + "expressway": "TD_DDIST", + "railway": "", + "docaikey": "DDIST" + }, + "시공공구_범위": { + "molit": "", + "expressway": "TD_CDIST", + "railway": "", + "docaikey": "CDIST" + }, + "개정번호_1": { "molit": "DC_RNUM1", "expressway": "TR_RNUM1", "railway": "TR_RNUM1", "docaikey": "RNUM1" }, - "계정날짜": { + "개정날짜_1": { "molit": "DC_RDATE1", "expressway": "TR_RDAT1", "railway": "TR_RDAT1", "docaikey": "RDAT1" }, - "개정내용": { + "개정내용_1": { "molit": "DC_RDES1", "expressway": "TR_RCON1", "railway": "TR_RCON1", "docaikey": "RCON1" }, - "작성자": { + "작성자_1": { "molit": "DC_RDGN1", "expressway": "TR_DGN1", "railway": "TR_DGN1", "docaikey": "DGN1" }, - "검토자": { + "검토자_1": { "molit": "DC_RCHK1", "expressway": "TR_CHK1", "railway": "TR_CHK1", "docaikey": "CHK1" }, - "확인자": { + "확인자_1": { "molit": "DC_RAPP1", "expressway": "TR_APP1", "railway": "TR_APP1", @@ -143,7 +173,7 @@ "system_mappings": { "expressway_to_transportation": { "TD_DNAME_MAIN": "DNAME_MAIN", - "TD_DNAME_BOT": "DNAME_BOT", + "TD_DNAME_BOT": "DNAME_BOT", "TD_DWGNO": "DWGNO", "TD_DWGCODE": "DWGCODE", "TB_MTITIL": "MTITIL", diff --git a/fletimageanalysis/multi_file_processor.py b/fletimageanalysis/multi_file_processor.py new file mode 100644 index 0000000..aa768b6 --- /dev/null +++ b/fletimageanalysis/multi_file_processor.py @@ -0,0 +1,588 @@ +""" +다중 파일 처리 모듈 +여러 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) + + # JSON 출력도 함께 생성 (좌표 정보 포함) + json_output_path = config.output_csv_path.replace('.csv', '.json') + await self.save_results_to_json(json_output_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 + + async def save_results_to_json(self, output_path: str) -> None: + """ + 처리 결과를 JSON 파일로 저장 (좌표 정보 포함) + + Args: + output_path: 출력 JSON 파일 경로 + """ + try: + # 결과를 JSON 구조로 변환 + json_data = { + "metadata": { + "total_files": len(self.processing_results), + "success_files": sum(1 for r in self.processing_results if r.success), + "failed_files": sum(1 for r in self.processing_results if not r.success), + "generated_at": datetime.now().isoformat(), + "format_version": "1.0" + }, + "results": [] + } + + for result in self.processing_results: + # 기본 정보 + result_data = { + "file_info": { + "name": result.file_name, + "path": result.file_path, + "type": result.file_type, + "size_bytes": result.file_size, + "size_mb": round(result.file_size / (1024 * 1024), 2) + }, + "processing_info": { + "success": result.success, + "processing_time_seconds": result.processing_time, + "processed_at": result.processed_at, + "error_message": result.error_message + } + } + + # PDF 분석 결과 (좌표 정보 포함) + if result.file_type.lower() == 'pdf' and result.pdf_analysis_result: + try: + # JSON 문자열을 딕셔너리로 변환 (이미 딕셔너리인 경우도 처리) + if isinstance(result.pdf_analysis_result, str): + analysis_data = json.loads(result.pdf_analysis_result) + else: + analysis_data = result.pdf_analysis_result + + result_data["pdf_analysis"] = analysis_data + + except (json.JSONDecodeError, TypeError) as e: + logger.warning(f"PDF 분석 결과 JSON 파싱 오류: {e}") + result_data["pdf_analysis"] = {"error": "JSON 파싱 실패", "raw_data": str(result.pdf_analysis_result)} + + # DXF 분석 결과 + elif result.file_type.lower() == 'dxf': + result_data["dxf_analysis"] = { + "total_attributes": result.dxf_total_attributes or 0, + "total_text_entities": result.dxf_total_text_entities or 0, + "title_blocks": result.dxf_title_blocks or [] + } + + json_data["results"].append(result_data) + + # JSON 파일 저장 (예쁜 포맷팅과 한글 지원) + with open(output_path, 'w', encoding='utf-8') as f: + json.dump(json_data, f, ensure_ascii=False, indent=2, default=str) + + logger.info(f"JSON 저장 완료: {output_path}") + logger.info(f"총 {len(json_data['results'])}개 파일 결과 저장 (좌표 정보 포함)") + + except Exception as e: + logger.error(f"JSON 저장 오류: {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()) diff --git a/fletimageanalysis/pdf_processor.py b/fletimageanalysis/pdf_processor.py new file mode 100644 index 0000000..e0dcfad --- /dev/null +++ b/fletimageanalysis/pdf_processor.py @@ -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 파일 검증 실패") diff --git a/fletimageanalysis/requirements-cli.txt b/fletimageanalysis/requirements-cli.txt new file mode 100644 index 0000000..63a8757 --- /dev/null +++ b/fletimageanalysis/requirements-cli.txt @@ -0,0 +1,9 @@ +# Essential packages for CLI batch processing only +PyMuPDF>=1.26.3 +google-genai>=1.0.0 +Pillow>=10.0.0 +ezdxf>=1.4.2 +numpy>=1.24.0 +python-dotenv>=1.0.0 +pandas>=2.0.0 +requests>=2.31.0 \ No newline at end of file diff --git a/fletimageanalysis/requirements.txt b/fletimageanalysis/requirements.txt new file mode 100644 index 0000000..02ea143 --- /dev/null +++ b/fletimageanalysis/requirements.txt @@ -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