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