Compare commits

...

18 Commits

Author SHA1 Message Date
horu2day
52fbc1c967 1차 완료 2025-08-13 13:57:46 +09:00
horu2day
f114b8b642 도면에서 표 추출 2025-08-12 14:33:18 +09:00
박승우우
3abb3c07ce raycasting note content box detect 2025-07-31 11:23:46 +09:00
박승우우
107eab90fa NoteDetectionRefactor plan 2025-07-31 10:29:56 +09:00
박승우우
9c76b624bf note detection v2 2025-07-31 10:03:48 +09:00
박승우우
0278688b28 노트 블럭별 추출 2025-07-30 17:20:00 +09:00
박승우우
22aa118316 note detect rollback 2025-07-30 15:18:59 +09:00
박승우우
5ead0e8045 노트별 분리 2025-07-30 13:50:41 +09:00
박승우우
66dd64306c ExportExcel 정리, height정렬 attRef 분리 2025-07-29 14:06:12 +09:00
박승우우
8bd5d9580c Height sort, 한 파일에 2025-07-28 11:20:21 +09:00
박승우우
9b94b59c49 excel 메모리누수 2025-07-25 10:30:39 +09:00
박승우우
ddb4a1c408 un-revert 2025-07-23 13:50:02 +09:00
박승우우
24b5ab9686 Revert "dwg only 버튼 추가"
This reverts commit a87644d8be.
2025-07-23 13:16:00 +09:00
박승우우
a87644d8be dwg only 버튼 추가 2025-07-23 13:15:27 +09:00
박승우우
b13e981d04 teigha 오류 수정 2025-07-22 10:45:51 +09:00
박승우우
5282927833 TD_CNAME 삭제 2025-07-21 16:30:13 +09:00
박승우우
348ebd1158 프롬프트 수정 - 도면 line 2025-07-21 16:05:58 +09:00
박승우우
4a4a0138da 중간 2025-07-21 15:32:49 +09:00
43 changed files with 13603 additions and 542 deletions

View File

@@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(dotnet build)",
"Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\MSBuild\\Current\\Bin\\MSBuild.exe\" DwgExtractorManual.csproj -p:Configuration=Debug -p:Platform=x64)",
"Bash(mkdir:*)",
"Bash(where msbuild)",
"Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" DwgExtractorManual.csproj -p:Configuration=Debug -p:Platform=x64)",
"Bash(dotnet run:*)",
"Bash(echo $HOME)",
"Bash(find:*)",
"Bash(dotnet clean:*)",
"Bash(dotnet build:*)",
"Bash(taskkill:*)",
"Bash(wmic process where ProcessId=17428 delete:*)"
],
"deny": []
},
"contextFileName": "AGENTS.md"
}

3
.gitignore vendored
View File

@@ -361,3 +361,6 @@ MigrationBackup/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
.venv/
venv/

85
AGENTS.md Normal file
View File

@@ -0,0 +1,85 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a C# WPF application that extracts data from DWG (AutoCAD) files and processes them using AI analysis. The application has two main components:
1. **C# WPF Application** (`DwgExtractorManual`) - Main GUI application for DWG processing
2. **Python Analysis Module** (`fletimageanalysis`) - AI-powered document analysis using Gemini API
## Build and Development Commands
### C# Application
```bash
# Build the application
dotnet build
# Run the application
dotnet run
# Clean build artifacts
dotnet clean
# Publish for deployment
dotnet publish -c Release
```
### Python Module Setup
```bash
# Run the cleanup and setup script (Windows)
cleanup_and_setup.bat
# Or manually setup Python environment
cd fletimageanalysis
python -m venv venv
call venv\Scripts\activate.bat
pip install -r requirements.txt
```
### Python CLI Usage
```bash
# Batch process files via CLI
cd fletimageanalysis
python batch_cli.py --files "file1.pdf,file2.dxf" --schema "한국도로공사" --concurrent 3 --output "results.csv"
```
## Architecture
### C# Component Structure
- **MainWindow.xaml.cs** - Main WPF window and UI logic
- **Models/DwgDataExtractor.cs** - Core DWG file processing using Teigha SDK
- **Models/ExcelDataWriter.cs** - Excel output generation using Office Interop
- **Models/TeighaServicesManager.cs** - Singleton manager for Teigha SDK lifecycle
- **Models/FieldMapper.cs** - Maps extracted data to target formats
- **Models/SettingsManager.cs** - Application configuration management
### Python Component Structure
- **batch_cli.py** - Command-line interface for batch processing
- **multi_file_processor.py** - Orchestrates multi-file processing workflows
- **gemini_analyzer.py** - AI analysis using Google Gemini API
- **pdf_processor.py** - PDF document processing
- **dxf_processor.py** - DXF file processing
- **csv_exporter.py** - CSV output generation
### Key Dependencies
- **Teigha SDK** - DWG file reading and CAD entity processing (requires DLL files in specific path)
- **Microsoft Office Interop** - Excel file generation
- **Npgsql** - PostgreSQL database connectivity
- **Google Gemini API** - AI-powered document analysis
- **PyMuPDF** - PDF processing in Python component
## Current Development Focus
The project is undergoing a **Note Detection Refactor** (see `NoteDetectionRefactor.md`):
- Replacing fragile "horizontal search line" algorithm in `DwgDataExtractor.cs`
- Implementing robust "vertical ray-casting" approach for NOTE content box detection
- Key methods being refactored: `FindNoteBox`, `GetAllLineSegments`, `TraceBoxFromTopLine`
## Important Notes
- Teigha DLLs must be present in the specified path for DWG processing to work
- The Python module requires Google Gemini API key configuration
- Excel output uses COM Interop and requires Microsoft Office installation
- The application supports both manual GUI operation and automated batch processing via CLI

14
App.config Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="office"
publicKeyToken="71e9bce111e9429c"
culture="neutral" />
<bindingRedirect oldVersion="15.0.0.0"
newVersion="16.0.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
See @AGENTS.md for guidelines.

144
Controls/ZoomBorder.cs Normal file
View File

@@ -0,0 +1,144 @@
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using Cursors = System.Windows.Input.Cursors;
using Point = System.Windows.Point;
namespace DwgExtractorManual.Controls
{
public class ZoomBorder : Border
{
private UIElement? child = null;
private Point origin;
private Point start;
private TranslateTransform GetTranslateTransform(UIElement element)
{
return (TranslateTransform)((TransformGroup)element.RenderTransform)
.Children.First(tr => tr is TranslateTransform);
}
private ScaleTransform GetScaleTransform(UIElement element)
{
return (ScaleTransform)((TransformGroup)element.RenderTransform)
.Children.First(tr => tr is ScaleTransform);
}
public override UIElement Child
{
get { return base.Child; }
set
{
if (value != null && value != this.Child)
this.Initialize(value);
base.Child = value;
}
}
public void Initialize(UIElement element)
{
this.child = element;
if (child != null)
{
TransformGroup group = new TransformGroup();
ScaleTransform st = new ScaleTransform();
group.Children.Add(st);
TranslateTransform tt = new TranslateTransform();
group.Children.Add(tt);
child.RenderTransform = group;
child.RenderTransformOrigin = new Point(0.0, 0.0);
this.MouseWheel += child_MouseWheel;
this.MouseLeftButtonDown += child_MouseLeftButtonDown;
this.MouseLeftButtonUp += child_MouseLeftButtonUp;
this.MouseMove += child_MouseMove;
this.PreviewMouseRightButtonDown += new MouseButtonEventHandler(
child_PreviewMouseRightButtonDown);
}
}
public void Reset()
{
if (child != null)
{
// reset zoom
var st = GetScaleTransform(child);
st.ScaleX = 1.0;
st.ScaleY = 1.0;
// reset pan
var tt = GetTranslateTransform(child);
tt.X = 0.0;
tt.Y = 0.0;
}
}
private void child_MouseWheel(object sender, MouseWheelEventArgs e)
{
if (child != null)
{
var st = GetScaleTransform(child);
var tt = GetTranslateTransform(child);
double zoom = e.Delta > 0 ? .2 : -.2;
if (!(e.Delta > 0) && (st.ScaleX < .4 || st.ScaleY < .4))
return;
Point relative = e.GetPosition(child);
double absoluteX;
double absoluteY;
absoluteX = relative.X * st.ScaleX + tt.X;
absoluteY = relative.Y * st.ScaleY + tt.Y;
st.ScaleX += zoom;
st.ScaleY += zoom;
tt.X = absoluteX - relative.X * st.ScaleX;
tt.Y = absoluteY - relative.Y * st.ScaleY;
}
}
private void child_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (child != null)
{
var tt = GetTranslateTransform(child);
start = e.GetPosition(this);
origin = new Point(tt.X, tt.Y);
this.Cursor = Cursors.Hand;
child.CaptureMouse();
}
}
private void child_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (child != null)
{
child.ReleaseMouseCapture();
this.Cursor = Cursors.Arrow;
}
}
void child_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
this.Reset();
}
private void child_MouseMove(object sender, System.Windows.Input.MouseEventArgs e)
{
if (child != null)
{
if (child.IsMouseCaptured)
{
var tt = GetTranslateTransform(child);
Vector v = start - e.GetPosition(this);
tt.X = origin.X - v.X;
tt.Y = origin.Y - v.Y;
}
}
}
}
}

View File

@@ -6,28 +6,53 @@
<UseWindowsForms>True</UseWindowsForms> <UseWindowsForms>True</UseWindowsForms>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<COMReference Include="Microsoft.Office.Interop.Excel">
<VersionMinor>9</VersionMinor>
<VersionMajor>1</VersionMajor>
<Guid>00020813-0000-0000-c000-000000000046</Guid>
<Lcid>0</Lcid>
<WrapperTool>tlbimp</WrapperTool>
<Isolated>false</Isolated>
<EmbedInteropTypes>true</EmbedInteropTypes>
</COMReference>
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Npgsql" Version="9.0.1" /> <PackageReference Include="Npgsql" Version="9.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup> </ItemGroup>
<!-- Teigha DLL 참조 - 실제 경로로 수정 필요 --> <!-- Copy all Teigha DLLs including native dependencies -->
<ItemGroup>
<None Include="D:\dev_Net8_git\trunk\DLL\Teigha\vc16_amd64dll_23.12SP2\*.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup> <ItemGroup>
<Reference Include="TD_Mgd_23.12_16"> <Reference Include="TD_Mgd_23.12_16">
<HintPath>..\..\..\..\GitNet8\trunk\DLL\Teigha\vc16_amd64dll_23.12SP2\TD_Mgd_23.12_16.dll</HintPath> <HintPath>..\..\..\GitNet8\trunk\DLL\Teigha\vc16_amd64dll_23.12SP2\TD_Mgd_23.12_16.dll</HintPath>
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Office.Interop.Excel">
<HintPath>C:\Program Files (x86)\Microsoft Office\Office16\DCF\Microsoft.Office.Interop.Excel.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="office">
<HintPath>C:\Program Files (x86)\Microsoft Office\Office16\DCF\office.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
<ItemGroup>
<Content Include="fletimageanalysis\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>fletimageanalysis\%(RecursiveDir)%(FileName)%(Extension)</Link>
</Content>
</ItemGroup>
</Project> </Project>

173
IntersectionTestConsole.cs Normal file
View File

@@ -0,0 +1,173 @@
//using System;
//using System.Collections.Generic;
//using System.Diagnostics;
//using System.Linq;
//using Teigha.Geometry;
//using DwgExtractorManual.Models;
//namespace DwgExtractorManual
//{
// /// <summary>
// /// 콘솔에서 실행할 수 있는 교차점 테스트 프로그램
// /// </summary>
// class IntersectionTestConsole
// {
// static void Main(string[] args)
// {
// Console.WriteLine("=== 교차점 테스트 프로그램 시작 ===");
// try
// {
// RunSimpleIntersectionTest();
// }
// catch (Exception ex)
// {
// Console.WriteLine($"오류 발생: {ex.Message}");
// Console.WriteLine(ex.StackTrace);
// }
// Console.WriteLine("테스트 완료. 아무 키나 누르세요...");
// Console.ReadKey();
// }
// static void RunSimpleIntersectionTest()
// {
// Console.WriteLine("테스트 시작: 3x4 그리드 생성");
// // 간단한 3x4 테이블 시뮬레이션 (실제 DWG 없이)
// var intersections = new List<IntersectionPoint>();
// // 수동으로 교차점 생성 (3행 x 4열 = 12개 교차점)
// for (int row = 1; row <= 4; row++) // 4개 행
// {
// for (int col = 1; col <= 5; col++) // 5개 열
// {
// double x = (col - 1) * 10.0; // 0, 10, 20, 30, 40
// double y = (row - 1) * 10.0; // 0, 10, 20, 30
// int directionBits = CalculateDirectionBits(row, col, 4, 5);
// var intersection = new IntersectionPoint
// {
// Position = new Point3d(x, y, 0),
// DirectionBits = directionBits,
// Row = row,
// Column = col
// };
// intersections.Add(intersection);
// Console.WriteLine($"교차점 R{row}C{col}: ({x:F0},{y:F0}) - DirectionBits: {directionBits}");
// }
// }
// Console.WriteLine($"\n총 {intersections.Count}개 교차점 생성됨");
// // DirectionBits 검증
// TestDirectionBitsValidation(intersections);
// // 셀 추출 시뮬레이션
// TestCellExtraction(intersections);
// }
// static int CalculateDirectionBits(int row, int col, int maxRow, int maxCol)
// {
// int bits = 0;
// // Right: 1 - 오른쪽에 더 많은 열이 있으면
// if (col < maxCol) bits |= 1;
// // Up: 2 - 위쪽에 더 많은 행이 있으면
// if (row < maxRow) bits |= 2;
// // Left: 4 - 왼쪽에 열이 있으면
// if (col > 1) bits |= 4;
// // Down: 8 - 아래쪽에 행이 있으면
// if (row > 1) bits |= 8;
// return bits;
// }
// static void TestDirectionBitsValidation(List<IntersectionPoint> intersections)
// {
// Console.WriteLine("\n=== DirectionBits 검증 ===");
// var mappingData = new MappingTableData();
// var fieldMapper = new FieldMapper(mappingData);
// var extractor = new DwgDataExtractor(fieldMapper);
// foreach (var intersection in intersections)
// {
// bool isTopLeft = extractor.IsValidTopLeft(intersection.DirectionBits);
// bool isBottomRight = extractor.IsValidBottomRight(intersection.DirectionBits);
// Console.WriteLine($"R{intersection.Row}C{intersection.Column} (bits: {intersection.DirectionBits:D2}) - " +
// $"TopLeft: {isTopLeft}, BottomRight: {isBottomRight}");
// }
// }
// static void TestCellExtraction(List<IntersectionPoint> intersections)
// {
// Console.WriteLine("\n=== 셀 추출 테스트 ===");
// var mappingData = new MappingTableData();
// var fieldMapper = new FieldMapper(mappingData);
// var extractor = new DwgDataExtractor(fieldMapper);
// // topLeft 후보들 찾기
// var topLeftCandidates = intersections.Where(i => extractor.IsValidTopLeft(i.DirectionBits)).ToList();
// Console.WriteLine($"TopLeft 후보: {topLeftCandidates.Count}개");
// foreach (var topLeft in topLeftCandidates)
// {
// Console.WriteLine($"\nTopLeft R{topLeft.Row}C{topLeft.Column} 처리 중...");
// // bottomRight 찾기 시뮬레이션
// var bottomRight = FindBottomRightSimulation(topLeft, intersections, extractor);
// if (bottomRight != null)
// {
// Console.WriteLine($" -> BottomRight 발견: R{bottomRight.Row}C{bottomRight.Column}");
// Console.WriteLine($" 셀 생성: ({topLeft.Position.X:F0},{bottomRight.Position.Y:F0}) to ({bottomRight.Position.X:F0},{topLeft.Position.Y:F0})");
// }
// else
// {
// Console.WriteLine(" -> BottomRight을 찾지 못함");
// }
// }
// }
// static IntersectionPoint? FindBottomRightSimulation(IntersectionPoint topLeft, List<IntersectionPoint> intersections, DwgDataExtractor extractor)
// {
// // 교차점들을 Row/Column으로 딕셔너리 구성
// var intersectionLookup = intersections
// .GroupBy(i => i.Row)
// .ToDictionary(g => g.Key, g => g.ToDictionary(i => i.Column, i => i));
// int maxRow = intersectionLookup.Keys.Max();
// int maxColumn = intersectionLookup.Values.SelectMany(row => row.Keys).Max();
// for (int targetRow = topLeft.Row + 1; targetRow <= maxRow + 1; targetRow++)
// {
// if (!intersectionLookup.ContainsKey(targetRow)) continue;
// var rowIntersections = intersectionLookup[targetRow];
// var availableColumns = rowIntersections.Keys.Where(col => col >= topLeft.Column).OrderBy(col => col);
// foreach (int targetColumn in availableColumns)
// {
// var candidate = rowIntersections[targetColumn];
// // bottomRight 검증 또는 테이블 경계 조건
// if (extractor.IsValidBottomRight(candidate.DirectionBits) ||
// (targetRow == maxRow && targetColumn == maxColumn))
// {
// return candidate;
// }
// }
// }
// return null;
// }
// }
//}

View File

@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Windows;
using DwgExtractorManual.Models;
using DwgExtractorManual.Views;
using MessageBox = System.Windows.MessageBox;
namespace DwgExtractorManual
{
/// <summary>
/// MainWindow의 시각화 관련 기능을 담당하는 partial class
/// </summary>
public partial class MainWindow
{
// 시각화 데이터 저장
private static List<TableCellVisualizationData> _visualizationDataCache = new List<TableCellVisualizationData>();
/// <summary>
/// 테이블 셀 시각화 데이터를 저장합니다.
/// </summary>
public static void SaveVisualizationData(TableCellVisualizationData data)
{
_visualizationDataCache.Add(data);
Debug.WriteLine($"[VISUALIZATION] 시각화 데이터 저장: {data.FileName}, 셀 수: {data.Cells.Count}");
}
/// <summary>
/// 저장된 시각화 데이터를 가져옵니다.
/// </summary>
public static List<TableCellVisualizationData> GetVisualizationData()
{
Debug.WriteLine($"[VISUALIZATION] 시각화 데이터 조회: {_visualizationDataCache.Count}개 항목");
return new List<TableCellVisualizationData>(_visualizationDataCache);
}
/// <summary>
/// 시각화 데이터를 초기화합니다.
/// </summary>
public static void ClearVisualizationData()
{
Debug.WriteLine($"[VISUALIZATION] 시각화 데이터 초기화 (기존 {_visualizationDataCache.Count}개 항목 삭제)");
_visualizationDataCache.Clear();
Debug.WriteLine("[VISUALIZATION] 시각화 데이터 초기화 완료");
}
/// <summary>
/// 테이블 셀 시각화 창을 엽니다.
/// </summary>
private void BtnVisualizeCells_Click(object sender, RoutedEventArgs e)
{
try
{
LogMessage("🎨 테이블 셀 시각화 창을 여는 중...");
var visualizationData = GetVisualizationData();
LogMessage($"[DEBUG] 조회된 시각화 데이터: {visualizationData.Count}개");
if (visualizationData.Count == 0)
{
MessageBox.Show("시각화할 데이터가 없습니다.\n먼저 'DWG추출(Height정렬)' 버튼을 눌러 데이터를 추출해주세요.",
"데이터 없음", MessageBoxButton.OK, MessageBoxImage.Information);
LogMessage("⚠️ 시각화할 데이터가 없습니다. 먼저 추출을 진행해주세요.");
return;
}
var visualizationWindow = new TableCellVisualizationWindow(visualizationData);
visualizationWindow.Owner = this;
visualizationWindow.Show();
LogMessage($"✅ 시각화 창 열기 완료 - {visualizationData.Count}개 파일 데이터");
}
catch (Exception ex)
{
LogMessage($"❌ 시각화 창 열기 중 오류: {ex.Message}");
MessageBox.Show($"시각화 창을 여는 중 오류가 발생했습니다:\n{ex.Message}",
"오류", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
}

View File

@@ -1,7 +1,7 @@
<Window x:Class="DwgExtractorManual.MainWindow" <Window x:Class="DwgExtractorManual.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="DWG 정보 추출기" Height="700" Width="900" Title="DWG 정보 추출기" Height="Auto" Width="900"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
MinHeight="600" MinWidth="800"> MinHeight="600" MinWidth="800">
@@ -12,6 +12,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
@@ -89,6 +90,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- 진행률 바 --> <!-- 진행률 바 -->
@@ -100,29 +102,154 @@
Text="준비됨" FontSize="12" Text="준비됨" FontSize="12"
HorizontalAlignment="Center" Margin="0,5"/> HorizontalAlignment="Center" Margin="0,5"/>
<!-- 추출 버튼 --> <!-- 추출 버튼 -->
<Button x:Name="btnExtract" Grid.Row="2" <StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,10,0,5">
Content="🚀 추출 시작" Width="200" Height="45" <Button x:Name="btnExtract"
Margin="0,10,0,5" HorizontalAlignment="Center" Content="🚀 DWG 추출" Width="150" Height="45"
Click="BtnExtract_Click" FontSize="16" FontWeight="Bold" Margin="5,0"
Background="#A3BE8C" Foreground="White" Click="BtnExtract_Click" FontSize="14" FontWeight="Bold"
BorderThickness="0"> Background="#A3BE8C" Foreground="White"
<Button.Style> BorderThickness="0">
<Style TargetType="Button"> <Button.Style>
<Setter Property="Background" Value="#A3BE8C"/> <Style TargetType="Button">
<Style.Triggers> <Setter Property="Background" Value="#A3BE8C"/>
<Trigger Property="IsMouseOver" Value="True"> <Style.Triggers>
<Setter Property="Background" Value="#8FAE74"/> <Trigger Property="IsMouseOver" Value="True">
</Trigger> <Setter Property="Background" Value="#8FAE74"/>
</Style.Triggers> </Trigger>
</Style> </Style.Triggers>
</Button.Style> </Style>
</Button> </Button.Style>
</Button>
<Button x:Name="btnPdfExtract"
Content="📄 PDF 추출" Width="150" Height="45"
Margin="5,0"
Click="BtnPdfExtract_Click" FontSize="14" FontWeight="Bold"
Background="#D08770" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#D08770"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#C5774F"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button x:Name="btnMerge"
Content="🔗 합치기" Width="150" Height="45"
Margin="5,0"
Click="BtnMerge_Click" FontSize="14" FontWeight="Bold"
Background="#B48EAD" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#B48EAD"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#A07A95"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button x:Name="btnAuto"
Content="🤖 자동" Width="150" Height="45"
Margin="5,0"
Click="BtnAuto_Click" FontSize="14" FontWeight="Bold"
Background="#4CAF50" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#4CAF50"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#45A049"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
<!-- Second row of buttons -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,5,0,10">
<Button x:Name="btnDwgOnly"
Content="🔧 DWG추출(폴더별)" Width="150" Height="45"
Margin="5,0"
Click="BtnDwgOnly_Click" FontSize="14" FontWeight="Bold"
Background="#8B4513" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#8B4513"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#A0522D"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button x:Name="btnDwgHeightSort"
Content="📏 DWG추출(Height정렬)" Width="150" Height="45"
Margin="5,0"
Click="BtnDwgHeightSort_Click" FontSize="14" FontWeight="Bold"
Background="#FF6B35" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#FF6B35"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#E55A2B"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button x:Name="btnVisualizeCells"
Content="🎨 셀 시각화" Width="150" Height="45"
Margin="5,0"
Click="BtnVisualizeCells_Click" FontSize="14" FontWeight="Bold"
Background="#9B59B6" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#9B59B6"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#8E44AD"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button x:Name="btnTestIntersection"
Content="🔬 교차점 테스트" Width="150" Height="45"
Margin="5,0"
Click="BtnTestIntersection_Click" FontSize="14" FontWeight="Bold"
Background="#E74C3C" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#E74C3C"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#C0392B"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
</Grid> </Grid>
</GroupBox> </GroupBox>
<!-- 로그 출력 --> <!-- 로그 출력 -->
<GroupBox Grid.Row="5" Header="📋 실시간 로그" Margin="15,5,15,10" <GroupBox Grid.Row="6" Header="📋 실시간 로그" Margin="15,5,15,10" Height="300"
FontWeight="SemiBold" FontSize="14"> FontWeight="SemiBold" FontSize="14">
<ScrollViewer Margin="5" VerticalScrollBarVisibility="Auto"> <ScrollViewer Margin="5" VerticalScrollBarVisibility="Auto">
<TextBox x:Name="txtLog" <TextBox x:Name="txtLog"
@@ -135,12 +262,14 @@
</GroupBox> </GroupBox>
<!-- 상태바 --> <!-- 상태바 -->
<StatusBar Grid.Row="6" Background="#3B4252" Foreground="White"> <StatusBar Grid.Row="7" Background="#3B4252" Foreground="White">
<StatusBarItem> <StatusBarItem>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock x:Name="txtStatusBar" Text="DWG 정보 추출기 v1.0 - 준비됨"/> <TextBlock x:Name="txtStatusBar" Text="DWG 정보 추출기 v1.0 - 준비됨"/>
<Separator Margin="10,0"/> <Separator Margin="10,0"/>
<TextBlock x:Name="txtFileCount" Text="파일: 0개"/> <TextBlock x:Name="txtFileCount" Text="파일: 0개"/>
<Separator Margin="10,0"/>
<TextBlock x:Name="txtBuildTime" Text="빌드: 로딩중..." FontSize="11" Foreground="LightGray"/>
</StackPanel> </StackPanel>
</StatusBarItem> </StatusBarItem>
<StatusBarItem HorizontalAlignment="Right"> <StatusBarItem HorizontalAlignment="Right">

File diff suppressed because it is too large Load Diff

9
Models/AppSettings.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace DwgExtractorManual.Models
{
public class AppSettings
{
public string? SourceFolderPath { get; set; }
public string? DestinationFolderPath { get; set; }
public string? LastExportType { get; set; }
}
}

261
Models/CsvDataWriter.cs Normal file
View File

@@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace DwgExtractorManual.Models
{
/// <summary>
/// Note 데이터를 CSV 파일로 출력하는 클래스
/// Note Box 안의 일반 텍스트와 테이블 텍스트를 분리하여 CSV로 출력
/// </summary>
public class CsvDataWriter
{
/// <summary>
/// Note 박스 안의 일반 텍스트들을 CSV 파일로 저장
/// </summary>
public void WriteNoteBoxTextToCsv(List<NoteEntityInfo> noteEntities, string filePath)
{
if (noteEntities == null || noteEntities.Count == 0)
return;
var csvLines = new List<string>();
// CSV 헤더 추가
csvLines.Add("Type,Layer,Text,X,Y,SortOrder,Path,FileName");
// Note와 NoteContent 데이터 추출 (테이블 제외)
var noteBoxTexts = noteEntities
.Where(ne => ne.Type == "Note" || ne.Type == "NoteContent")
.OrderBy(ne => ne.SortOrder)
.ToList();
foreach (var noteEntity in noteBoxTexts)
{
var csvLine = $"{EscapeCsvField(noteEntity.Type)}," +
$"{EscapeCsvField(noteEntity.Layer)}," +
$"{EscapeCsvField(noteEntity.Text)}," +
$"{noteEntity.X:F3}," +
$"{noteEntity.Y:F3}," +
$"{noteEntity.SortOrder}," +
$"{EscapeCsvField(noteEntity.Path)}," +
$"{EscapeCsvField(noteEntity.FileName)}";
csvLines.Add(csvLine);
}
// UTF-8 BOM 포함하여 파일 저장 (Excel에서 한글 깨짐 방지)
var utf8WithBom = new UTF8Encoding(true);
File.WriteAllLines(filePath, csvLines, utf8WithBom);
}
/// <summary>
/// Note 박스 안의 테이블 데이터들을 별도 CSV 파일로 저장
/// </summary>
public void WriteNoteTablesToCsv(List<NoteEntityInfo> noteEntities, string filePath)
{
if (noteEntities == null || noteEntities.Count == 0)
return;
var allCsvLines = new List<string>();
// 테이블 데이터가 있는 Note들 추출
var notesWithTables = noteEntities
.Where(ne => ne.Type == "Note" && !string.IsNullOrEmpty(ne.TableCsv))
.OrderByDescending(ne => ne.Y) // Y 좌표로 정렬 (위에서 아래로)
.ToList();
foreach (var noteWithTable in notesWithTables)
{
// Note 정보 헤더 추가
allCsvLines.Add($"=== NOTE: {noteWithTable.Text} (at {noteWithTable.X:F1}, {noteWithTable.Y:F1}) ===");
allCsvLines.Add(""); // 빈 줄
// 테이블 CSV 데이터 추가
var tableLines = noteWithTable.TableCsv.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
allCsvLines.AddRange(tableLines);
// Note 간 구분을 위한 빈 줄들
allCsvLines.Add("");
allCsvLines.Add("");
}
// UTF-8 BOM 포함하여 파일 저장
var utf8WithBom = new UTF8Encoding(true);
File.WriteAllLines(filePath, allCsvLines, utf8WithBom);
}
/// <summary>
/// Note 박스와 테이블 데이터를 통합하여 하나의 CSV 파일로 저장
/// </summary>
public void WriteNoteDataToCombinedCsv(List<NoteEntityInfo> noteEntities, string filePath)
{
if (noteEntities == null || noteEntities.Count == 0)
return;
var csvLines = new List<string>();
// CSV 헤더 추가
csvLines.Add("Type,Layer,Text,X,Y,SortOrder,TableData,Path,FileName");
// 모든 Note 관련 데이터를 SortOrder로 정렬
var sortedNoteEntities = noteEntities
.OrderBy(ne => ne.SortOrder)
.ToList();
foreach (var noteEntity in sortedNoteEntities)
{
// 테이블 데이터가 있는 경우 이를 별도 필드로 처리
var tableData = "";
if (noteEntity.Type == "Note" && !string.IsNullOrEmpty(noteEntity.TableCsv))
{
// 테이블 CSV 데이터를 하나의 필드로 압축 (줄바꿈을 |로 대체)
tableData = noteEntity.TableCsv.Replace("\n", "|").Replace("\r", "");
}
var csvLine = $"{EscapeCsvField(noteEntity.Type)}," +
$"{EscapeCsvField(noteEntity.Layer)}," +
$"{EscapeCsvField(noteEntity.Text)}," +
$"{noteEntity.X:F3}," +
$"{noteEntity.Y:F3}," +
$"{noteEntity.SortOrder}," +
$"{EscapeCsvField(tableData)}," +
$"{EscapeCsvField(noteEntity.Path)}," +
$"{EscapeCsvField(noteEntity.FileName)}";
csvLines.Add(csvLine);
}
// UTF-8 BOM 포함하여 파일 저장
var utf8WithBom = new UTF8Encoding(true);
File.WriteAllLines(filePath, csvLines, utf8WithBom);
}
/// <summary>
/// 각 Note별로 개별 CSV 파일 생성 (테이블이 있는 경우)
/// </summary>
public void WriteIndividualNoteTablesCsv(List<NoteEntityInfo> noteEntities, string baseDirectory)
{
if (noteEntities == null || noteEntities.Count == 0)
return;
// 디렉토리가 없으면 생성
if (!Directory.Exists(baseDirectory))
{
Directory.CreateDirectory(baseDirectory);
}
var notesWithTables = noteEntities
.Where(ne => ne.Type == "Note" && !string.IsNullOrEmpty(ne.TableCsv))
.OrderByDescending(ne => ne.Y)
.ToList();
int noteIndex = 1;
foreach (var noteWithTable in notesWithTables)
{
// 파일명 생성 (특수문자 제거)
var safeNoteText = MakeSafeFileName(noteWithTable.Text);
var fileName = $"Note_{noteIndex:D2}_{safeNoteText}.csv";
var fullPath = Path.Combine(baseDirectory, fileName);
// 테이블 CSV 데이터를 파일로 저장
var tableLines = noteWithTable.TableCsv.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
var utf8WithBom = new UTF8Encoding(true);
File.WriteAllLines(fullPath, tableLines, utf8WithBom);
noteIndex++;
}
}
/// <summary>
/// CSV 필드에서 특수문자를 이스케이프 처리
/// </summary>
private string EscapeCsvField(string field)
{
if (string.IsNullOrEmpty(field))
return "";
// 쉼표, 따옴표, 줄바꿈이 있으면 따옴표로 감싸기
if (field.Contains(",") || field.Contains("\"") || field.Contains("\n") || field.Contains("\r"))
{
return "\"" + field.Replace("\"", "\"\"") + "\"";
}
return field;
}
/// <summary>
/// 파일명에 사용할 수 없는 문자들을 제거하여 안전한 파일명 생성
/// </summary>
private string MakeSafeFileName(string fileName)
{
if (string.IsNullOrEmpty(fileName))
return "Unknown";
var invalidChars = Path.GetInvalidFileNameChars();
var safeFileName = fileName;
foreach (var invalidChar in invalidChars)
{
safeFileName = safeFileName.Replace(invalidChar, '_');
}
// 길이 제한 (Windows 파일명 제한 고려)
if (safeFileName.Length > 50)
{
safeFileName = safeFileName.Substring(0, 50);
}
return safeFileName.Trim();
}
/// <summary>
/// Note 데이터 통계 정보를 CSV로 저장
/// </summary>
public void WriteNoteStatisticsToCsv(List<NoteEntityInfo> noteEntities, string filePath)
{
if (noteEntities == null || noteEntities.Count == 0)
return;
var csvLines = new List<string>();
// 통계 헤더
csvLines.Add("Statistic,Count,Details");
// 전체 Note 개수
var totalNotes = noteEntities.Count(ne => ne.Type == "Note");
csvLines.Add($"Total Notes,{totalNotes},");
// 테이블이 있는 Note 개수
var notesWithTables = noteEntities.Count(ne => ne.Type == "Note" && !string.IsNullOrEmpty(ne.TableCsv));
csvLines.Add($"Notes with Tables,{notesWithTables},");
// 일반 텍스트만 있는 Note 개수
var notesWithTextOnly = totalNotes - notesWithTables;
csvLines.Add($"Notes with Text Only,{notesWithTextOnly},");
// 전체 Note 콘텐츠 개수
var totalNoteContents = noteEntities.Count(ne => ne.Type == "NoteContent");
csvLines.Add($"Total Note Contents,{totalNoteContents},");
// 레이어별 분포
csvLines.Add(",,");
csvLines.Add("Layer Distribution,,");
var layerGroups = noteEntities
.GroupBy(ne => ne.Layer)
.OrderByDescending(g => g.Count())
.ToList();
foreach (var layerGroup in layerGroups)
{
csvLines.Add($"Layer: {layerGroup.Key},{layerGroup.Count()},");
}
var utf8WithBom = new UTF8Encoding(true);
File.WriteAllLines(filePath, csvLines, utf8WithBom);
}
}
}

3096
Models/DwgDataExtractor.cs Normal file

File diff suppressed because it is too large Load Diff

639
Models/ExcelDataWriter.cs Normal file
View File

@@ -0,0 +1,639 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Excel = Microsoft.Office.Interop.Excel;
namespace DwgExtractorManual.Models
{
/// <summary>
/// Excel <20><>Ʈ<EFBFBD><C6AE> <20><><EFBFBD><EFBFBD><EFBFBD>͸<EFBFBD> <20><><EFBFBD><EFBFBD> <20>۾<EFBFBD><DBBE><EFBFBD> <20><><EFBFBD><EFBFBD>ϴ<EFBFBD> Ŭ<><C5AC><EFBFBD><EFBFBD>
/// </summary>
internal class ExcelDataWriter
{
private readonly ExcelManager excelManager;
public ExcelDataWriter(ExcelManager excelManager)
{
this.excelManager = excelManager ?? throw new ArgumentNullException(nameof(excelManager));
}
/// <summary>
/// Title Block <20><><EFBFBD><EFBFBD><EFBFBD>͸<EFBFBD> Excel <20><>Ʈ<EFBFBD><C6AE> <20><><EFBFBD>
/// </summary>
public void WriteTitleBlockData(List<TitleBlockRowData> titleBlockRows)
{
if (excelManager.TitleBlockSheet == null || titleBlockRows == null || titleBlockRows.Count == 0)
return;
int currentRow = 2; // <20><><EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
foreach (var row in titleBlockRows)
{
excelManager.TitleBlockSheet.Cells[currentRow, 1] = row.Type;
excelManager.TitleBlockSheet.Cells[currentRow, 2] = row.Name;
excelManager.TitleBlockSheet.Cells[currentRow, 3] = row.Tag;
excelManager.TitleBlockSheet.Cells[currentRow, 4] = row.Prompt;
excelManager.TitleBlockSheet.Cells[currentRow, 5] = row.Value;
excelManager.TitleBlockSheet.Cells[currentRow, 6] = row.Path;
excelManager.TitleBlockSheet.Cells[currentRow, 7] = row.FileName;
currentRow++;
}
}
/// <summary>
/// Text Entity <20><><EFBFBD><EFBFBD><EFBFBD>͸<EFBFBD> Excel <20><>Ʈ<EFBFBD><C6AE> <20><><EFBFBD>
/// </summary>
public void WriteTextEntityData(List<TextEntityRowData> textEntityRows)
{
if (excelManager.TextEntitiesSheet == null || textEntityRows == null || textEntityRows.Count == 0)
return;
int currentRow = 2; // <20><><EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
foreach (var row in textEntityRows)
{
excelManager.TextEntitiesSheet.Cells[currentRow, 1] = row.Type;
excelManager.TextEntitiesSheet.Cells[currentRow, 2] = row.Layer;
excelManager.TextEntitiesSheet.Cells[currentRow, 3] = row.Text;
excelManager.TextEntitiesSheet.Cells[currentRow, 4] = row.Path;
excelManager.TextEntitiesSheet.Cells[currentRow, 5] = row.FileName;
currentRow++;
}
}
/// <summary>
/// Note 엔터티 데이터를 Excel 시트에 쓰기 (테이블 및 셀 병합 포함)
/// </summary>
public void WriteNoteEntityData(List<NoteEntityInfo> noteEntityRows)
{
if (excelManager.NoteEntitiesSheet == null || noteEntityRows == null || noteEntityRows.Count == 0)
return;
int excelRow = 2; // 헤더 다음 행부터 시작
foreach (var note in noteEntityRows)
{
// 기본 Note 정보 쓰기
excelManager.NoteEntitiesSheet.Cells[excelRow, 1] = note.Type;
excelManager.NoteEntitiesSheet.Cells[excelRow, 2] = note.Layer;
excelManager.NoteEntitiesSheet.Cells[excelRow, 3] = note.Text;
excelManager.NoteEntitiesSheet.Cells[excelRow, 4] = note.X;
excelManager.NoteEntitiesSheet.Cells[excelRow, 5] = note.Y;
excelManager.NoteEntitiesSheet.Cells[excelRow, 6] = note.SortOrder;
excelManager.NoteEntitiesSheet.Cells[excelRow, 8] = note.Path;
excelManager.NoteEntitiesSheet.Cells[excelRow, 9] = note.FileName;
int tableRowCount = 0;
if (note.Cells != null && note.Cells.Count > 0)
{
// 테이블 데이터 처리
foreach (var cell in note.Cells)
{
int startRow = excelRow + cell.Row;
int startCol = 7 + cell.Column; // G열부터 시작
int endRow = startRow + cell.RowSpan - 1;
int endCol = startCol + cell.ColumnSpan - 1;
Excel.Range cellRange = excelManager.NoteEntitiesSheet.Range[
excelManager.NoteEntitiesSheet.Cells[startRow, startCol],
excelManager.NoteEntitiesSheet.Cells[endRow, endCol]];
// 병합 먼저 수행
if (cell.RowSpan > 1 || cell.ColumnSpan > 1)
{
cellRange.Merge();
}
// 값 설정 및 서식 지정
cellRange.Value = cell.CellText;
cellRange.VerticalAlignment = Excel.XlVAlign.xlVAlignCenter;
cellRange.HorizontalAlignment = Excel.XlHAlign.xlHAlignCenter;
}
// 이 테이블이 차지하는 총 행 수를 계산
tableRowCount = note.Cells.Max(c => c.Row + c.RowSpan);
}
// 다음 Note를 기록할 위치로 이동
// 테이블이 있으면 테이블 높이만큼, 없으면 한 칸만 이동
excelRow += (tableRowCount > 0) ? tableRowCount : 1;
}
}
/// <summary>
/// <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD>͸<EFBFBD> Excel <20><>Ʈ<EFBFBD><C6AE> <20><><EFBFBD>
/// </summary>
public void WriteMappingDataToExcel(Dictionary<string, Dictionary<string, (string, string, string, string)>> mappingData)
{
try
{
if (excelManager.MappingSheet == null || mappingData == null)
return;
int currentRow = 2; // <20><><EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
Debug.WriteLine($"[DEBUG] Writing mapping data to Excel. Total files: {mappingData.Count}");
foreach (var fileEntry in mappingData)
{
string fileName = fileEntry.Key;
var fileMappingData = fileEntry.Value;
Debug.WriteLine($"[DEBUG] Processing file: {fileName}, entries: {fileMappingData.Count}");
foreach (var mapEntry in fileMappingData)
{
string mapKey = mapEntry.Key;
(string aiLabel, string dwgTag, string attValue, string pdfValue) = mapEntry.Value;
if (string.IsNullOrEmpty(fileName) || string.IsNullOrEmpty(mapKey))
{
continue;
}
try
{
// <20><>ġ <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ʈ<EFBFBD><C6AE> <20><><EFBFBD><EFBFBD> <20><20><><EFBFBD>
object[,] rowData = new object[1, 6];
rowData[0, 0] = fileName;
rowData[0, 1] = mapKey;
rowData[0, 2] = aiLabel ?? "";
rowData[0, 3] = dwgTag ?? "";
rowData[0, 4] = attValue ?? "";
rowData[0, 5] = pdfValue ?? "";
Excel.Range range = excelManager.MappingSheet.Range[
excelManager.MappingSheet.Cells[currentRow, 1],
excelManager.MappingSheet.Cells[currentRow, 6]];
range.Value = rowData;
}
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}");
}
catch (System.Exception ex)
{
Debug.WriteLine($"? <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Excel <20><><EFBFBD> <20><> <20><><EFBFBD><EFBFBD>: {ex.Message}");
throw;
}
}
/// <summary>
/// Excel <20><><EFBFBD><EFBFBD> <20><>Ʈ<EFBFBD><C6AE><EFBFBD><EFBFBD> FileName<6D><65> AILabel<65><6C> <20><>Ī<EFBFBD>Ǵ<EFBFBD> <20><><EFBFBD><EFBFBD> ã<><C3A3> Pdf_value<75><65> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ʈ
/// </summary>
public bool UpdateExcelRow(string fileName, string aiLabel, string pdfValue)
{
try
{
if (excelManager.MappingSheet == null)
return false;
Excel.Range usedRange = excelManager.MappingSheet.UsedRange;
if (usedRange == null) return false;
int lastRow = usedRange.Rows.Count;
for (int row = 2; row <= lastRow; row++)
{
var cellFileName = ((Excel.Range)excelManager.MappingSheet.Cells[row, 1]).Value?.ToString() ?? "";
var cellAiLabel = ((Excel.Range)excelManager.MappingSheet.Cells[row, 3]).Value?.ToString() ?? "";
if (string.Equals(cellFileName.Trim(), fileName.Trim(), StringComparison.OrdinalIgnoreCase) &&
string.Equals(cellAiLabel.Trim(), aiLabel.Trim(), StringComparison.OrdinalIgnoreCase))
{
excelManager.MappingSheet.Cells[row, 6] = pdfValue;
return true;
}
}
return false;
}
catch (System.Exception ex)
{
Debug.WriteLine($"? Excel <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ʈ <20><> <20><><EFBFBD><EFBFBD>: {ex.Message}");
return false;
}
}
/// <summary>
/// DWG <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><>ũ<EFBFBD><C5A9><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD>ϰ<EFBFBD> <20><><EFBFBD><EFBFBD> (PDF <20>÷<EFBFBD> <20><><EFBFBD><EFBFBD>)
/// </summary>
public void SaveDwgOnlyMappingWorkbook(Dictionary<string, Dictionary<string, (string, string, string, string)>> mappingData, string resultFolderPath)
{
try
{
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string savePath = System.IO.Path.Combine(resultFolderPath, $"{timestamp}_DwgOnly_Mapping.xlsx");
Debug.WriteLine($"[DEBUG] DWG <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><>ũ<EFBFBD><C5A9> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>: {savePath}");
var dwgOnlyWorkbook = excelManager.CreateNewWorkbook();
var dwgOnlyWorksheet = (Excel.Worksheet)dwgOnlyWorkbook.Worksheets[1];
dwgOnlyWorksheet.Name = "DWG Mapping Data";
// <20><><EFBFBD> <20><><EFBFBD><EFBFBD> (PDF Value <20>÷<EFBFBD> <20><><EFBFBD><EFBFBD>)
dwgOnlyWorksheet.Cells[1, 1] = "<22><><EFBFBD>ϸ<EFBFBD>";
dwgOnlyWorksheet.Cells[1, 2] = "Map Key";
dwgOnlyWorksheet.Cells[1, 3] = "AI Label";
dwgOnlyWorksheet.Cells[1, 4] = "DWG Tag";
dwgOnlyWorksheet.Cells[1, 5] = "DWG Value";
// <20><><EFBFBD> <20><>Ÿ<EFBFBD><C5B8> <20><><EFBFBD><EFBFBD>
var headerRange = dwgOnlyWorksheet.Range["A1:E1"];
headerRange.Font.Bold = true;
headerRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightGray);
headerRange.Borders.LineStyle = Excel.XlLineStyle.xlContinuous;
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20>Է<EFBFBD>
int totalRows = mappingData.Sum(f => f.Value.Count);
if (totalRows > 0)
{
object[,] data = new object[totalRows, 5];
int row = 0;
foreach (var fileEntry in mappingData)
{
string fileName = fileEntry.Key;
foreach (var mapEntry in fileEntry.Value)
{
string mapKey = mapEntry.Key;
var (aiLabel, dwgTag, dwgValue, pdfValue) = mapEntry.Value;
data[row, 0] = fileName;
data[row, 1] = mapKey;
data[row, 2] = aiLabel;
data[row, 3] = dwgTag;
data[row, 4] = dwgValue;
row++;
}
}
Excel.Range dataRange = dwgOnlyWorksheet.Range[
dwgOnlyWorksheet.Cells[2, 1],
dwgOnlyWorksheet.Cells[totalRows + 1, 5]];
dataRange.Value = data;
}
dwgOnlyWorksheet.Columns.AutoFit();
excelManager.SaveWorkbookAs(dwgOnlyWorkbook, savePath);
Debug.WriteLine($"? DWG <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><>ũ<EFBFBD><C5A9> <20><><EFBFBD><EFBFBD> <20>Ϸ<EFBFBD>: {System.IO.Path.GetFileName(savePath)}");
dwgOnlyWorkbook.Close(false);
System.GC.Collect();
System.GC.WaitForPendingFinalizers();
}
catch (System.Exception ex)
{
Debug.WriteLine($"? DWG <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><>ũ<EFBFBD><C5A9> <20><><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD>: {ex.Message}");
throw;
}
}
/// <summary>
/// Height <20><><EFBFBD>ĵ<EFBFBD> Excel <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
/// </summary>
public void WriteHeightSortedData(List<TextEntityInfo> textEntities, Excel.Worksheet worksheet, string fileName)
{
// <20><><EFBFBD> <20><><EFBFBD><EFBFBD>
worksheet.Cells[1, 1] = "Height";
worksheet.Cells[1, 2] = "Type";
worksheet.Cells[1, 3] = "Layer";
worksheet.Cells[1, 4] = "Tag";
worksheet.Cells[1, 5] = "FileName";
worksheet.Cells[1, 6] = "Text";
// <20><><EFBFBD> <20><>Ÿ<EFBFBD><C5B8> <20><><EFBFBD><EFBFBD>
var headerRange = worksheet.Range["A1:F1"];
headerRange.Font.Bold = true;
headerRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightBlue);
// <20><><EFBFBD><EFBFBD><EFBFBD>ʹ<EFBFBD> ExtractTextEntitiesWithHeight<68><74><EFBFBD><EFBFBD> <20>̹<EFBFBD> <20><><EFBFBD>ĵǾ<C4B5><C7BE><EFBFBD><EFBFBD>Ƿ<EFBFBD> <20>ٽ<EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20>ʽ<EFBFBD><CABD>ϴ<EFBFBD>.
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20>Է<EFBFBD>
int row = 2;
foreach (var entity in textEntities) // sortedEntities<65><73> textEntities<65><73> <20><><EFBFBD><EFBFBD>
{
worksheet.Cells[row, 1] = entity.Height;
worksheet.Cells[row, 2] = entity.Type;
worksheet.Cells[row, 3] = entity.Layer;
worksheet.Cells[row, 4] = entity.Tag;
worksheet.Cells[row, 5] = fileName;
worksheet.Cells[row, 6] = entity.Text;
row++;
}
worksheet.Columns.AutoFit();
}
/// <summary>
/// Note 엔티티들을 Excel 워크시트에 기록합니다 (기존 데이터 아래에 추가).
/// CellBoundary 데이터를 사용하여 병합된 셀의 텍스트를 적절히 처리합니다.
/// </summary>
public void WriteNoteEntities(List<NoteEntityInfo> noteEntities, Excel.Worksheet worksheet, string fileName)
{
if (noteEntities == null || noteEntities.Count == 0)
{
Debug.WriteLine("[DEBUG] Note 엔티티가 없습니다.");
return;
}
try
{
// 현재 워크시트의 마지막 사용된 행 찾기
Excel.Range usedRange = null;
int lastRow = 1;
try
{
usedRange = worksheet.UsedRange;
lastRow = usedRange?.Rows.Count ?? 1;
}
catch (System.Exception ex)
{
Debug.WriteLine($"[DEBUG] UsedRange 접근 오류, 기본값 사용: {ex.Message}");
lastRow = 1;
}
int startRow = lastRow + 2; // 한 줄 띄우고 시작
Debug.WriteLine($"[DEBUG] Note 데이터 기록 시작: {startRow}행부터 {noteEntities.Count}개 항목");
// Note 섹션 헤더 추가 (표 컬럼 포함)
try
{
worksheet.Cells[startRow - 1, 1] = "=== Notes (with Cell Boundary Tables) ===";
worksheet.Cells[startRow - 1, 2] = "";
worksheet.Cells[startRow - 1, 3] = "";
worksheet.Cells[startRow - 1, 4] = "";
worksheet.Cells[startRow - 1, 5] = "";
worksheet.Cells[startRow - 1, 6] = "";
// 표 컬럼 헤더 추가 (G열부터 최대 20개 컬럼)
for (int col = 7; col <= 26; col++) // G~Z열 (20개 컬럼)
{
worksheet.Cells[startRow - 1, col] = $"Table Col {col - 6}";
var tableHeaderCell = (Excel.Range)worksheet.Cells[startRow - 1, col];
tableHeaderCell.Font.Bold = true;
tableHeaderCell.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightBlue);
tableHeaderCell.Font.Size = 9; // 작은 폰트로 설정
}
// 헤더 스타일 적용 (개별 셀로 처리)
var headerCell = (Excel.Range)worksheet.Cells[startRow - 1, 1];
headerCell.Font.Bold = true;
headerCell.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightYellow);
}
catch (System.Exception ex)
{
Debug.WriteLine($"[DEBUG] Note 헤더 작성 오류: {ex.Message}");
}
// Note 데이터 입력 (CellBoundary 데이터 사용)
int row = startRow;
try
{
foreach (var noteEntity in noteEntities)
{
// 기본 Note 정보 입력 (F열까지)
worksheet.Cells[row, 1] = 0; // Height는 0으로 설정
worksheet.Cells[row, 2] = noteEntity.Type ?? "";
worksheet.Cells[row, 3] = noteEntity.Layer ?? "";
worksheet.Cells[row, 4] = ""; // Tag는 빈 값
worksheet.Cells[row, 5] = fileName ?? "";
worksheet.Cells[row, 6] = noteEntity.Text ?? ""; // 일반 텍스트만 (표 데이터 제외)
int currentRow = row; // 현재 처리 중인 행 번호
// CellBoundary 데이터가 있으면 G열부터 테이블 데이터 처리
if (noteEntity.CellBoundaries != null && noteEntity.CellBoundaries.Count > 0)
{
Debug.WriteLine($"[DEBUG] CellBoundary 데이터 처리: Row {row}, 셀 수={noteEntity.CellBoundaries.Count}");
// CellBoundary의 각 셀을 해당 위치에 직접 배치
int maxTableRow = 0;
foreach (var cellBoundary in noteEntity.CellBoundaries)
{
var (sRow, sCol, eRow, eCol) = ParseCellRangeFromLabel(cellBoundary.Label);
Debug.WriteLine($"[DEBUG] CellBoundary 처리: {cellBoundary.Label} → Range=R{sRow}C{sCol}:R{eRow}C{eCol}, Text='{cellBoundary.CellText}'");
if (sRow > 0 && sCol > 0 && eRow > 0 && eCol > 0)
{
// 병합된 영역의 셀 개수 계산: (eRow - sRow) × (eCol - sCol)
int rowCount = eRow - sRow; // 행 개수 (bottomRight - topLeft)
int colCount = eCol - sCol; // 열 개수 (bottomRight - topLeft)
Debug.WriteLine($"[DEBUG] 병합 영역 크기: {rowCount+1}행 × {colCount+1}열");
// 병합된 영역의 모든 셀에 텍스트 복사 (topLeft부터 bottomRight-1까지)
for (int r = sRow; r < eRow; r++) // < eRow (bottomRight 제외)
{
for (int c = sCol; c < eCol; c++) // < eCol (bottomRight 제외)
{
// Excel에서 테이블 위치 계산:
// R1 → Note의 현재 행 (currentRow)
// R2 → Note의 다음 행 (currentRow + 1)
// C1 → G열(7), C2 → H열(8)
int excelRow = currentRow + (r - 1); // R1=currentRow, R2=currentRow+1, ...
int excelCol = 7 + (c - 1); // C1=G열(7), C2=H열(8), ...
// Excel 범위 체크 (최대 20개 컬럼까지)
if (excelCol <= 26) // Z열까지
{
// CellText가 비어있어도 일단 배치해보기 (디버그용)
var cellValue = string.IsNullOrEmpty(cellBoundary.CellText) ? "[빈셀]" : cellBoundary.CellText;
// 텍스트 형식으로 설정하여 "0:0" 같은 값이 시간으로 포맷되지 않도록 함
var cell = (Excel.Range)worksheet.Cells[excelRow, excelCol];
cell.NumberFormat = "@"; // 텍스트 형식
cell.Value = cellValue;
Debug.WriteLine($"[DEBUG] ✅ 셀 복사: R{r}C{c} → Excel[{excelRow},{excelCol}] = '{cellValue}'");
}
else
{
Debug.WriteLine($"[DEBUG] ❌ Excel 컬럼 범위 초과: {excelCol} > 26");
}
// 테이블이 차지하는 최대 행 수 추적
maxTableRow = Math.Max(maxTableRow, r);
}
}
}
else
{
Debug.WriteLine($"[DEBUG] ❌ 잘못된 Range: {cellBoundary.Label} → R{sRow}C{sCol}:R{eRow}C{eCol}");
}
}
// 테이블이 여러 행을 차지하는 경우 currentRow 업데이트
if (maxTableRow > 1)
{
currentRow += (maxTableRow - 1);
Debug.WriteLine($"[DEBUG] 테이블 행 수만큼 currentRow 업데이트: +{maxTableRow - 1} → {currentRow}");
}
}
else if (!string.IsNullOrEmpty(noteEntity.TableCsv))
{
// 기존 TableCsv 방식 (백업용)
var tableRows = noteEntity.TableCsv.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
Debug.WriteLine($"[DEBUG] 기존 표 데이터 처리: Row {row}, 표 행 수={tableRows.Length}");
for (int tableRowIndex = 0; tableRowIndex < tableRows.Length; tableRowIndex++)
{
var tableCells = tableRows[tableRowIndex].Split(',');
// 각 셀을 G열부터 배치 (최대 15개 컬럼까지)
for (int cellIndex = 0; cellIndex < Math.Min(tableCells.Length, 15); cellIndex++)
{
var cellValue = tableCells[cellIndex].Trim().Trim('"'); // 따옴표 제거
// 텍스트 형식으로 설정하여 "0:0" 같은 값이 시간으로 포맷되지 않도록 함
var cell = (Excel.Range)worksheet.Cells[currentRow, 7 + cellIndex];
cell.NumberFormat = "@"; // 텍스트 형식
cell.Value = cellValue; // G열(7)부터 시작
}
// 표의 첫 번째 행이 아니면 새로운 Excel 행 추가
if (tableRowIndex > 0)
{
currentRow++;
// 새로운 행에는 기본 Note 정보 복사 (Type, Layer 등)
worksheet.Cells[currentRow, 1] = 0;
worksheet.Cells[currentRow, 2] = noteEntity.Type ?? "";
worksheet.Cells[currentRow, 3] = noteEntity.Layer ?? "";
worksheet.Cells[currentRow, 4] = "";
worksheet.Cells[currentRow, 5] = fileName ?? "";
worksheet.Cells[currentRow, 6] = "(continued)"; // 연속 표시
}
Debug.WriteLine($"[DEBUG] 표 행 {tableRowIndex + 1}/{tableRows.Length}: Excel Row {currentRow}, 셀 수={tableCells.Length}");
}
}
// "NOTE" 타입인 경우 행 배경색 변경 (표 영역 포함)
if (noteEntity.Type == "Note")
{
Excel.Range noteRowRange = worksheet.Range[worksheet.Cells[row, 1], worksheet.Cells[currentRow, 26]]; // Z열까지
noteRowRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightYellow);
noteRowRange.Font.Bold = true;
}
Debug.WriteLine($"[DEBUG] Excel 기록: Row {row}~{currentRow}, Order {noteEntity.SortOrder}, Type {noteEntity.Type}, Pos({noteEntity.X:F1},{noteEntity.Y:F1}), Text: '{noteEntity.Text}', HasCellBoundaries: {noteEntity.CellBoundaries?.Count > 0} (Count: {noteEntity.CellBoundaries?.Count ?? 0}), HasTableCsv: {!string.IsNullOrEmpty(noteEntity.TableCsv)}");
// CellBoundaries 상세 디버그
if (noteEntity.CellBoundaries != null && noteEntity.CellBoundaries.Count > 0)
{
Debug.WriteLine($"[DEBUG] CellBoundaries 상세:");
foreach (var cb in noteEntity.CellBoundaries.Take(5)) // 처음 5개만 출력
{
Debug.WriteLine($"[DEBUG] {cb.Label}: '{cb.CellText}'");
}
}
// 다음 Note는 현재 행의 다음 행부터 시작
row = currentRow + 1;
}
Debug.WriteLine($"[DEBUG] Note 데이터 기록 완료: {row - startRow}개 항목");
}
catch (System.Exception ex)
{
Debug.WriteLine($"[DEBUG] Note 데이터 입력 오류: {ex.Message}");
Debug.WriteLine($"[DEBUG] 처리된 행: {row - startRow}개");
}
// AutoFit 시도 (오류 발생시 무시)
try
{
worksheet.Columns.AutoFit();
}
catch (System.Exception ex)
{
Debug.WriteLine($"[DEBUG] AutoFit 오류 (무시됨): {ex.Message}");
}
}
catch (System.Exception ex)
{
Debug.WriteLine($"❌ WriteNoteEntities 전체 오류: {ex.Message}");
Debug.WriteLine($" 스택 트레이스: {ex.StackTrace}");
throw; // 상위로 예외 전파
}
}
/// <summary>
/// 라벨에서 셀 범위 정보를 파싱합니다.
/// 예: "R1C2" → (1, 2, 1, 2) 또는 "R2C2→R3C4" → (2, 2, 3, 4)
/// </summary>
private (int sRow, int sCol, int eRow, int eCol) ParseCellRangeFromLabel(string label)
{
try
{
if (string.IsNullOrEmpty(label))
return (0, 0, 0, 0);
if (label.Contains("→"))
{
// "R2C2→R3C4" 형태 - 범위 파싱
var parts = label.Split('→');
if (parts.Length == 2)
{
var (sRow, sCol) = ParseSingleCell(parts[0]);
var (eRow, eCol) = ParseSingleCell(parts[1]);
return (sRow, sCol, eRow, eCol);
}
}
else
{
// "R1C2" 형태 - 단일 셀
var (row, col) = ParseSingleCell(label);
return (row, col, row, col);
}
return (0, 0, 0, 0);
}
catch
{
return (0, 0, 0, 0);
}
}
/// <summary>
/// 단일 셀 위치를 파싱합니다. (예: "R2C3" → (2, 3))
/// </summary>
private (int row, int col) ParseSingleCell(string cellStr)
{
try
{
if (string.IsNullOrEmpty(cellStr))
return (0, 0);
var rIndex = cellStr.IndexOf('R');
var cIndex = cellStr.IndexOf('C');
if (rIndex >= 0 && cIndex > rIndex)
{
var rowStr = cellStr.Substring(rIndex + 1, cIndex - rIndex - 1);
var colStr = cellStr.Substring(cIndex + 1);
if (int.TryParse(rowStr, out int row) && int.TryParse(colStr, out int col))
{
return (row, col);
}
}
return (0, 0);
}
catch
{
return (0, 0);
}
}
}
}

338
Models/ExcelManager.cs Normal file
View File

@@ -0,0 +1,338 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using Excel = Microsoft.Office.Interop.Excel;
namespace DwgExtractorManual.Models
{
/// <summary>
/// Excel COM <20><>ü <20><><EFBFBD><EFBFBD> <20><> <20><20>۾<EFBFBD><DBBE><EFBFBD> <20><><EFBFBD><EFBFBD>ϴ<EFBFBD> Ŭ<><C5AC><EFBFBD><EFBFBD>
/// </summary>
internal class ExcelManager : IDisposable
{
public Excel.Application? ExcelApplication { get; private set; }
public Excel.Workbook? TitleBlockWorkbook { get; private set; }
public Excel.Workbook? MappingWorkbook { get; private set; }
public Excel.Worksheet? TitleBlockSheet { get; private set; }
public Excel.Worksheet? TextEntitiesSheet { get; private set; }
public Excel.Worksheet? NoteEntitiesSheet { get; private set; }
public Excel.Worksheet? MappingSheet { get; private set; }
/// <summary>
/// Excel <20><><EFBFBD>ø<EFBFBD><C3B8><EFBFBD><EFBFBD>̼<EFBFBD> <20><> <20><>ũ<EFBFBD><C5A9>Ʈ <20>ʱ<EFBFBD>ȭ
/// </summary>
public void InitializeExcel()
{
try
{
var excelApp = new Excel.Application();
ExcelApplication = excelApp;
ExcelApplication.Visible = false; // WPF<50><46><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> ó<><C3B3>
Excel.Workbook workbook = excelApp.Workbooks.Add();
TitleBlockWorkbook = workbook;
// Title Block Sheet <20><><EFBFBD><EFBFBD> (<28>⺻ Sheet1)
TitleBlockSheet = (Excel.Worksheet)workbook.Sheets[1];
TitleBlockSheet.Name = "Title Block";
SetupTitleBlockHeaders();
// Text Entities Sheet <20>߰<EFBFBD>
TextEntitiesSheet = (Excel.Worksheet)workbook.Sheets.Add();
TextEntitiesSheet.Name = "Text Entities";
SetupTextEntitiesHeaders();
// Note Entities Sheet <20>߰<EFBFBD>
NoteEntitiesSheet = (Excel.Worksheet)workbook.Sheets.Add();
NoteEntitiesSheet.Name = "Note Entities";
SetupNoteEntitiesHeaders();
// <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD>Ϳ<EFBFBD> <20><>ũ<EFBFBD><C5A9> <20><> <20><>Ʈ <20><><EFBFBD><EFBFBD>
MappingWorkbook = excelApp.Workbooks.Add();
MappingSheet = (Excel.Worksheet)MappingWorkbook.Sheets[1];
MappingSheet.Name = "Mapping Data";
SetupMappingHeaders();
}
catch (System.Exception ex)
{
Debug.WriteLine($"Excel <20>ʱ<EFBFBD>ȭ <20><> <20><><EFBFBD><EFBFBD> <20>߻<EFBFBD>: {ex.Message}");
ReleaseExcelObjects();
throw;
}
}
/// <summary>
/// <20><><EFBFBD><EFBFBD> Excel <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><>Ʈ<EFBFBD><C6AE> <20><><EFBFBD><EFBFBD>
/// </summary>
public bool OpenExistingFile(string excelFilePath)
{
try
{
if (!File.Exists(excelFilePath))
{
Debug.WriteLine($"? Excel <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20>ʽ<EFBFBD><CABD>ϴ<EFBFBD>: {excelFilePath}");
return false;
}
if (ExcelApplication == null)
{
ExcelApplication = new Excel.Application();
ExcelApplication.Visible = false;
}
MappingWorkbook = ExcelApplication.Workbooks.Open(excelFilePath);
MappingSheet = (Excel.Worksheet)MappingWorkbook.Sheets["Mapping Data"];
if (MappingSheet == null)
{
Debug.WriteLine("? 'Mapping Data' <20><>Ʈ<EFBFBD><C6AE> ã<><C3A3> <20><> <20><><EFBFBD><EFBFBD><EFBFBD>ϴ<EFBFBD>.");
return false;
}
return true;
}
catch (System.Exception ex)
{
Debug.WriteLine($"? Excel <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD>: {ex.Message}");
return false;
}
}
/// <summary>
/// <20><><EFBFBD>ο<EFBFBD> <20><>ũ<EFBFBD><C5A9> <20><><EFBFBD><EFBFBD> (Height <20><><EFBFBD>Ŀ<EFBFBD>)
/// </summary>
public Excel.Workbook CreateNewWorkbook()
{
if (ExcelApplication == null)
{
ExcelApplication = new Excel.Application();
ExcelApplication.Visible = false;
}
return ExcelApplication.Workbooks.Add();
}
// Title Block <20><>Ʈ <20><><EFBFBD> <20><><EFBFBD><EFBFBD>
private void SetupTitleBlockHeaders()
{
if (TitleBlockSheet == null) return;
TitleBlockSheet.Cells[1, 1] = "Type"; // <20><>: AttributeReference, AttributeDefinition
TitleBlockSheet.Cells[1, 2] = "Name"; // BlockReference <20≯<EFBFBD> <20>Ǵ<EFBFBD> BlockDefinition <20≯<EFBFBD>
TitleBlockSheet.Cells[1, 3] = "Tag"; // Attribute Tag
TitleBlockSheet.Cells[1, 4] = "Prompt"; // Attribute Prompt
TitleBlockSheet.Cells[1, 5] = "Value"; // Attribute <20><> (TextString)
TitleBlockSheet.Cells[1, 6] = "Path"; // <20><><EFBFBD><EFBFBD> DWG <20><><EFBFBD><EFBFBD> <20><>ü <20><><EFBFBD>
TitleBlockSheet.Cells[1, 7] = "FileName"; // <20><><EFBFBD><EFBFBD> DWG <20><><EFBFBD><EFBFBD> <20≯<EFBFBD><CCB8><EFBFBD>
// <20><><EFBFBD> <20><> <20><>Ÿ<EFBFBD><C5B8>
Excel.Range headerRange = TitleBlockSheet.Range["A1:G1"];
headerRange.Font.Bold = true;
headerRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightBlue);
}
// Text Entities <20><>Ʈ <20><><EFBFBD> <20><><EFBFBD><EFBFBD>
private void SetupTextEntitiesHeaders()
{
if (TextEntitiesSheet == null) return;
TextEntitiesSheet.Cells[1, 1] = "Type"; // DBText, MText
TextEntitiesSheet.Cells[1, 2] = "Layer"; // Layer <20≯<EFBFBD>
TextEntitiesSheet.Cells[1, 3] = "Text"; // <20><><EFBFBD><EFBFBD> <20>ؽ<EFBFBD>Ʈ <20><><EFBFBD><EFBFBD>
TextEntitiesSheet.Cells[1, 4] = "Path"; // <20><><EFBFBD><EFBFBD> DWG <20><><EFBFBD><EFBFBD> <20><>ü <20><><EFBFBD>
TextEntitiesSheet.Cells[1, 5] = "FileName"; // <20><><EFBFBD><EFBFBD> DWG <20><><EFBFBD><EFBFBD> <20≯<EFBFBD><CCB8><EFBFBD>
// <20><><EFBFBD> <20><> <20><>Ÿ<EFBFBD><C5B8>
Excel.Range headerRange = TextEntitiesSheet.Range["A1:E1"];
headerRange.Font.Bold = true;
headerRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightGreen);
}
// <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><>Ʈ <20><><EFBFBD> <20><><EFBFBD><EFBFBD>
private void SetupMappingHeaders()
{
if (MappingSheet == null) return;
MappingSheet.Cells[1, 1] = "FileName"; // <20><><EFBFBD><EFBFBD> <20≯<EFBFBD>
MappingSheet.Cells[1, 2] = "MapKey"; // <20><><EFBFBD><EFBFBD> Ű
MappingSheet.Cells[1, 3] = "AILabel"; // AI <20><>
MappingSheet.Cells[1, 4] = "DwgTag"; // DWG Tag
MappingSheet.Cells[1, 5] = "Att_value"; // DWG <20><>
MappingSheet.Cells[1, 6] = "Pdf_value"; // PDF <20><> (<28><><EFBFBD><EFBFBD><EFBFBD> <20><> <20><>)
// <20><><EFBFBD> <20><> <20><>Ÿ<EFBFBD><C5B8>
Excel.Range headerRange = MappingSheet.Range["A1:F1"];
headerRange.Font.Bold = true;
headerRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightYellow);
}
// Note Entities <20><>Ʈ <20><><EFBFBD> <20><><EFBFBD><EFBFBD>
private void SetupNoteEntitiesHeaders()
{
if (NoteEntitiesSheet == null) return;
NoteEntitiesSheet.Cells[1, 1] = "Type"; // Note, NoteContent
NoteEntitiesSheet.Cells[1, 2] = "Layer"; // Layer <20≯<EFBFBD>
NoteEntitiesSheet.Cells[1, 3] = "Text"; // <20><><EFBFBD><EFBFBD> <20>ؽ<EFBFBD>Ʈ <20><><EFBFBD><EFBFBD>
NoteEntitiesSheet.Cells[1, 4] = "X"; // X <20><>ǥ
NoteEntitiesSheet.Cells[1, 5] = "Y"; // Y <20><>ǥ
NoteEntitiesSheet.Cells[1, 6] = "SortOrder"; // <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
NoteEntitiesSheet.Cells[1, 7] = "TableCsv"; // <20><><EFBFBD>̺<EFBFBD> CSV <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
NoteEntitiesSheet.Cells[1, 8] = "Path"; // <20><><EFBFBD><EFBFBD> DWG <20><><EFBFBD><EFBFBD> <20><>ü <20><><EFBFBD>
NoteEntitiesSheet.Cells[1, 9] = "FileName"; // <20><><EFBFBD><EFBFBD> DWG <20><><EFBFBD><EFBFBD> <20≯<EFBFBD><CCB8><EFBFBD>
// <20><><EFBFBD> <20><> <20><>Ÿ<EFBFBD><C5B8>
Excel.Range headerRange = NoteEntitiesSheet.Range["A1:I1"];
headerRange.Font.Bold = true;
headerRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightCoral);
}
/// <summary>
/// <20><>ũ<EFBFBD><C5A9> <20><><EFBFBD><EFBFBD>
/// </summary>
public bool SaveWorkbook(Excel.Workbook? workbook = null)
{
try
{
if (workbook != null)
{
workbook.Save();
return true;
}
if (MappingWorkbook != null)
{
MappingWorkbook.Save();
return true;
}
if (TitleBlockWorkbook != null)
{
TitleBlockWorkbook.Save();
return true;
}
return false;
}
catch (System.Exception ex)
{
Debug.WriteLine($"? Excel <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD>: {ex.Message}");
return false;
}
}
/// <summary>
/// <20><>ũ<EFBFBD><C5A9><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><>ο<EFBFBD> <20><><EFBFBD><EFBFBD>
/// </summary>
public void SaveWorkbookAs(Excel.Workbook? workbook, string savePath)
{
if (workbook == null) return;
string? directory = Path.GetDirectoryName(savePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
workbook.SaveAs(savePath,
FileFormat: Excel.XlFileFormat.xlOpenXMLWorkbook,
AccessMode: Excel.XlSaveAsAccessMode.xlNoChange);
}
/// <summary>
/// Excel <20><>Ʈ<EFBFBD><C6AE><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><> <20>ִ<EFBFBD> <20><>ȿ<EFBFBD><C8BF> <20≯<EFBFBD><CCB8><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD>մϴ<D5B4>.
/// </summary>
public string GetValidSheetName(string originalName)
{
if (string.IsNullOrEmpty(originalName))
return "Sheet";
// Excel <20><>Ʈ<EFBFBD><C6AE><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20>ʴ<EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
string validName = originalName;
char[] invalidChars = { '\\', '/', '?', '*', '[', ']', ':' };
foreach (char c in invalidChars)
{
validName = validName.Replace(c, '_');
}
// 31<33>ڷ<EFBFBD> <20><><EFBFBD><EFBFBD> (Excel <20><>Ʈ<EFBFBD><C6AE> <20>ִ<EFBFBD> <20><><EFBFBD><EFBFBD>)
if (validName.Length > 31)
{
validName = validName.Substring(0, 31);
}
return validName;
}
public void CloseWorkbooks()
{
if (TitleBlockWorkbook != null)
{
try { TitleBlockWorkbook.Close(false); }
catch { }
}
if (MappingWorkbook != null)
{
try { MappingWorkbook.Close(false); }
catch { }
}
if (ExcelApplication != null)
{
try { ExcelApplication.Quit(); }
catch { }
}
}
private void ReleaseExcelObjects()
{
ReleaseComObject(TitleBlockSheet);
ReleaseComObject(TextEntitiesSheet);
ReleaseComObject(NoteEntitiesSheet);
ReleaseComObject(MappingSheet);
ReleaseComObject(TitleBlockWorkbook);
ReleaseComObject(MappingWorkbook);
ReleaseComObject(ExcelApplication);
TitleBlockSheet = null;
TextEntitiesSheet = null;
NoteEntitiesSheet = null;
MappingSheet = null;
TitleBlockWorkbook = null;
MappingWorkbook = null;
ExcelApplication = null;
}
private void ReleaseComObject(object? obj)
{
try
{
if (obj != null && Marshal.IsComObject(obj))
{
Marshal.ReleaseComObject(obj);
}
}
catch (System.Exception)
{
// <20><><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD> <20>߻<EFBFBD> <20><> <20><><EFBFBD><EFBFBD>
}
}
public void Dispose()
{
try
{
Debug.WriteLine("[DEBUG] ExcelManager Dispose <20><><EFBFBD><EFBFBD>");
CloseWorkbooks();
ReleaseExcelObjects();
GC.Collect();
GC.WaitForPendingFinalizers();
Debug.WriteLine("[DEBUG] ExcelManager Dispose <20>Ϸ<EFBFBD>");
}
catch (System.Exception ex)
{
Debug.WriteLine($"[DEBUG] ExcelManager Dispose <20><> <20><><EFBFBD><EFBFBD>: {ex.Message}");
}
}
}
}

View File

@@ -1,384 +1,441 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; // COM 객체 해제를 위해 필요
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Teigha.DatabaseServices;
using Teigha.Geometry;
using Teigha.Runtime; using Teigha.Runtime;
using Excel = Microsoft.Office.Interop.Excel;
namespace DwgExtractorManual.Models namespace DwgExtractorManual.Models
{ {
/// <summary> /// <summary>
/// DWG 파일에서 Excel로 데이터 내보내기 클래스 /// DWG 파일에서 Excel로 데이터 내보내기 메인 클래스
/// AttributeReference, AttributeDefinition, DBText, MText 추출 지원 /// 리팩토링된 구조로 각 기능별 클래스를 조합하여 사용
/// </summary> /// </summary>
internal class ExportExcel : IDisposable internal class ExportExcel : IDisposable
{ {
// ODA 서비스 객체 // 컴포넌트들
private Services appServices; private readonly ExcelManager excelManager;
public readonly DwgDataExtractor DwgExtractor;
private readonly JsonDataProcessor jsonProcessor;
private readonly ExcelDataWriter excelWriter;
private readonly FieldMapper fieldMapper;
// Excel COM 객체들 // ODA 서비스 관리
private Excel.Application excelApplication; private Services? appServices;
private Excel.Workbook workbook1;
private Excel.Worksheet titleBlockSheet; // Title Block용 시트
private Excel.Worksheet textEntitiesSheet; // Text Entities용 시트
// 각 시트의 현재 행 번호 // 매핑 데이터 저장용
private int titleBlockCurrentRow = 2; // 헤더가 1행이므로 데이터는 2행부터 시작 private Dictionary<string, Dictionary<string, (string, string, string, string)>> FileToMapkeyToLabelTagValuePdf
private int textEntitiesCurrentRow = 2; // 헤더가 1행이므로 데이터는 2행부터 시작 = new Dictionary<string, Dictionary<string, (string, string, string, string)>>();
// 생성자: ODA 및 Excel 초기화 readonly List<string>? MapKeys;
/// <summary>
/// 생성자: 모든 컴포넌트 초기화
/// </summary>
public ExportExcel() public ExportExcel()
{
ActivateAndInitializeODA();
InitializeExcel();
}
// ODA 제품 활성화 및 초기화
private void ActivateAndInitializeODA()
{
var userInfo = "c2FtYW4gZW5naW5lZXJpbmc=";
var userSignature = "F0kuQTmtVpHtvl/TgaFVGE92/YqGmYR9SLoXckEjnOk8NoAQh7Sg6GQruVC04JqD4C/IipxJYqpqvMfMc2auRMG+cAJCKqKUE2djIMdkUdb+C5IVx2c97fcK5ba3n8DDvB54Upokajl+6j12yD8h8MKGOR3Z3zysObeXD62bFpQgp00GCYTqlxEZtTIRjHIPAfJfix8Y0jtXWWYyVJ3LYOu86as5xtx+hY1aakpYIJiQk/6pGd84qSn/9K1w8nxR7UrFzieDeQ/xM58BHSD4u/ZxVJwvv6Uy10tsdBFBTvfJMAFp05Y7yeyeCNr100tA3iOfmWoXAVRHfxnkPfiYR54aK04QI+R6OGkI+yd1oR5BtmN6BdDt3z8KYK5EpFGJGiJIGoUy5PvkYdJ2VV6xe9JWBiIJuI/tDn1Y+uyTQFA9qaDHnOURriXsRGfy8reDPf1eejybSJxWKkpilG6RXhq3xHlCkjZzh1Q45S+xYXNGatcWMm9nkn20M8Ke5JEVaI9w/p2GE36CHRtRQbt8kfPmsbWNXJCFr4svHW2MPbCKWoyn5XEyMWBnuAKi74zvczB13DKjf29SqSIgF5k/hwy2QrgvnaKzY1k8bw8w2/k0vJXcS3GKOB/ZYDle1tf/lkAD1HtnF9zE18TiXhVnqwAVjwg4ui1RPLn/LMs6b5Y=";
Services.odActivate(userInfo, userSignature);
appServices = new Services();
}
// Excel 애플리케이션 및 워크시트 초기화
private void InitializeExcel()
{ {
try try
{ {
var excelApp = new Excel.Application(); Debug.WriteLine("🔄 FieldMapper 로딩 중: mapping_table_json.json...");
excelApplication = excelApp; fieldMapper = FieldMapper.LoadFromFile("fletimageanalysis/mapping_table_json.json");
excelApplication.Visible = false; // WPF에서는 숨김 처리 Debug.WriteLine("✅ FieldMapper 로딩 성공");
Excel.Workbook workbook = excelApp.Workbooks.Add(); MapKeys = fieldMapper.GetAllDocAiKeys() ?? new List<string>();
workbook1 = workbook; Debug.WriteLine($"📊 총 DocAI 키 개수: {MapKeys?.Count ?? 0}");
// Title Block Sheet 설정 (기본 Sheet1) // 매핑 테스트 (디버깅용)
titleBlockSheet = (Excel.Worksheet)workbook.Sheets[1]; TestFieldMapper();
titleBlockSheet.Name = "Title Block";
SetupTitleBlockHeaders();
// Text Entities Sheet 추가 Debug.WriteLine("🔄 ODA 초기화 중...");
textEntitiesSheet = (Excel.Worksheet)workbook.Sheets.Add(); InitializeTeighaServices();
textEntitiesSheet.Name = "Text Entities";
SetupTextEntitiesHeaders(); // 컴포넌트들 초기화
excelManager = new ExcelManager();
DwgExtractor = new DwgDataExtractor(fieldMapper);
jsonProcessor = new JsonDataProcessor();
excelWriter = new ExcelDataWriter(excelManager);
Debug.WriteLine("🔄 Excel 초기화 중...");
excelManager.InitializeExcel();
} }
catch (System.Exception ex) catch (System.Exception ex)
{ {
Console.WriteLine($"Excel 초기화 중 오류 발생: {ex.Message}"); Debug.WriteLine($"❌ ExportExcel 초기화 오류: {ex.Message}");
ReleaseExcelObjects();
throw; throw;
} }
} }
// Title Block 시트 헤더 설정
private void SetupTitleBlockHeaders()
{
titleBlockSheet.Cells[1, 1] = "Type"; // 예: AttributeReference, AttributeDefinition
titleBlockSheet.Cells[1, 2] = "Name"; // BlockReference 이름 또는 BlockDefinition 이름
titleBlockSheet.Cells[1, 3] = "Tag"; // Attribute Tag
titleBlockSheet.Cells[1, 4] = "Prompt"; // Attribute Prompt
titleBlockSheet.Cells[1, 5] = "Value"; // Attribute 값 (TextString)
titleBlockSheet.Cells[1, 6] = "Path"; // 원본 DWG 파일 전체 경로
titleBlockSheet.Cells[1, 7] = "FileName"; // 원본 DWG 파일 이름만
// 헤더 행 스타일
Excel.Range headerRange = titleBlockSheet.Range["A1:G1"];
headerRange.Font.Bold = true;
headerRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightBlue);
}
// Text Entities 시트 헤더 설정
private void SetupTextEntitiesHeaders()
{
textEntitiesSheet.Cells[1, 1] = "Type"; // DBText, MText
textEntitiesSheet.Cells[1, 2] = "Layer"; // Layer 이름
textEntitiesSheet.Cells[1, 3] = "Text"; // 실제 텍스트 내용
textEntitiesSheet.Cells[1, 4] = "Path"; // 원본 DWG 파일 전체 경로
textEntitiesSheet.Cells[1, 5] = "FileName"; // 원본 DWG 파일 이름만
// 헤더 행 스타일
Excel.Range headerRange = textEntitiesSheet.Range["A1:E1"];
headerRange.Font.Bold = true;
headerRange.Interior.Color = System.Drawing.ColorTranslator.ToOle(System.Drawing.Color.LightGreen);
}
/// <summary> /// <summary>
/// 단일 DWG 파일에서 AttributeReference/AttributeDefinition 데이터를 추출하여 /// 단일 DWG 파일에서 데이터를 추출하여 Excel에 추가
/// 초기화된 Excel 워크시트에 추가합니다.
/// </summary> /// </summary>
/// <param name="filePath">처리할 DWG 파일 경로</param> public bool ExportDwgToExcel(string filePath, IProgress<double>? progress = null, CancellationToken cancellationToken = default)
/// <param name="progress">진행 상태 보고를 위한 IProgress 객체</param>
/// <param name="cancellationToken">작업 취소를 위한 CancellationToken</param>
/// <returns>성공 시 true, 실패 시 false 반환</returns>
public bool ExportDwgToExcel(string filePath, IProgress<double> progress = null, CancellationToken cancellationToken = default)
{ {
if (excelApplication == null) Debug.WriteLine($"[DEBUG] ExportDwgToExcel 시작: {filePath}");
if (excelManager.ExcelApplication == null)
{ {
Console.WriteLine("Excel이 초기화되지 않았습니다."); Debug.WriteLine("Excel이 초기화되지 않았습니다.");
return false;
}
if (!File.Exists(filePath))
{
Debug.WriteLine($"❌ 파일이 존재하지 않습니다: {filePath}");
return false; return false;
} }
try try
{ {
progress?.Report(0); // DWG 데이터 추출
cancellationToken.ThrowIfCancellationRequested(); var extractionResult = DwgExtractor.ExtractFromDwgFile(filePath, progress, cancellationToken);
// ODA Database 객체 생성 및 DWG 파일 읽기 if (extractionResult == null)
using (var database = new Database(false, true))
{ {
database.ReadDwgFile(filePath, FileOpenMode.OpenForReadAndWriteNoShare, false, null); return false;
}
cancellationToken.ThrowIfCancellationRequested(); // Excel에 데이터 기록
progress?.Report(10); excelWriter.WriteTitleBlockData(extractionResult.TitleBlockRows);
excelWriter.WriteTextEntityData(extractionResult.TextEntityRows);
using (var tran = database.TransactionManager.StartTransaction()) // 매핑 데이터 병합
foreach (var fileEntry in extractionResult.FileToMapkeyToLabelTagValuePdf)
{
if (!FileToMapkeyToLabelTagValuePdf.ContainsKey(fileEntry.Key))
{ {
var bt = tran.GetObject(database.BlockTableId, OpenMode.ForRead) as BlockTable; FileToMapkeyToLabelTagValuePdf[fileEntry.Key] = new Dictionary<string, (string, string, string, string)>();
using (var btr = tran.GetObject(bt[BlockTableRecord.ModelSpace], OpenMode.ForRead) as BlockTableRecord) }
{
int totalEntities = btr.Cast<ObjectId>().Count();
int processedCount = 0;
foreach (ObjectId entId in btr) foreach (var mapEntry in fileEntry.Value)
{ {
cancellationToken.ThrowIfCancellationRequested(); FileToMapkeyToLabelTagValuePdf[fileEntry.Key][mapEntry.Key] = mapEntry.Value;
using (var ent = tran.GetObject(entId, OpenMode.ForRead) as Entity)
{
// Layer 이름 가져오기 (공통)
string layerName = GetLayerName(ent.LayerId, tran, database);
// AttributeDefinition 추출
if (ent is AttributeDefinition attDef)
{
titleBlockSheet.Cells[titleBlockCurrentRow, 1] = attDef.GetType().Name;
titleBlockSheet.Cells[titleBlockCurrentRow, 2] = attDef.BlockName;
titleBlockSheet.Cells[titleBlockCurrentRow, 3] = attDef.Tag;
titleBlockSheet.Cells[titleBlockCurrentRow, 4] = attDef.Prompt;
titleBlockSheet.Cells[titleBlockCurrentRow, 5] = attDef.TextString;
titleBlockSheet.Cells[titleBlockCurrentRow, 6] = database.Filename;
titleBlockSheet.Cells[titleBlockCurrentRow, 7] = Path.GetFileName(database.Filename);
titleBlockCurrentRow++;
}
// BlockReference 및 그 안의 AttributeReference 추출
else if (ent is BlockReference blr)
{
foreach (ObjectId attId in blr.AttributeCollection)
{
cancellationToken.ThrowIfCancellationRequested();
using (var attRef = tran.GetObject(attId, OpenMode.ForRead) as AttributeReference)
{
if (attRef != null && attRef.TextString.Trim() !="")
{
titleBlockSheet.Cells[titleBlockCurrentRow, 1] = attRef.GetType().Name;
titleBlockSheet.Cells[titleBlockCurrentRow, 2] = blr.Name;
titleBlockSheet.Cells[titleBlockCurrentRow, 3] = attRef.Tag;
titleBlockSheet.Cells[titleBlockCurrentRow, 4] = GetPromptFromAttributeReference(tran, blr, attRef.Tag);
titleBlockSheet.Cells[titleBlockCurrentRow, 5] = attRef.TextString;
titleBlockSheet.Cells[titleBlockCurrentRow, 6] = database.Filename;
titleBlockSheet.Cells[titleBlockCurrentRow, 7] = Path.GetFileName(database.Filename);
titleBlockCurrentRow++;
}
}
}
}
// DBText 엔티티 추출 (별도 시트)
else if (ent is DBText dbText)
{
textEntitiesSheet.Cells[textEntitiesCurrentRow, 1] = "DBText"; // Type
textEntitiesSheet.Cells[textEntitiesCurrentRow, 2] = layerName; // Layer
textEntitiesSheet.Cells[textEntitiesCurrentRow, 3] = dbText.TextString; // Text
textEntitiesSheet.Cells[textEntitiesCurrentRow, 4] = database.Filename; // Path
textEntitiesSheet.Cells[textEntitiesCurrentRow, 5] = Path.GetFileName(database.Filename); // FileName
textEntitiesCurrentRow++;
}
// MText 엔티티 추출 (별도 시트)
else if (ent is MText mText)
{
textEntitiesSheet.Cells[textEntitiesCurrentRow, 1] = "MText"; // Type
textEntitiesSheet.Cells[textEntitiesCurrentRow, 2] = layerName; // Layer
textEntitiesSheet.Cells[textEntitiesCurrentRow, 3] = mText.Contents; // Text
textEntitiesSheet.Cells[textEntitiesCurrentRow, 4] = database.Filename; // Path
textEntitiesSheet.Cells[textEntitiesCurrentRow, 5] = Path.GetFileName(database.Filename); // FileName
textEntitiesCurrentRow++;
}
}
processedCount++;
double currentProgress = 10.0 + (double)processedCount / totalEntities * 80.0;
progress?.Report(Math.Min(currentProgress, 90.0));
}
}
tran.Commit();
} }
} }
progress?.Report(100); // 매핑 데이터를 Excel에 기록
excelWriter.WriteMappingDataToExcel(FileToMapkeyToLabelTagValuePdf);
return true; return true;
} }
catch (OperationCanceledException) catch (System.Exception ex)
{ {
progress?.Report(0); Debug.WriteLine($"❌ ExportDwgToExcel 오류: {ex.Message}");
return false; return false;
} }
}
/// <summary>
/// 기존 Excel 파일을 열어 JSON 파일의 PDF 분석 결과로 업데이트
/// </summary>
public bool UpdateExistingExcelWithJson(string excelFilePath, string jsonFilePath)
{
try
{
Debug.WriteLine($"[DEBUG] 기존 Excel 파일 업데이트 시작: {excelFilePath}");
if (!excelManager.OpenExistingFile(excelFilePath))
{
return false;
}
Debug.WriteLine("✅ 기존 Excel 파일 열기 성공");
return UpdateMappingSheetFromJson(jsonFilePath);
}
catch (System.Exception ex)
{
Debug.WriteLine($"❌ 기존 Excel 파일 업데이트 중 오류: {ex.Message}");
return false;
}
}
/// <summary>
/// JSON 파일에서 PDF 분석 결과를 읽어 Excel 매핑 시트 업데이트
/// </summary>
public bool UpdateMappingSheetFromJson(string jsonFilePath)
{
if (!File.Exists(jsonFilePath))
{
Debug.WriteLine($"❌ JSON 파일이 존재하지 않습니다: {jsonFilePath}");
return false;
}
// JSON 처리를 통해 매핑 데이터를 업데이트하고, Excel에 반영
return jsonProcessor.UpdateMappingDataFromJson(FileToMapkeyToLabelTagValuePdf, jsonFilePath) &&
UpdateExcelFromMappingData();
}
/// <summary>
/// DWG 파일들을 처리하여 Height 순으로 정렬된 Excel 파일 생성
/// </summary>
public void ExportDwgToExcelHeightSorted(string[] dwgFiles, string resultFolder)
{
try
{
Debug.WriteLine($"[DEBUG] Height 정렬 Excel 생성 시작: {dwgFiles.Length}개 파일");
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string savePath = Path.Combine(resultFolder, $"{timestamp}_HeightSorted.xlsx");
var heightSortedWorkbook = excelManager.CreateNewWorkbook();
bool firstSheetProcessed = false;
foreach (string dwgFile in dwgFiles)
{
if (!File.Exists(dwgFile))
{
continue;
}
string fileName = Path.GetFileNameWithoutExtension(dwgFile);
try
{
Microsoft.Office.Interop.Excel.Worksheet worksheet = firstSheetProcessed ?
(Microsoft.Office.Interop.Excel.Worksheet)heightSortedWorkbook.Worksheets.Add() :
(Microsoft.Office.Interop.Excel.Worksheet)heightSortedWorkbook.Worksheets[1];
worksheet.Name = excelManager.GetValidSheetName(fileName);
firstSheetProcessed = true;
// Note 엔티티 먼저 추출
var noteEntities = DwgExtractor.ExtractNotesFromDrawing(dwgFile);
// Note에서 사용된 텍스트 제외하고 일반 텍스트 추출
var textEntities = DwgExtractor.ExtractTextEntitiesWithHeightExcluding(dwgFile, noteEntities.UsedTextIds);
excelWriter.WriteHeightSortedData(textEntities, worksheet, fileName);
Debug.WriteLine($"[DEBUG] {fileName} 시트 완료: {textEntities.Count}개 엔티티");
}
catch (System.Exception ex)
{
Debug.WriteLine($"❌ {fileName} 처리 중 오류: {ex.Message}");
continue;
}
}
if (!firstSheetProcessed)
{
Microsoft.Office.Interop.Excel.Worksheet defaultSheet = (Microsoft.Office.Interop.Excel.Worksheet)heightSortedWorkbook.Worksheets[1];
defaultSheet.Name = "No_DWG_Files";
defaultSheet.Cells[1, 1] = "No DWG files found in this folder";
}
excelManager.SaveWorkbookAs(heightSortedWorkbook, savePath);
heightSortedWorkbook.Close(false);
Debug.WriteLine($"✅ Height 정렬 Excel 파일 저장 완료: {Path.GetFileName(savePath)}");
}
catch (System.Exception ex)
{
Debug.WriteLine($"❌ Height 정렬 Excel 생성 중 오류: {ex.Message}");
throw;
}
}
/// <summary>
/// 모든 DWG 파일들을 하나의 Excel 파일로 처리하여 Height 순으로 정렬
/// </summary>
public void ExportAllDwgToExcelHeightSorted(List<(string filePath, string folderName)> allDwgFiles, string savePath)
{
try
{
Debug.WriteLine($"[DEBUG] 단일 Excel 파일로 Height 정렬 생성 시작: {allDwgFiles.Count}개 파일");
// 시각화 데이터 초기화
MainWindow.ClearVisualizationData();
Debug.WriteLine("[VISUALIZATION] 시각화 데이터 초기화 완료");
var heightSortedWorkbook = excelManager.CreateNewWorkbook();
bool firstSheetProcessed = false;
foreach (var (filePath, folderName) in allDwgFiles)
{
if (!File.Exists(filePath))
{
continue;
}
string fileName = Path.GetFileNameWithoutExtension(filePath);
try
{
Microsoft.Office.Interop.Excel.Worksheet worksheet = firstSheetProcessed ?
(Microsoft.Office.Interop.Excel.Worksheet)heightSortedWorkbook.Worksheets.Add() :
(Microsoft.Office.Interop.Excel.Worksheet)heightSortedWorkbook.Worksheets[1];
worksheet.Name = excelManager.GetValidSheetName(fileName);
firstSheetProcessed = true;
// Note 엔티티 먼저 추출
var noteEntities = DwgExtractor.ExtractNotesFromDrawing(filePath);
// Note에서 사용된 텍스트 제외하고 일반 텍스트 추출
var textEntities = DwgExtractor.ExtractTextEntitiesWithHeightExcluding(filePath, noteEntities.UsedTextIds);
excelWriter.WriteHeightSortedData(textEntities, worksheet, fileName);
if (noteEntities.NoteEntities.Count > 0)
{
excelWriter.WriteNoteEntities(noteEntities.NoteEntities, worksheet, fileName);
Debug.WriteLine($"[DEBUG] {fileName}: {noteEntities.NoteEntities.Count}개 Note 엔티티 추가됨");
}
Debug.WriteLine($"[DEBUG] {fileName} 시트 완료: {textEntities.Count}개 엔티티");
}
catch (System.Exception ex)
{
Debug.WriteLine($"❌ {fileName} 처리 중 오류: {ex.Message}");
continue;
}
}
if (!firstSheetProcessed)
{
Microsoft.Office.Interop.Excel.Worksheet defaultSheet = (Microsoft.Office.Interop.Excel.Worksheet)heightSortedWorkbook.Worksheets[1];
defaultSheet.Name = "No_DWG_Files";
defaultSheet.Cells[1, 1] = "No DWG files found in any folder";
}
excelManager.SaveWorkbookAs(heightSortedWorkbook, savePath);
heightSortedWorkbook.Close(false);
Debug.WriteLine($"✅ 단일 Height 정렬 Excel 파일 저장 완료: {Path.GetFileName(savePath)}");
}
catch (System.Exception ex)
{
Debug.WriteLine($"❌ 단일 Height 정렬 Excel 생성 중 오류: {ex.Message}");
throw;
}
}
// Helper methods and legacy support methods
public bool SaveExcel() => excelManager.SaveWorkbook();
public void SaveMappingWorkbookOnly(string savePath) => excelManager.SaveWorkbookAs(excelManager.MappingWorkbook, savePath);
public void SaveDwgOnlyMappingWorkbook(string resultFolderPath) => excelWriter.SaveDwgOnlyMappingWorkbook(FileToMapkeyToLabelTagValuePdf, resultFolderPath);
public void SaveAndCloseExcel(string savePath)
{
try
{
excelManager.SaveWorkbookAs(excelManager.TitleBlockWorkbook, savePath);
if (excelManager.MappingWorkbook != null)
{
string directory = Path.GetDirectoryName(savePath) ?? "";
string mappingPath = Path.Combine(directory, Path.GetFileNameWithoutExtension(savePath) + "_Mapping.xlsx");
excelManager.SaveWorkbookAs(excelManager.MappingWorkbook, mappingPath);
}
}
catch (System.Exception ex)
{
Debug.WriteLine($"Excel 파일 저장 중 오류 발생: {ex.Message}");
}
finally
{
excelManager.CloseWorkbooks();
}
}
public void CloseExcelObjectsWithoutSaving() => excelManager.CloseWorkbooks();
public void SaveMappingDictionary(string filePath) => jsonProcessor.SaveMappingDictionary(FileToMapkeyToLabelTagValuePdf, filePath);
public void LoadMappingDictionary(string filePath)
{
FileToMapkeyToLabelTagValuePdf = jsonProcessor.LoadMappingDictionary(filePath);
}
public void WriteCompleteMapping() => excelWriter.WriteMappingDataToExcel(FileToMapkeyToLabelTagValuePdf);
public void UpdateWithPdfData(string jsonFilePath) => jsonProcessor.UpdateMappingDataFromJson(FileToMapkeyToLabelTagValuePdf, jsonFilePath);
public void ClearAccumulatedData() => FileToMapkeyToLabelTagValuePdf.Clear();
// Private helper methods
private void TestFieldMapper()
{
Debug.WriteLine("[DEBUG] Testing field mapper...");
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}");
}
}
private void InitializeTeighaServices()
{
try
{
Debug.WriteLine("[DEBUG] TeighaServicesManager를 통한 Services 획득 중...");
appServices = TeighaServicesManager.Instance.AcquireServices();
Debug.WriteLine($"[DEBUG] Services 획득 성공. Reference Count: {TeighaServicesManager.Instance.ReferenceCount}");
}
catch (Teigha.Runtime.Exception ex) catch (Teigha.Runtime.Exception ex)
{ {
progress?.Report(0); Debug.WriteLine($"[DEBUG] Teigha Services 초기화 실패: {ex.Message}");
Console.WriteLine($"DWG 파일 처리 중 오류 발생: {ex.Message}"); throw;
return false;
}
catch (System.Exception ex)
{
progress?.Report(0);
Console.WriteLine($"일반 오류 발생: {ex.Message}");
return false;
} }
} }
// Paste the helper function from above here
public string GetPromptFromAttributeReference(Transaction tr, BlockReference blockref, string tag) private bool UpdateExcelFromMappingData()
{ {
try
string prompt = null;
BlockTableRecord blockDef = tr.GetObject(blockref.BlockTableRecord, OpenMode.ForRead) as BlockTableRecord;
if (blockDef == null) return null;
foreach (ObjectId objId in blockDef)
{ {
AttributeDefinition attDef = tr.GetObject(objId, OpenMode.ForRead) as AttributeDefinition; foreach (var fileEntry in FileToMapkeyToLabelTagValuePdf)
if (attDef != null)
{ {
if (attDef.Tag.Equals(tag, System.StringComparison.OrdinalIgnoreCase)) foreach (var mapEntry in fileEntry.Value)
{ {
prompt = attDef.Prompt; var (aiLabel, dwgTag, dwgValue, pdfValue) = mapEntry.Value;
break; if (!string.IsNullOrEmpty(pdfValue))
{
excelWriter.UpdateExcelRow(fileEntry.Key, aiLabel, pdfValue);
}
} }
} }
} return true;
return prompt;
}
/// <summary>
/// 현재 Excel 워크북을 지정된 경로에 저장하고 Excel 애플리케이션을 종료합니다.
/// </summary>
/// <param name="savePath">Excel 파일을 저장할 전체 경로</param>
public void SaveAndCloseExcel(string savePath)
{
if (workbook1 == null) return;
try
{
string directory = Path.GetDirectoryName(savePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
workbook1.SaveAs(savePath, AccessMode: Excel.XlSaveAsAccessMode.xlNoChange);
} }
catch (System.Exception ex) catch (System.Exception ex)
{ {
Console.WriteLine($"Excel 파일 저장 중 오류 발생: {ex.Message}"); Debug.WriteLine($"Excel 업데이트 중 오류: {ex.Message}");
} return false;
finally
{
CloseExcelObjects();
}
}
private void CloseExcelObjects()
{
if (workbook1 != null)
{
try { workbook1.Close(false); }
catch { }
}
if (excelApplication != null)
{
try { excelApplication.Quit(); }
catch { }
}
ReleaseExcelObjects();
}
private void ReleaseExcelObjects()
{
ReleaseComObject(titleBlockSheet);
ReleaseComObject(textEntitiesSheet);
ReleaseComObject(workbook1);
ReleaseComObject(excelApplication);
titleBlockSheet = null;
textEntitiesSheet = null;
workbook1 = null;
excelApplication = null;
}
private void ReleaseComObject(object obj)
{
try
{
if (obj != null && Marshal.IsComObject(obj))
{
Marshal.ReleaseComObject(obj);
}
}
catch (System.Exception)
{
// 해제 중 오류 발생 시 무시
}
finally
{
obj = null;
}
}
/// <summary>
/// Layer ID로부터 Layer 이름을 가져옵니다.
/// </summary>
/// <param name="layerId">Layer ObjectId</param>
/// <param name="transaction">현재 트랜잭션</param>
/// <param name="database">데이터베이스 객체</param>
/// <returns>Layer 이름 또는 빈 문자열</returns>
private string GetLayerName(ObjectId layerId, Transaction transaction, Database database)
{
try
{
using (var layerTableRecord = transaction.GetObject(layerId, OpenMode.ForRead) as LayerTableRecord)
{
return layerTableRecord?.Name ?? "";
}
}
catch (System.Exception ex)
{
Console.WriteLine($"Layer 이름 가져오기 오류: {ex.Message}");
return "";
} }
} }
public void Dispose() public void Dispose()
{ {
if (excelApplication != null) try
{ {
CloseExcelObjects(); Debug.WriteLine("[DEBUG] ExportExcel Dispose 시작");
}
if (appServices != null) excelManager?.Dispose();
if (appServices != null)
{
Debug.WriteLine("[DEBUG] Teigha Services 해제 중...");
try
{
TeighaServicesManager.Instance.ReleaseServices();
Debug.WriteLine($"[DEBUG] Teigha Services 해제 완료. Remaining ref count: {TeighaServicesManager.Instance.ReferenceCount}");
}
catch (System.Exception ex)
{
Debug.WriteLine($"[DEBUG] Teigha Services 해제 중 오류 (무시됨): {ex.Message}");
}
finally
{
appServices = null;
}
}
Debug.WriteLine("[DEBUG] ExportExcel Dispose 완료");
}
catch (System.Exception ex)
{ {
appServices.Dispose(); Debug.WriteLine($"[DEBUG] ExportExcel Dispose 중 전역 오류: {ex.Message}");
appServices = null;
} }
} }
} }

View File

@@ -0,0 +1,267 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Teigha.Geometry;
namespace DwgExtractorManual.Models
{
/// <summary>
/// 교차점 생성 및 셀 추출 로직을 테스트하고 디버깅하는 클래스
/// </summary>
public class IntersectionTestDebugger
{
/// <summary>
/// 간단한 테스트 테이블을 만들어서 교차점과 셀 생성을 테스트합니다.
/// </summary>
public static void RunIntersectionTest()
{
Debug.WriteLine("=== 교차점 및 셀 생성 테스트 시작 ===");
try
{
// 테스트용 테이블 생성 (3x4 그리드)
var testSegments = CreateTestTable();
Debug.WriteLine($"테스트 선분 개수: {testSegments.Count}");
// DwgDataExtractor 인스턴스 생성 (실제 코드와 동일)
var mappingData = new MappingTableData();
var fieldMapper = new FieldMapper(mappingData);
var extractor = new DwgDataExtractor(fieldMapper);
// 교차점 찾기 테스트
var intersections = FindTestIntersections(testSegments, extractor);
Debug.WriteLine($"발견된 교차점 개수: {intersections.Count}");
// 각 교차점의 DirectionBits 출력
for (int i = 0; i < intersections.Count; i++)
{
var intersection = intersections[i];
Debug.WriteLine($"교차점 {i}: ({intersection.Position.X:F1}, {intersection.Position.Y:F1}) - DirectionBits: {intersection.DirectionBits} - R{intersection.Row}C{intersection.Column}");
// topLeft/bottomRight 검증
bool isTopLeft = extractor.IsValidTopLeft(intersection.DirectionBits);
bool isBottomRight = extractor.IsValidBottomRight(intersection.DirectionBits);
Debug.WriteLine($" IsTopLeft: {isTopLeft}, IsBottomRight: {isBottomRight}");
}
Debug.WriteLine("=== 테스트 완료 ===");
}
catch (Exception ex)
{
Debug.WriteLine($"테스트 중 오류 발생: {ex.Message}");
Debug.WriteLine(ex.StackTrace);
}
}
/// <summary>
/// 테스트용 3x4 테이블 선분들을 생성합니다.
/// </summary>
private static List<(Point3d start, Point3d end, bool isHorizontal)> CreateTestTable()
{
var segments = new List<(Point3d start, Point3d end, bool isHorizontal)>();
// 수평선들 (4개 - 0, 10, 20, 30 Y좌표)
for (int i = 0; i <= 3; i++)
{
double y = i * 10.0;
segments.Add((new Point3d(0, y, 0), new Point3d(40, y, 0), true));
}
// 수직선들 (5개 - 0, 10, 20, 30, 40 X좌표)
for (int i = 0; i <= 4; i++)
{
double x = i * 10.0;
segments.Add((new Point3d(x, 0, 0), new Point3d(x, 30, 0), false));
}
Debug.WriteLine($"생성된 테스트 테이블: 수평선 4개, 수직선 5개");
return segments;
}
/// <summary>
/// 테스트 선분들로부터 교차점을 찾습니다.
/// </summary>
private static List<IntersectionPoint> FindTestIntersections(List<(Point3d start, Point3d end, bool isHorizontal)> segments, DwgDataExtractor extractor)
{
var intersections = new List<IntersectionPoint>();
double tolerance = 0.1;
var horizontalSegments = segments.Where(s => s.isHorizontal).ToList();
var verticalSegments = segments.Where(s => !s.isHorizontal).ToList();
foreach (var hSeg in horizontalSegments)
{
foreach (var vSeg in verticalSegments)
{
// 교차점 계산
double intersectX = vSeg.start.X;
double intersectY = hSeg.start.Y;
var intersectPoint = new Point3d(intersectX, intersectY, 0);
// 교차점이 두 선분의 범위 내에 있는지 확인
bool onHorizontal = intersectX >= Math.Min(hSeg.start.X, hSeg.end.X) - tolerance &&
intersectX <= Math.Max(hSeg.start.X, hSeg.end.X) + tolerance;
bool onVertical = intersectY >= Math.Min(vSeg.start.Y, vSeg.end.Y) - tolerance &&
intersectY <= Math.Max(vSeg.start.Y, vSeg.end.Y) + tolerance;
if (onHorizontal && onVertical)
{
// DirectionBits 계산
int directionBits = CalculateDirectionBits(intersectPoint, segments, tolerance);
// Row, Column 계산 (1-based)
int row = (int)Math.Round(intersectY / 10.0) + 1;
int column = (int)Math.Round(intersectX / 10.0) + 1;
var intersection = new IntersectionPoint
{
Position = intersectPoint,
DirectionBits = directionBits,
Row = row,
Column = column
};
intersections.Add(intersection);
}
}
}
return intersections;
}
/// <summary>
/// 특정 점에서의 DirectionBits를 계산합니다.
/// </summary>
private static int CalculateDirectionBits(Point3d point, List<(Point3d start, Point3d end, bool isHorizontal)> segments, double tolerance)
{
int bits = 0;
// Right: 1, Up: 2, Left: 4, Down: 8
foreach (var segment in segments)
{
if (segment.isHorizontal)
{
// 수평선에서 점이 선분 위에 있는지 확인
if (Math.Abs(point.Y - segment.start.Y) < tolerance &&
point.X >= Math.Min(segment.start.X, segment.end.X) - tolerance &&
point.X <= Math.Max(segment.start.X, segment.end.X) + tolerance)
{
// 오른쪽으로 선분이 있는지 확인
if (Math.Max(segment.start.X, segment.end.X) > point.X + tolerance)
bits |= 1; // Right
// 왼쪽으로 선분이 있는지 확인
if (Math.Min(segment.start.X, segment.end.X) < point.X - tolerance)
bits |= 4; // Left
}
}
else
{
// 수직선에서 점이 선분 위에 있는지 확인
if (Math.Abs(point.X - segment.start.X) < tolerance &&
point.Y >= Math.Min(segment.start.Y, segment.end.Y) - tolerance &&
point.Y <= Math.Max(segment.start.Y, segment.end.Y) + tolerance)
{
// 위쪽으로 선분이 있는지 확인
if (Math.Max(segment.start.Y, segment.end.Y) > point.Y + tolerance)
bits |= 2; // Up
// 아래쪽으로 선분이 있는지 확인
if (Math.Min(segment.start.Y, segment.end.Y) < point.Y - tolerance)
bits |= 8; // Down
}
}
}
return bits;
}
/// <summary>
/// 교차점들로부터 셀을 추출합니다.
/// </summary>
private static List<TableCell> ExtractTestCells(List<IntersectionPoint> intersections,
List<(Point3d start, Point3d end, bool isHorizontal)> segments,
DwgDataExtractor extractor)
{
var cells = new List<TableCell>();
double tolerance = 0.1;
// topLeft 후보들을 찾아서 각각에 대해 bottomRight를 찾기
var topLeftCandidates = intersections.Where(i => extractor.IsValidTopLeft(i.DirectionBits)).ToList();
Debug.WriteLine($"TopLeft 후보 개수: {topLeftCandidates.Count}");
foreach (var topLeft in topLeftCandidates)
{
Debug.WriteLine($"\nTopLeft 후보 R{topLeft.Row}C{topLeft.Column} 처리 중...");
// bottomRight 찾기 (실제 코드와 동일한 방식)
var bottomRight = FindBottomRightForTest(topLeft, intersections, extractor);
if (bottomRight != null)
{
Debug.WriteLine($" BottomRight 발견: R{bottomRight.Row}C{bottomRight.Column}");
// 셀 생성
var cell = new TableCell
{
MinPoint = new Point3d(topLeft.Position.X, bottomRight.Position.Y, 0),
MaxPoint = new Point3d(bottomRight.Position.X, topLeft.Position.Y, 0),
Row = topLeft.Row,
Column = topLeft.Column,
CellText = $"R{topLeft.Row}C{topLeft.Column}"
};
cells.Add(cell);
Debug.WriteLine($" 셀 생성 완료: ({cell.MinPoint.X:F1},{cell.MinPoint.Y:F1}) to ({cell.MaxPoint.X:F1},{cell.MaxPoint.Y:F1})");
}
else
{
Debug.WriteLine($" BottomRight을 찾지 못함");
}
}
return cells;
}
/// <summary>
/// 테스트용 bottomRight 찾기 메서드
/// </summary>
private static IntersectionPoint FindBottomRightForTest(IntersectionPoint topLeft,
List<IntersectionPoint> intersections,
DwgDataExtractor extractor)
{
// 교차점들을 Row/Column으로 딕셔너리 구성
var intersectionLookup = intersections
.Where(i => i.Row > 0 && i.Column > 0)
.GroupBy(i => i.Row)
.ToDictionary(g => g.Key, g => g.ToDictionary(i => i.Column, i => i));
// topLeft에서 시작하여 bottomRight 찾기
int maxRow = intersectionLookup.Keys.Any() ? intersectionLookup.Keys.Max() : topLeft.Row;
for (int targetRow = topLeft.Row + 1; targetRow <= maxRow + 2; targetRow++)
{
if (!intersectionLookup.ContainsKey(targetRow)) continue;
var rowIntersections = intersectionLookup[targetRow];
var availableColumns = rowIntersections.Keys.Where(col => col >= topLeft.Column).OrderBy(col => col);
foreach (int targetColumn in availableColumns)
{
var candidate = rowIntersections[targetColumn];
if (extractor.IsValidBottomRight(candidate.DirectionBits) ||
(targetRow == maxRow && targetColumn == intersectionLookup.Values.SelectMany(row => row.Keys).Max()))
{
return candidate;
}
}
}
return null;
}
}
}

317
Models/JsonDataProcessor.cs Normal file
View File

@@ -0,0 +1,317 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace DwgExtractorManual.Models
{
/// <summary>
/// JSON 파일 처리 및 매핑 데이터 관리를 담당하는 클래스
/// </summary>
internal class JsonDataProcessor
{
/// <summary>
/// JSON 파일에서 PDF 분석 결과를 읽어 매핑 데이터를 업데이트
/// </summary>
public bool UpdateMappingDataFromJson(Dictionary<string, Dictionary<string, (string, string, string, string)>> mappingData, string jsonFilePath)
{
try
{
Debug.WriteLine($"[DEBUG] JSON 파일에서 PDF 값 업데이트 시작: {jsonFilePath}");
if (!File.Exists(jsonFilePath))
{
Debug.WriteLine($"? JSON 파일이 존재하지 않습니다: {jsonFilePath}");
return false;
}
// JSON 파일 읽기 및 정리
string jsonContent = File.ReadAllText(jsonFilePath, System.Text.Encoding.UTF8);
jsonContent = CleanJsonContent(jsonContent);
JObject jsonData;
try
{
jsonData = JObject.Parse(jsonContent);
}
catch (Newtonsoft.Json.JsonReaderException jsonEx)
{
Debug.WriteLine($"? JSON 파싱 오류: {jsonEx.Message}");
throw new System.Exception($"PDF 분석 JSON 파일 파싱 실패: {jsonEx.Message}\n파일: {jsonFilePath}");
}
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);
if (!mappingData.ContainsKey(fileNameWithoutExt))
{
Debug.WriteLine($"?? 매핑 데이터에 파일이 없습니다: {fileNameWithoutExt}");
continue;
}
foreach (var property in pdfAnalysis.Cast<JProperty>())
{
string aiLabel = property.Name;
var valueObj = property.Value as JObject;
if (valueObj == null) continue;
string pdfValue = valueObj["value"]?.ToString();
if (string.IsNullOrEmpty(pdfValue)) continue;
totalEntries++;
var fileData = mappingData[fileNameWithoutExt];
var matchingEntry = fileData.FirstOrDefault(kvp =>
string.Equals(kvp.Value.Item1.Trim(), aiLabel.Trim(), StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(matchingEntry.Key))
{
var existingValue = matchingEntry.Value;
fileData[matchingEntry.Key] = (existingValue.Item1, existingValue.Item2, existingValue.Item3, pdfValue);
updatedCount++;
}
}
}
Debug.WriteLine($"[DEBUG] PDF 데이터 업데이트 완료: {updatedCount}/{totalEntries} 업데이트됨");
return true;
}
catch (System.Exception ex)
{
Debug.WriteLine($"? JSON에서 PDF 값 업데이트 중 오류: {ex.Message}");
return false;
}
}
/// <summary>
/// 매핑 딕셔너리를 JSON 파일로 저장
/// </summary>
public void SaveMappingDictionary(Dictionary<string, Dictionary<string, (string, string, string, string)>> mappingData, string filePath)
{
try
{
Debug.WriteLine($"[DEBUG] 매핑 딕셔너리 저장 시작: {filePath}");
var serializableData = new Dictionary<string, Dictionary<string, object>>();
foreach (var fileEntry in mappingData)
{
var fileData = new Dictionary<string, object>();
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;
}
string jsonContent = JsonConvert.SerializeObject(serializableData, Formatting.Indented);
File.WriteAllText(filePath, jsonContent, System.Text.Encoding.UTF8);
Debug.WriteLine($"? 매핑 딕셔너리 저장 완료: {Path.GetFileName(filePath)}");
Debug.WriteLine($"?? 저장된 파일 수: {mappingData.Count}");
}
catch (System.Exception ex)
{
Debug.WriteLine($"? 매핑 딕셔너리 저장 중 오류: {ex.Message}");
throw;
}
}
/// <summary>
/// JSON 파일에서 매핑 딕셔너리를 로드
/// </summary>
public Dictionary<string, Dictionary<string, (string, string, string, string)>> LoadMappingDictionary(string filePath)
{
var result = new Dictionary<string, Dictionary<string, (string, string, string, string)>>();
try
{
Debug.WriteLine($"[DEBUG] 매핑 딕셔너리 로드 시작: {filePath}");
if (!File.Exists(filePath))
{
Debug.WriteLine($"?? 매핑 파일이 존재하지 않습니다: {filePath}");
return result;
}
string jsonContent = File.ReadAllText(filePath, System.Text.Encoding.UTF8);
jsonContent = CleanJsonContent(jsonContent);
Dictionary<string, Dictionary<string, JObject>> deserializedData;
try
{
deserializedData = JsonConvert.DeserializeObject<Dictionary<string, Dictionary<string, JObject>>>(jsonContent);
}
catch (Newtonsoft.Json.JsonReaderException jsonEx)
{
Debug.WriteLine($"? JSON 파싱 오류: {jsonEx.Message}");
throw new System.Exception($"매핑 JSON 파일 파싱 실패: {jsonEx.Message}\n파일: {filePath}");
}
if (deserializedData != null)
{
foreach (var fileEntry in deserializedData)
{
var fileData = new Dictionary<string, (string, string, string, string)>();
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);
}
result[fileEntry.Key] = fileData;
}
}
Debug.WriteLine($"? 매핑 딕셔너리 로드 완료");
Debug.WriteLine($"?? 로드된 파일 수: {result.Count}");
return result;
}
catch (System.Exception ex)
{
Debug.WriteLine($"? 매핑 딕셔너리 로드 중 오류: {ex.Message}");
throw;
}
}
/// <summary>
/// JSON 내용을 정리하여 파싱 가능한 상태로 만듭니다.
/// </summary>
private string CleanJsonContent(string jsonContent)
{
if (string.IsNullOrEmpty(jsonContent))
return jsonContent;
try
{
var lines = jsonContent.Split('\n');
var cleanedLines = new List<string>();
bool inMultiLineComment = false;
foreach (string line in lines)
{
string processedLine = line;
// 멀티라인 주석 처리
if (inMultiLineComment)
{
int endIndex = processedLine.IndexOf("*/");
if (endIndex >= 0)
{
processedLine = processedLine.Substring(endIndex + 2);
inMultiLineComment = false;
}
else
{
continue;
}
}
int multiLineStart = processedLine.IndexOf("/*");
if (multiLineStart >= 0)
{
int multiLineEnd = processedLine.IndexOf("*/", multiLineStart + 2);
if (multiLineEnd >= 0)
{
processedLine = processedLine.Substring(0, multiLineStart) +
processedLine.Substring(multiLineEnd + 2);
}
else
{
processedLine = processedLine.Substring(0, multiLineStart);
inMultiLineComment = true;
}
}
// 싱글라인 주석 제거
bool inString = false;
bool escaped = false;
int commentIndex = -1;
for (int i = 0; i < processedLine.Length - 1; i++)
{
char current = processedLine[i];
char next = processedLine[i + 1];
if (escaped)
{
escaped = false;
continue;
}
if (current == '\\')
{
escaped = true;
continue;
}
if (current == '"')
{
inString = !inString;
continue;
}
if (!inString && current == '/' && next == '/')
{
commentIndex = i;
break;
}
}
if (commentIndex >= 0)
{
processedLine = processedLine.Substring(0, commentIndex);
}
if (!string.IsNullOrWhiteSpace(processedLine))
{
cleanedLines.Add(processedLine);
}
}
return string.Join("\n", cleanedLines);
}
catch (System.Exception ex)
{
Debug.WriteLine($"? JSON 정리 중 오류: {ex.Message}");
return jsonContent;
}
}
}
}

View File

@@ -0,0 +1,177 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Collections.Generic;
namespace DwgExtractorManual.Models
{
/// <summary>
/// Note 박스 텍스트와 테이블 추출 기능을 테스트하는 클래스
/// </summary>
public class NoteExtractionTester
{
/// <summary>
/// DWG 파일에서 Note 데이터를 추출하고 CSV로 저장하는 전체 테스트
/// </summary>
public static void TestNoteExtractionAndCsvExport(string dwgFilePath, string outputDirectory)
{
try
{
Debug.WriteLine("=== Note 추출 및 CSV 내보내기 테스트 시작 ===");
// 출력 디렉토리가 없으면 생성
if (!Directory.Exists(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
Debug.WriteLine($"출력 디렉토리 생성: {outputDirectory}");
}
// 1. Teigha 서비스 초기화
Debug.WriteLine("1. Teigha 서비스 초기화 중...");
TeighaServicesManager.Instance.AcquireServices();
// 2. DwgDataExtractor 인스턴스 생성
Debug.WriteLine("2. DwgDataExtractor 인스턴스 생성...");
var mappingData = new MappingTableData();
var fieldMapper = new FieldMapper(mappingData);
var extractor = new DwgDataExtractor(fieldMapper);
// 3. Note 데이터 추출
Debug.WriteLine($"3. DWG 파일에서 Note 추출 중: {Path.GetFileName(dwgFilePath)}");
var noteEntities = extractor.ExtractNotesFromDrawing(dwgFilePath);
Debug.WriteLine($" 추출된 Note 엔터티 수: {noteEntities.NoteEntities.Count}");
// 4. Note 데이터 분석
AnalyzeNoteData(noteEntities.NoteEntities);
// 5. CSV 내보내기
Debug.WriteLine("5. CSV 파일 생성 중...");
var csvWriter = new CsvDataWriter();
var baseFileName = Path.GetFileNameWithoutExtension(dwgFilePath);
// 5-1. Note 박스 텍스트만 CSV로 저장
var noteTextCsvPath = Path.Combine(outputDirectory, $"{baseFileName}_note_texts.csv");
csvWriter.WriteNoteBoxTextToCsv(noteEntities.NoteEntities, noteTextCsvPath);
Debug.WriteLine($" Note 텍스트 CSV 저장: {noteTextCsvPath}");
// 5-2. Note 테이블 데이터만 CSV로 저장
var noteTableCsvPath = Path.Combine(outputDirectory, $"{baseFileName}_note_tables.csv");
csvWriter.WriteNoteTablesToCsv(noteEntities.NoteEntities, noteTableCsvPath);
Debug.WriteLine($" Note 테이블 CSV 저장: {noteTableCsvPath}");
// 5-3. 통합 CSV 저장
var combinedCsvPath = Path.Combine(outputDirectory, $"{baseFileName}_note_combined.csv");
csvWriter.WriteNoteDataToCombinedCsv(noteEntities.NoteEntities, combinedCsvPath);
Debug.WriteLine($" 통합 CSV 저장: {combinedCsvPath}");
// 5-4. 개별 테이블 CSV 저장
var individualTablesDir = Path.Combine(outputDirectory, $"{baseFileName}_individual_tables");
csvWriter.WriteIndividualNoteTablesCsv(noteEntities.NoteEntities, individualTablesDir);
Debug.WriteLine($" 개별 테이블 CSV 저장: {individualTablesDir}");
// 5-5. 통계 정보 CSV 저장
var statisticsCsvPath = Path.Combine(outputDirectory, $"{baseFileName}_note_statistics.csv");
csvWriter.WriteNoteStatisticsToCsv(noteEntities.NoteEntities, statisticsCsvPath);
Debug.WriteLine($" 통계 정보 CSV 저장: {statisticsCsvPath}");
Debug.WriteLine("=== Note 추출 및 CSV 내보내기 테스트 완료 ===");
Debug.WriteLine($"모든 결과 파일이 저장되었습니다: {outputDirectory}");
}
catch (Exception ex)
{
Debug.WriteLine($"❌ 테스트 중 오류 발생: {ex.Message}");
Debug.WriteLine($"스택 트레이스: {ex.StackTrace}");
throw;
}
finally
{
// Teigha 서비스 정리
try
{
TeighaServicesManager.Instance.ForceDisposeServices();
Debug.WriteLine("Teigha 서비스 정리 완료");
}
catch (Exception ex)
{
Debug.WriteLine($"Teigha 서비스 정리 중 오류: {ex.Message}");
}
}
}
/// <summary>
/// 추출된 Note 데이터를 분석하여 로그로 출력
/// </summary>
private static void AnalyzeNoteData(List<NoteEntityInfo> noteEntities)
{
Debug.WriteLine("=== Note 데이터 분석 ===");
var noteHeaders = noteEntities.Where(ne => ne.Type == "Note").ToList();
var noteContents = noteEntities.Where(ne => ne.Type == "NoteContent").ToList();
var notesWithTables = noteEntities.Where(ne => ne.Type == "Note" && !string.IsNullOrEmpty(ne.TableCsv)).ToList();
Debug.WriteLine($"전체 Note 헤더 수: {noteHeaders.Count}");
Debug.WriteLine($"전체 Note 콘텐츠 수: {noteContents.Count}");
Debug.WriteLine($"테이블이 있는 Note 수: {notesWithTables.Count}");
// 각 Note 분석
foreach (var note in noteHeaders)
{
Debug.WriteLine($"");
Debug.WriteLine($"Note: '{note.Text}' at ({note.X:F1}, {note.Y:F1})");
if (!string.IsNullOrEmpty(note.TableCsv))
{
var tableLines = note.TableCsv.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
Debug.WriteLine($" 테이블 데이터: {tableLines.Length}행");
// 테이블 내용 일부 출력
for (int i = 0; i < Math.Min(3, tableLines.Length); i++)
{
var line = tableLines[i];
if (line.Length > 50) line = line.Substring(0, 50) + "...";
Debug.WriteLine($" 행 {i + 1}: {line}");
}
if (tableLines.Length > 3)
{
Debug.WriteLine($" ... 및 {tableLines.Length - 3}개 행 더");
}
}
// 이 Note와 연관된 NoteContent들
var relatedContents = noteContents.Where(nc =>
Math.Abs(nc.Y - note.Y) < 50 && // Y 좌표가 비슷한 범위 (Note 아래)
nc.Y < note.Y) // Note보다 아래쪽
.OrderBy(nc => nc.SortOrder)
.ToList();
if (relatedContents.Count > 0)
{
Debug.WriteLine($" 관련 콘텐츠: {relatedContents.Count}개");
foreach (var content in relatedContents.Take(3))
{
Debug.WriteLine($" '{content.Text}' at ({content.X:F1}, {content.Y:F1})");
}
if (relatedContents.Count > 3)
{
Debug.WriteLine($" ... 및 {relatedContents.Count - 3}개 더");
}
}
}
// 레이어별 분포
Debug.WriteLine("");
Debug.WriteLine("레이어별 분포:");
var layerGroups = noteEntities.GroupBy(ne => ne.Layer).OrderByDescending(g => g.Count());
foreach (var layerGroup in layerGroups)
{
Debug.WriteLine($" {layerGroup.Key}: {layerGroup.Count()}개");
}
Debug.WriteLine("=== Note 데이터 분석 완료 ===");
}
}
}

25
Models/SettingsManager.cs Normal file
View File

@@ -0,0 +1,25 @@
using Newtonsoft.Json;
using System.IO;
namespace DwgExtractorManual.Models
{
public static class SettingsManager
{
private static readonly string SettingsFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json");
public static void SaveSettings(AppSettings settings)
{
string json = JsonConvert.SerializeObject(settings, Formatting.Indented);
File.WriteAllText(SettingsFilePath, json);
}
public static AppSettings? LoadSettings()
{
if (!File.Exists(SettingsFilePath))
{ return null; }
string json = File.ReadAllText(SettingsFilePath);
return JsonConvert.DeserializeObject<AppSettings>(json);
}
}
}

View File

@@ -15,15 +15,22 @@ namespace DwgExtractorManual.Models
/// </summary> /// </summary>
internal sealed class SqlDatas : IDisposable internal sealed class SqlDatas : IDisposable
{ {
Services appServices; // ODA 제품 활성화용 Services? appServices; // ODA 제품 활성화용 (managed by singleton)
readonly string connectionString = "Host=localhost;Database=postgres;Username=postgres;Password=Qwer1234"; readonly string connectionString = "Host=localhost;Database=postgres;Username=postgres;Password=Qwer1234";
void ActivateAndInitializeODA() void InitializeTeighaServices()
{ {
var userInfo = "c2FtYW4gZW5naW5lZXJpbmc="; try
var userSignature = "F0kuQTmtVpHtvl/TgaFVGE92/YqGmYR9SLoXckEjnOk8NoAQh7Sg6GQruVC04JqD4C/IipxJYqpqvMfMc2auRMG+cAJCKqKUE2djIMdkUdb+C5IVx2c97fcK5ba3n8DDvB54Upokajl+6j12yD8h8MKGOR3Z3zysObeXD62bFpQgp00GCYTqlxEZtTIRjHIPAfJfix8Y0jtXWWYyVJ3LYOu86as5xtx+hY1aakpYIJiQk/6pGd84qSn/9K1w8nxR7UrFzieDeQ/xM58BHSD4u/ZxVJwvv6Uy10tsdBFBTvfJMAFp05Y7yeyeCNr100tA3iOfmWoXAVRHfxnkPfiYR54aK04QI+R6OGkI+yd1oR5BtmN6BdDt3z8KYK5EpFGJGiJIGoUy5PvkYdJ2VV6xe9JWBiIJuI/tDn1Y+uyTQFA9qaDHnOURriXsRGfy8reDPf1eejybSJxWKkpilG6RXhq3xHlCkjZzh1Q45S+xYXNGatcWMm9nkn20M8Ke5JEVaI9w/p2GE36CHRtRQbt8kfPmsbWNXJCFr4svHW2MPbCKWoyn5XEyMWBnuAKi74zvczB13DKjf29SqSIgF5k/hwy2QrgvnaKzY1k8bw8w2/k0vJXcS3GKOB/ZYDle1tf/lkAD1HtnF9zE18TiXhVnqwAVjwg4ui1RPLn/LMs6b5Y="; {
Services.odActivate(userInfo, userSignature); Debug.WriteLine("[SqlDatas] TeighaServicesManager를 통한 Services 획득 중...");
appServices = new Services(); appServices = TeighaServicesManager.Instance.AcquireServices();
Debug.WriteLine($"[SqlDatas] Services 획득 성공. Reference Count: {TeighaServicesManager.Instance.ReferenceCount}");
}
catch (Teigha.Runtime.Exception ex)
{
Debug.WriteLine($"[SqlDatas] Teigha Services 초기화 실패: {ex.Message}");
throw;
}
} }
void CreateTables() void CreateTables()
@@ -78,7 +85,7 @@ namespace DwgExtractorManual.Models
public SqlDatas() public SqlDatas()
{ {
ActivateAndInitializeODA(); InitializeTeighaServices();
CreateTables(); CreateTables();
} }
@@ -136,8 +143,8 @@ namespace DwgExtractorManual.Models
cmd.Parameters.AddWithValue("Type", "DBText"); cmd.Parameters.AddWithValue("Type", "DBText");
cmd.Parameters.AddWithValue("Layer", layerName); cmd.Parameters.AddWithValue("Layer", layerName);
cmd.Parameters.AddWithValue("Text", dbText.TextString ?? ""); cmd.Parameters.AddWithValue("Text", dbText.TextString ?? "");
cmd.Parameters.AddWithValue("Path", database.Filename); cmd.Parameters.AddWithValue("Path", database.Filename ?? "");
cmd.Parameters.AddWithValue("FileName", Path.GetFileName(database.Filename)); cmd.Parameters.AddWithValue("FileName", string.IsNullOrEmpty(database.Filename) ? "" : Path.GetFileName(database.Filename));
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
@@ -155,8 +162,8 @@ namespace DwgExtractorManual.Models
cmd.Parameters.AddWithValue("Type", "MText"); cmd.Parameters.AddWithValue("Type", "MText");
cmd.Parameters.AddWithValue("Layer", layerName); cmd.Parameters.AddWithValue("Layer", layerName);
cmd.Parameters.AddWithValue("Text", mText.Contents ?? ""); cmd.Parameters.AddWithValue("Text", mText.Contents ?? "");
cmd.Parameters.AddWithValue("Path", database.Filename); cmd.Parameters.AddWithValue("Path", database.Filename ?? "");
cmd.Parameters.AddWithValue("FileName", Path.GetFileName(database.Filename)); cmd.Parameters.AddWithValue("FileName", string.IsNullOrEmpty(database.Filename) ? "" : Path.GetFileName(database.Filename));
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
@@ -224,8 +231,8 @@ namespace DwgExtractorManual.Models
else else
cmd.Parameters.AddWithValue("Value", tString); cmd.Parameters.AddWithValue("Value", tString);
cmd.Parameters.AddWithValue("Path", database.Filename); cmd.Parameters.AddWithValue("Path", database.Filename ?? "");
cmd.Parameters.AddWithValue("FileName", Path.GetFileName(database.Filename)); cmd.Parameters.AddWithValue("FileName", string.IsNullOrEmpty(database.Filename) ? "" : Path.GetFileName(database.Filename));
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
@@ -301,8 +308,20 @@ namespace DwgExtractorManual.Models
{ {
if (appServices != null) if (appServices != null)
{ {
appServices.Dispose(); try
appServices = null; {
Debug.WriteLine("[SqlDatas] Teigha Services 해제 중...");
TeighaServicesManager.Instance.ReleaseServices();
Debug.WriteLine($"[SqlDatas] Teigha Services 해제 완료. Remaining ref count: {TeighaServicesManager.Instance.ReferenceCount}");
}
catch (Teigha.Runtime.Exception ex)
{
Debug.WriteLine($"[SqlDatas] Teigha Services 해제 중 오류 (무시됨): {ex.Message}");
}
finally
{
appServices = null;
}
} }
} }
} }

View File

@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using Teigha.Geometry;
namespace DwgExtractorManual.Models
{
/// <summary>
/// 테이블 셀 시각화를 위한 데이터 클래스
/// </summary>
public class TableCellVisualizationData
{
public string FileName { get; set; } = "";
public string NoteText { get; set; } = "";
public List<CellBounds> Cells { get; set; } = new List<CellBounds>();
public List<SegmentInfo> TableSegments { get; set; } = new List<SegmentInfo>();
public List<TextInfo> TextEntities { get; set; } = new List<TextInfo>();
public List<IntersectionInfo> IntersectionPoints { get; set; } = new List<IntersectionInfo>(); // 교차점 정보 추가
public List<DiagonalLine> DiagonalLines { get; set; } = new List<DiagonalLine>(); // 셀 대각선 정보 추가
public List<CellBoundaryInfo> CellBoundaries { get; set; } = new List<CellBoundaryInfo>(); // 정확한 셀 경계 정보 추가
public (double minX, double minY, double maxX, double maxY) NoteBounds { get; set; }
}
/// <summary>
/// 셀 경계 정보
/// </summary>
public class CellBounds
{
public double MinX { get; set; }
public double MinY { get; set; }
public double MaxX { get; set; }
public double MaxY { get; set; }
public int Row { get; set; }
public int Column { get; set; }
public string Text { get; set; } = "";
public bool IsValid { get; set; } = true;
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
public double CenterX => (MinX + MaxX) / 2;
public double CenterY => (MinY + MaxY) / 2;
}
/// <summary>
/// 선분 정보
/// </summary>
public class SegmentInfo
{
public double StartX { get; set; }
public double StartY { get; set; }
public double EndX { get; set; }
public double EndY { get; set; }
public bool IsHorizontal { get; set; }
public string Color { get; set; } = "Black";
}
/// <summary>
/// 텍스트 정보
/// </summary>
public class TextInfo
{
public double X { get; set; }
public double Y { get; set; }
public string Text { get; set; } = "";
public bool IsInTable { get; set; }
public string Color { get; set; } = "Blue";
}
/// <summary>
/// 교차점 정보
/// </summary>
public class IntersectionInfo
{
public double X { get; set; }
public double Y { get; set; }
public int DirectionBits { get; set; } // 비트 플래그 숫자
public int Row { get; set; } // Row 번호
public int Column { get; set; } // Column 번호
public bool IsTopLeft { get; set; } // topLeft 후보인지
public bool IsBottomRight { get; set; } // bottomRight 후보인지
public string Color { get; set; } = "Red";
}
/// <summary>
/// 대각선 정보 (셀 디버깅용)
/// </summary>
public class DiagonalLine
{
public double StartX { get; set; }
public double StartY { get; set; }
public double EndX { get; set; }
public double EndY { get; set; }
public string Color { get; set; } = "Green";
public string Label { get; set; } = ""; // 디버깅 라벨
}
/// <summary>
/// 정확한 셀 경계 정보 (4개 모서리 좌표)
/// </summary>
public class CellBoundaryInfo
{
public double TopLeftX { get; set; }
public double TopLeftY { get; set; }
public double TopRightX { get; set; }
public double TopRightY { get; set; }
public double BottomLeftX { get; set; }
public double BottomLeftY { get; set; }
public double BottomRightX { get; set; }
public double BottomRightY { get; set; }
public string Label { get; set; } = "";
public double Width { get; set; }
public double Height { get; set; }
public string Color { get; set; } = "DarkBlue";
public string CellText { get; set; } = ""; // 셀 내 텍스트 내용
}
}

View File

@@ -0,0 +1,192 @@
using System;
using System.Diagnostics;
using Teigha.Runtime;
namespace DwgExtractorManual.Models
{
/// <summary>
/// Singleton class to manage Teigha Services lifecycle and prevent disposal conflicts
/// </summary>
public sealed class TeighaServicesManager
{
private static readonly object _lock = new object();
private static TeighaServicesManager? _instance = null;
private static Services? _services = null;
private static int _referenceCount = 0;
private static bool _isActivated = false;
private TeighaServicesManager()
{
// Private constructor for singleton
}
public static TeighaServicesManager Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = new TeighaServicesManager();
}
}
}
return _instance;
}
}
/// <summary>
/// Acquires Teigha Services (creates if needed, increments reference count)
/// </summary>
/// <returns>The Services instance</returns>
public Services AcquireServices()
{
lock (_lock)
{
try
{
Debug.WriteLine($"[TeighaManager] AcquireServices - Current ref count: {_referenceCount}");
if (!_isActivated)
{
Debug.WriteLine("[TeighaManager] Activating ODA for first time...");
ActivateODA();
_isActivated = true;
}
if (_services == null)
{
Debug.WriteLine("[TeighaManager] Creating new Services instance...");
_services = new Services();
Debug.WriteLine("[TeighaManager] Services instance created successfully");
}
_referenceCount++;
Debug.WriteLine($"[TeighaManager] Services acquired - New ref count: {_referenceCount}");
return _services;
}
catch (Teigha.Runtime.Exception ex)
{
Debug.WriteLine($"[TeighaManager] Error acquiring services: {ex.Message}");
throw;
}
}
}
/// <summary>
/// Releases Teigha Services (decrements reference count, disposes when count reaches 0)
/// </summary>
public void ReleaseServices()
{
lock (_lock)
{
try
{
Debug.WriteLine($"[TeighaManager] ReleaseServices - Current ref count: {_referenceCount}");
if (_referenceCount > 0)
{
_referenceCount--;
Debug.WriteLine($"[TeighaManager] Services released - New ref count: {_referenceCount}");
}
// Don't dispose Services until app shutdown to prevent conflicts
// Just track reference count for debugging
if (_referenceCount == 0)
{
Debug.WriteLine("[TeighaManager] All references released (Services kept alive for app lifetime)");
}
}
catch (Teigha.Runtime.Exception ex)
{
Debug.WriteLine($"[TeighaManager] Error releasing services: {ex.Message}");
// Don't throw on release to prevent cascade failures
}
}
}
/// <summary>
/// Force dispose Services (only call on application shutdown)
/// </summary>
public void ForceDisposeServices()
{
lock (_lock)
{
try
{
Debug.WriteLine("[TeighaManager] Force disposing Services...");
if (_services != null)
{
_services.Dispose();
Debug.WriteLine("[TeighaManager] Services disposed successfully");
}
_services = null;
_referenceCount = 0;
_isActivated = false;
Debug.WriteLine("[TeighaManager] Force dispose completed");
}
catch (Teigha.Runtime.Exception ex)
{
Debug.WriteLine($"[TeighaManager] Error during force dispose: {ex.Message}");
// Reset state even if disposal fails
_services = null;
_referenceCount = 0;
_isActivated = false;
}
}
}
/// <summary>
/// Get current reference count (for debugging)
/// </summary>
public int ReferenceCount
{
get
{
lock (_lock)
{
return _referenceCount;
}
}
}
/// <summary>
/// Check if Services is active and valid
/// </summary>
public bool IsServicesActive
{
get
{
lock (_lock)
{
return _services != null && _isActivated;
}
}
}
private void ActivateODA()
{
try
{
Debug.WriteLine("[TeighaManager] Activating ODA...");
var userInfo = "c2FtYW4gZW5naW5lZXJpbmc=";
var userSignature = "F0kuQTmtVpHtvl/TgaFVGE92/YqGmYR9SLoXckEjnOk8NoAQh7Sg6GQruVC04JqD4C/IipxJYqpqvMfMc2auRMG+cAJCKqKUE2djIMdkUdb+C5IVx2c97fcK5ba3n8DDvB54Upokajl+6j12yD8h8MKGOR3Z3zysObeXD62bFpQgp00GCYTqlxEZtTIRjHIPAfJfix8Y0jtXWWYyVJ3LYOu86as5xtx+hY1aakpYIJiQk/6pGd84qSn/9K1w8nxR7UrFzieDeQ/xM58BHSD4u/ZxVJwvv6Uy10tsdBFBTvfJMAFp05Y7yeyeCNr100tA3iOfmWoXAVRHfxnkPfiYR54aK04QI+R6OGkI+yd1oR5BtmN6BdDt3z8KYK5EpFGJGiJIGoUy5PvkYdJ2VV6xe9JWBiIJuI/tDn1Y+uyTQFA9qaDHnOURriXsRGfy8reDPf1eejybSJxWKkpilG6RXhq3xHlCkjZzh1Q45S+xYXNGatcWMm9nkn20M8Ke5JEVaI9w/p2GE36CHRtRQbt8kfPmsbWNXJCFr4svHW2MPbCKWoyn5XEyMWBnuAKi74zvczB13DKjf29SqSIgF5k/hwy2QrgvnaKzY1k8bw8w2/k0vJXcS3GKOB/ZYDle1tf/lkAD1HtnF9zE18TiXhVnqwAVjwg4ui1RPLn/LMs6b5Y=";
Services.odActivate(userInfo, userSignature);
Debug.WriteLine("[TeighaManager] ODA activation successful");
}
catch (Teigha.Runtime.Exception ex)
{
Debug.WriteLine($"[TeighaManager] ODA activation failed: {ex.Message}");
throw;
}
}
}
}

48
NoteDetectionRefactor.md Normal file
View File

@@ -0,0 +1,48 @@
Project: Refactor the NOTE Content Box Detection Algorithm
1. High-Level Goal:
The primary objective is to replace the current, fragile "horizontal search line" algorithm in
Models/DwgDataExtractor.cs with a more robust and accurate method that reliably finds the content box for
any "NOTE" text, regardless of its position or the composition of its bounding box.
2. Core Strategy: "Vertical Ray-Casting"
We will implement a new algorithm that emulates how a human would visually locate the content. This
involves a "gradual downward scan" (or vertical ray-cast) from the NOTE's position.
3. Implementation Plan (TODO List):
* Step 1: Unify All Geometry into Line Segments
* Create a single helper method, GetAllLineSegments, that processes all Line and Polyline entities from
the drawing.
* This method will decompose every Polyline into its constituent Line segments.
* It will return a single, unified List<Line> containing every potential boundary segment in the
drawing.
* Crucially: This method must ensure all temporary Line objects created during the process are properly
disposed of to prevent memory leaks.
* Step 2: Implement the Ray-Casting Logic in `FindNoteBox`
* The FindNoteBox method will be completely rewritten.
* It will first call GetAllLineSegments to get the unified geometry list.
* It will then perform the vertical ray-cast starting from the NOTE's X-coordinate and scanning
downwards.
* It will find all horizontal lines that intersect the ray and sort them by their Y-coordinate (from
top to bottom).
* It will identify the second line in this sorted list as the top edge of the content box (the first is
assumed to be the NOTE's own bounding box).
* Step 3: Implement Smart Box Tracing
* Create a new helper method, TraceBoxFromTopLine.
* This method will take the identified top line segment as its starting point.
* It will intelligently trace the remaining three sides of the rectangle by searching the unified list
of line segments for the nearest connecting corners.
* This tracing logic must be tolerant of small gaps between the endpoints of the lines forming the box.
* Step 4: Final Cleanup
* Once the new ray-casting algorithm is fully implemented and validated, all of the old, obsolete
methods related to the previous search-line approach must be deleted to keep the code clean. This
includes:
* FindIntersectingLineSegments
* TraceRectangleFromLineSegments
* FindNextConnectedLineSegment
* DoesLineIntersectPolyline
* GetPolylineBounds

View File

@@ -0,0 +1,109 @@
<Window x:Class="DwgExtractorManual.Views.TableCellVisualizationWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:DwgExtractorManual.Controls"
Title="테이블 셀 시각화" Height="800" Width="1200"
WindowStartupLocation="CenterOwner"
MinHeight="600" MinWidth="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 헤더 -->
<Border Grid.Row="0" Background="#34495E" Padding="15">
<StackPanel>
<TextBlock Text="테이블 셀 시각화"
FontSize="20" FontWeight="Bold"
Foreground="White" HorizontalAlignment="Center"/>
<TextBlock Text="추출된 테이블 셀들의 경계를 시각적으로 확인할 수 있습니다"
FontSize="12" Foreground="LightGray"
HorizontalAlignment="Center" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- 메인 영역 -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 좌측 패널: 파일 목록 및 설정 -->
<Border Grid.Column="0" Background="#ECF0F1" BorderBrush="#BDC3C7" BorderThickness="0,0,1,0">
<StackPanel Margin="10">
<TextBlock Text="파일 목록" FontWeight="Bold" FontSize="14" Margin="0,0,0,10"/>
<ListBox x:Name="lstFiles" Height="200"
SelectionChanged="LstFiles_SelectionChanged"
Background="White" BorderBrush="#BDC3C7">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding FileName}" FontWeight="Bold" FontSize="12"/>
<TextBlock Text="{Binding NoteText}" FontSize="10" Foreground="Gray" TextTrimming="CharacterEllipsis"/>
<TextBlock FontSize="10" Foreground="DarkBlue">
<Run Text="셀: "/>
<Run Text="{Binding Path=Cells.Count, Mode=OneWay}"/>
<Run Text="개"/>
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Separator Margin="0,10"/>
<TextBlock Text="표시 옵션" FontWeight="Bold" FontSize="14" Margin="0,0,0,10"/>
<CheckBox x:Name="chkShowCells" Content="셀 경계 표시 (기존)" IsChecked="False"
Checked="RefreshVisualization" Unchecked="RefreshVisualization"/>
<CheckBox x:Name="chkShowCellBoundaries" Content="정확한 셀 경계 표시" IsChecked="True" Margin="0,5,0,0"
Checked="RefreshVisualization" Unchecked="RefreshVisualization"/>
<CheckBox x:Name="chkShowSegments" Content="선분 표시" IsChecked="True" Margin="0,5,0,0"
Checked="RefreshVisualization" Unchecked="RefreshVisualization"/>
<CheckBox x:Name="chkShowTexts" Content="텍스트 표시" IsChecked="True" Margin="0,5,0,0"
Checked="RefreshVisualization" Unchecked="RefreshVisualization"/>
<CheckBox x:Name="chkShowIntersections" Content="교차점 표시" IsChecked="True" Margin="0,5,0,0"
Checked="RefreshVisualization" Unchecked="RefreshVisualization"/>
<CheckBox x:Name="chkShowDiagonals" Content="셀 대각선 표시" IsChecked="True" Margin="0,5,0,0"
Checked="RefreshVisualization" Unchecked="RefreshVisualization"/>
<CheckBox x:Name="chkShowNoteBounds" Content="Note 경계 표시" IsChecked="False" Margin="0,5,0,0"
Checked="RefreshVisualization" Unchecked="RefreshVisualization"/>
<Separator Margin="0,10"/>
<TextBlock Text="확대/축소" FontWeight="Bold" FontSize="14" Margin="0,0,0,10"/>
<Button x:Name="btnZoomFit" Content="초기화 (마우스 우클릭)" Click="BtnZoomFit_Click" Margin="0,2"/>
<Separator Margin="0,10"/>
<TextBlock x:Name="txtInfo" Text="파일을 선택하세요" FontSize="11"
Foreground="DarkGray" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- 우측 패널: 시각화 영역 -->
<Border Grid.Column="1" Background="White">
<controls:ZoomBorder x:Name="svViewer" ClipToBounds="True">
<Canvas x:Name="cnvVisualization" Background="White"
Width="800" Height="600"
MouseMove="CnvVisualization_MouseMove"/>
</controls:ZoomBorder>
</Border>
</Grid>
<!-- 상태바 -->
<StatusBar Grid.Row="2" Background="#95A5A6">
<StatusBarItem>
<TextBlock x:Name="txtStatus" Text="준비됨"/>
</StatusBarItem>
<StatusBarItem HorizontalAlignment="Right">
<TextBlock x:Name="txtMousePos" Text="마우스: (0, 0)"/>
</StatusBarItem>
</StatusBar>
</Grid>
</Window>

View File

@@ -0,0 +1,493 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
using DwgExtractorManual.Models;
using Brushes = System.Windows.Media.Brushes;
using MouseEventArgs = System.Windows.Input.MouseEventArgs;
using Point = System.Windows.Point;
using Rectangle = System.Windows.Shapes.Rectangle;
namespace DwgExtractorManual.Views
{
public partial class TableCellVisualizationWindow : Window
{
private List<TableCellVisualizationData> _visualizationData;
private TableCellVisualizationData? _currentData;
private double _scale = 1.0;
private const double MARGIN = 50;
public TableCellVisualizationWindow(List<TableCellVisualizationData> visualizationData)
{
InitializeComponent();
_visualizationData = visualizationData;
LoadFileList();
}
private void LoadFileList()
{
lstFiles.ItemsSource = _visualizationData;
if (_visualizationData.Count > 0)
{
lstFiles.SelectedIndex = 0;
}
}
private void LstFiles_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (lstFiles.SelectedItem is TableCellVisualizationData selectedData)
{
_currentData = selectedData;
RefreshVisualization();
UpdateInfo();
}
}
private void UpdateInfo()
{
if (_currentData == null)
{
txtInfo.Text = "파일을 선택하세요";
txtStatus.Text = "준비됨";
return;
}
txtInfo.Text = $"파일: {_currentData.FileName}\n" +
$"Note: {_currentData.NoteText}\n" +
$"셀 수: {_currentData.Cells.Count}\n" +
$"선분 수: {_currentData.TableSegments.Count}\n" +
$"텍스트 수: {_currentData.TextEntities.Count}\n" +
$"교차점 수: {_currentData.IntersectionPoints.Count}\n" +
$"대각선 수: {_currentData.DiagonalLines.Count}";
txtStatus.Text = $"{_currentData.FileName} - 셀 {_currentData.Cells.Count}개";
}
private void RefreshVisualization()
{
if (_currentData == null) return;
cnvVisualization.Children.Clear();
// 좌표계 변환 계산
var bounds = CalculateBounds();
if (bounds == null) return;
var (minX, minY, maxX, maxY) = bounds.Value;
var dataWidth = maxX - minX;
var dataHeight = maxY - minY;
// 캔버스 크기 설정 (여백 포함)
var canvasWidth = cnvVisualization.Width;
var canvasHeight = cnvVisualization.Height;
// 스케일 계산 (데이터가 캔버스에 맞도록)
var scaleX = (canvasWidth - 2 * MARGIN) / dataWidth;
var scaleY = (canvasHeight - 2 * MARGIN) / dataHeight;
_scale = Math.Min(scaleX, scaleY) * 0.9; // 여유분 10%
// Note 경계 표시
if (chkShowNoteBounds.IsChecked == true)
{
DrawNoteBounds(minX, minY, maxX, maxY);
}
// 선분 표시
if (chkShowSegments.IsChecked == true)
{
DrawSegments();
}
// 셀 경계 표시 (기존)
if (chkShowCells.IsChecked == true)
{
DrawCells();
}
// 정확한 셀 경계 표시 (새로운)
if (chkShowCellBoundaries.IsChecked == true)
{
DrawCellBoundaries();
}
// 텍스트 표시
if (chkShowTexts.IsChecked == true)
{
DrawTexts();
}
// 교차점 표시
if (chkShowIntersections.IsChecked == true)
{
DrawIntersections();
}
// 대각선 표시 (셀 디버깅용)
if (chkShowDiagonals.IsChecked == true)
{
DrawDiagonals();
}
}
private (double minX, double minY, double maxX, double maxY)? CalculateBounds()
{
if (_currentData == null || _currentData.Cells.Count == 0) return null;
var minX = _currentData.Cells.Min(c => c.MinX);
var minY = _currentData.Cells.Min(c => c.MinY);
var maxX = _currentData.Cells.Max(c => c.MaxX);
var maxY = _currentData.Cells.Max(c => c.MaxY);
// 선분도 고려
if (_currentData.TableSegments.Count > 0)
{
minX = Math.Min(minX, _currentData.TableSegments.Min(s => Math.Min(s.StartX, s.EndX)));
minY = Math.Min(minY, _currentData.TableSegments.Min(s => Math.Min(s.StartY, s.EndY)));
maxX = Math.Max(maxX, _currentData.TableSegments.Max(s => Math.Max(s.StartX, s.EndX)));
maxY = Math.Max(maxY, _currentData.TableSegments.Max(s => Math.Max(s.StartY, s.EndY)));
}
// 교차점도 고려
if (_currentData.IntersectionPoints.Count > 0)
{
minX = Math.Min(minX, _currentData.IntersectionPoints.Min(i => i.X));
minY = Math.Min(minY, _currentData.IntersectionPoints.Min(i => i.Y));
maxX = Math.Max(maxX, _currentData.IntersectionPoints.Max(i => i.X));
maxY = Math.Max(maxY, _currentData.IntersectionPoints.Max(i => i.Y));
}
// 대각선도 고려
if (_currentData.DiagonalLines.Count > 0)
{
minX = Math.Min(minX, _currentData.DiagonalLines.Min(d => Math.Min(d.StartX, d.EndX)));
minY = Math.Min(minY, _currentData.DiagonalLines.Min(d => Math.Min(d.StartY, d.EndY)));
maxX = Math.Max(maxX, _currentData.DiagonalLines.Max(d => Math.Max(d.StartX, d.EndX)));
maxY = Math.Max(maxY, _currentData.DiagonalLines.Max(d => Math.Max(d.StartY, d.EndY)));
}
// 정확한 셀 경계도 고려
if (_currentData.CellBoundaries != null && _currentData.CellBoundaries.Count > 0)
{
var allCellX = _currentData.CellBoundaries.SelectMany(cb => new[] { cb.TopLeftX, cb.TopRightX, cb.BottomLeftX, cb.BottomRightX });
var allCellY = _currentData.CellBoundaries.SelectMany(cb => new[] { cb.TopLeftY, cb.TopRightY, cb.BottomLeftY, cb.BottomRightY });
minX = Math.Min(minX, allCellX.Min());
minY = Math.Min(minY, allCellY.Min());
maxX = Math.Max(maxX, allCellX.Max());
maxY = Math.Max(maxY, allCellY.Max());
}
return (minX, minY, maxX, maxY);
}
private Point TransformPoint(double x, double y)
{
var bounds = CalculateBounds();
if (bounds == null) return new Point(0, 0);
var (minX, minY, maxX, maxY) = bounds.Value;
// CAD 좌표계 -> WPF 좌표계 변환 (Y축 뒤집기)
var transformedX = (x - minX) * _scale + MARGIN;
var transformedY = cnvVisualization.Height - ((y - minY) * _scale + MARGIN);
return new Point(transformedX, transformedY);
}
private void DrawNoteBounds(double minX, double minY, double maxX, double maxY)
{
var topLeft = TransformPoint(minX, maxY);
var bottomRight = TransformPoint(maxX, minY);
var rect = new Rectangle
{
Width = bottomRight.X - topLeft.X,
Height = bottomRight.Y - topLeft.Y,
Stroke = Brushes.Red,
StrokeThickness = 2,
StrokeDashArray = new DoubleCollection { 5, 5 },
Fill = null
};
Canvas.SetLeft(rect, topLeft.X);
Canvas.SetTop(rect, topLeft.Y);
cnvVisualization.Children.Add(rect);
}
private void DrawSegments()
{
if (_currentData == null) return;
foreach (var segment in _currentData.TableSegments)
{
var startPoint = TransformPoint(segment.StartX, segment.StartY);
var endPoint = TransformPoint(segment.EndX, segment.EndY);
var line = new Line
{
X1 = startPoint.X,
Y1 = startPoint.Y,
X2 = endPoint.X,
Y2 = endPoint.Y,
Stroke = segment.IsHorizontal ? Brushes.Blue : Brushes.Green,
StrokeThickness = 1
};
cnvVisualization.Children.Add(line);
}
}
private void DrawCells()
{
if (_currentData == null) return;
var colors = new[] { Brushes.Red, Brushes.Blue, Brushes.Green, Brushes.Purple, Brushes.Orange };
for (int i = 0; i < _currentData.Cells.Count; i++)
{
var cell = _currentData.Cells[i];
var topLeft = TransformPoint(cell.MinX, cell.MaxY);
var bottomRight = TransformPoint(cell.MaxX, cell.MinY);
var rect = new Rectangle
{
Width = bottomRight.X - topLeft.X,
Height = bottomRight.Y - topLeft.Y,
Stroke = colors[i % colors.Length],
StrokeThickness = 2,
Fill = null
};
Canvas.SetLeft(rect, topLeft.X);
Canvas.SetTop(rect, topLeft.Y);
cnvVisualization.Children.Add(rect);
// 셀 번호 표시
var label = new TextBlock
{
Text = $"R{cell.Row}C{cell.Column}", // 이미 1-based 인덱싱 적용됨
FontSize = 8, // 폰트 크기 조정
Foreground = colors[i % colors.Length],
FontWeight = FontWeights.Bold
};
Canvas.SetLeft(label, topLeft.X + 2); // 좌상단에 위치 + 약간의 패딩
Canvas.SetTop(label, topLeft.Y + 2); // 좌상단에 위치 + 약간의 패딩
cnvVisualization.Children.Add(label);
// 셀 텍스트 표시
if (!string.IsNullOrEmpty(cell.Text))
{
var textLabel = new TextBlock
{
Text = cell.Text,
FontSize = 8,
Foreground = Brushes.Black,
Background = Brushes.LightYellow
};
// 셀 텍스트는 셀 중앙에 표시
var centerPoint = TransformPoint(cell.CenterX, cell.CenterY);
Canvas.SetLeft(textLabel, centerPoint.X - (textLabel.ActualWidth / 2)); // 텍스트 중앙 정렬
Canvas.SetTop(textLabel, centerPoint.Y - (textLabel.ActualHeight / 2)); // 텍스트 중앙 정렬
cnvVisualization.Children.Add(textLabel);
}
}
}
private void DrawCellBoundaries()
{
if (_currentData == null || _currentData.CellBoundaries == null) return;
for (int i = 0; i < _currentData.CellBoundaries.Count; i++)
{
var cellBoundary = _currentData.CellBoundaries[i];
// 4개 모서리 좌표 변환
var topLeft = TransformPoint(cellBoundary.TopLeftX, cellBoundary.TopLeftY);
var topRight = TransformPoint(cellBoundary.TopRightX, cellBoundary.TopRightY);
var bottomLeft = TransformPoint(cellBoundary.BottomLeftX, cellBoundary.BottomLeftY);
var bottomRight = TransformPoint(cellBoundary.BottomRightX, cellBoundary.BottomRightY);
// 셀 경계를 Path로 그리기 (정확한 4개 모서리 연결)
var pathGeometry = new PathGeometry();
var pathFigure = new PathFigure();
pathFigure.StartPoint = topLeft;
pathFigure.Segments.Add(new LineSegment(topRight, true));
pathFigure.Segments.Add(new LineSegment(bottomRight, true));
pathFigure.Segments.Add(new LineSegment(bottomLeft, true));
pathFigure.IsClosed = true;
pathGeometry.Figures.Add(pathFigure);
var path = new Path
{
Data = pathGeometry,
Stroke = Brushes.DarkBlue,
StrokeThickness = 3,
Fill = null
};
cnvVisualization.Children.Add(path);
// 라벨 표시 (셀 중앙)
if (!string.IsNullOrEmpty(cellBoundary.Label))
{
var centerX = (topLeft.X + bottomRight.X) / 2;
var centerY = (topLeft.Y + bottomRight.Y) / 2;
var label = new TextBlock
{
Text = cellBoundary.Label,
FontSize = 10,
Foreground = Brushes.DarkBlue,
FontWeight = FontWeights.Bold,
Background = Brushes.LightCyan
};
Canvas.SetLeft(label, centerX - 15); // 대략적인 중앙 정렬
Canvas.SetTop(label, centerY - 6);
cnvVisualization.Children.Add(label);
}
}
}
private void DrawTexts()
{
if (_currentData == null) return;
foreach (var text in _currentData.TextEntities)
{
var point = TransformPoint(text.X, text.Y);
var textBlock = new TextBlock
{
Text = text.Text,
FontSize = 9,
Foreground = text.IsInTable ? Brushes.DarkBlue : Brushes.Gray
};
Canvas.SetLeft(textBlock, point.X);
Canvas.SetTop(textBlock, point.Y);
cnvVisualization.Children.Add(textBlock);
// 텍스트 위치 점 표시
var dot = new Ellipse
{
Width = 3,
Height = 3,
Fill = text.IsInTable ? Brushes.Blue : Brushes.Gray
};
Canvas.SetLeft(dot, point.X - 1.5);
Canvas.SetTop(dot, point.Y - 1.5);
cnvVisualization.Children.Add(dot);
}
}
private void DrawIntersections()
{
if (_currentData == null) return;
foreach (var intersection in _currentData.IntersectionPoints)
{
var point = TransformPoint(intersection.X, intersection.Y);
// 교차점 원 표시
var circle = new Ellipse
{
Width = 8,
Height = 8,
Fill = GetIntersectionColor(intersection),
Stroke = Brushes.Black,
StrokeThickness = 1
};
Canvas.SetLeft(circle, point.X - 4);
Canvas.SetTop(circle, point.Y - 4);
cnvVisualization.Children.Add(circle);
// 교차점 타입 숫자 표시 (우측에)
var numberLabel = new TextBlock
{
Text = intersection.DirectionBits.ToString(),
FontSize = 12,
FontWeight = FontWeights.Bold,
Foreground = Brushes.Black,
Background = Brushes.Yellow,
Padding = new Thickness(2)
};
Canvas.SetLeft(numberLabel, point.X + 8); // 교차점 우측에 표시
Canvas.SetTop(numberLabel, point.Y - 6); // 약간 위쪽으로 조정
cnvVisualization.Children.Add(numberLabel);
}
}
private void DrawDiagonals()
{
if (_currentData == null) return;
foreach (var diagonal in _currentData.DiagonalLines)
{
var startPoint = TransformPoint(diagonal.StartX, diagonal.StartY);
var endPoint = TransformPoint(diagonal.EndX, diagonal.EndY);
// 대각선 표시
var line = new Line
{
X1 = startPoint.X,
Y1 = startPoint.Y,
X2 = endPoint.X,
Y2 = endPoint.Y,
Stroke = Brushes.Green,
StrokeThickness = 2,
StrokeDashArray = new DoubleCollection { 5, 3 } // 점선으로 표시
};
cnvVisualization.Children.Add(line);
// 대각선 중앙에 라벨 표시
if (!string.IsNullOrEmpty(diagonal.Label))
{
var centerX = (startPoint.X + endPoint.X) / 2;
var centerY = (startPoint.Y + endPoint.Y) / 2;
var label = new TextBlock
{
Text = diagonal.Label,
FontSize = 9,
FontWeight = FontWeights.Bold,
Foreground = Brushes.Green,
Background = Brushes.White
};
Canvas.SetLeft(label, centerX - 20); // 중앙에서 약간 왼쪽으로
Canvas.SetTop(label, centerY - 10); // 중앙에서 약간 위쪽으로
cnvVisualization.Children.Add(label);
}
}
}
private System.Windows.Media.Brush GetIntersectionColor(IntersectionInfo intersection)
{
if (intersection.IsTopLeft && intersection.IsBottomRight)
return Brushes.Purple; // 둘 다 가능
else if (intersection.IsTopLeft)
return Brushes.Green; // topLeft 후보
else if (intersection.IsBottomRight)
return Brushes.Blue; // bottomRight 후보
else
return Brushes.Red; // 기타
}
private void RefreshVisualization(object sender, RoutedEventArgs e)
{
RefreshVisualization();
}
private void BtnZoomFit_Click(object sender, RoutedEventArgs e)
{
svViewer.Reset();
}
private void CnvVisualization_MouseMove(object sender, MouseEventArgs e)
{
var pos = e.GetPosition(cnvVisualization);
txtMousePos.Text = $"마우스: ({pos.X:F0}, {pos.Y:F0})";
}
}
}

67
cleanup_and_setup.bat Normal file
View File

@@ -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

View File

@@ -8,37 +8,37 @@ using System.Text.Json.Serialization;
public class MappingTableData public class MappingTableData
{ {
[JsonPropertyName("mapping_table")] [JsonPropertyName("mapping_table")]
public MappingTable MappingTable { get; set; } public MappingTable MappingTable { get; set; } = default!;
} }
public class MappingTable public class MappingTable
{ {
[JsonPropertyName("ailabel_to_systems")] [JsonPropertyName("ailabel_to_systems")]
public Dictionary<string, SystemFields> AilabelToSystems { get; set; } public Dictionary<string, SystemFields> AilabelToSystems { get; set; } = default!;
[JsonPropertyName("system_mappings")] [JsonPropertyName("system_mappings")]
public SystemMappings SystemMappings { get; set; } public SystemMappings SystemMappings { get; set; } = default!;
} }
public class SystemFields public class SystemFields
{ {
[JsonPropertyName("molit")] [JsonPropertyName("molit")]
public string Molit { get; set; } public string Molit { get; set; } = default!;
[JsonPropertyName("expressway")] [JsonPropertyName("expressway")]
public string Expressway { get; set; } public string Expressway { get; set; } = default!;
[JsonPropertyName("railway")] [JsonPropertyName("railway")]
public string Railway { get; set; } public string Railway { get; set; } = default!;
[JsonPropertyName("docaikey")] [JsonPropertyName("docaikey")]
public string DocAiKey { get; set; } public string DocAiKey { get; set; } = default!;
} }
public class SystemMappings public class SystemMappings
{ {
[JsonPropertyName("expressway_to_transportation")] [JsonPropertyName("expressway_to_transportation")]
public Dictionary<string, string> ExpresswayToTransportation { get; set; } public Dictionary<string, string> ExpresswayToTransportation { get; set; } = default!;
} }
// 필드 매퍼 클래스 // 필드 매퍼 클래스
@@ -56,21 +56,164 @@ public class FieldMapper
/// </summary> /// </summary>
public static FieldMapper LoadFromFile(string jsonFilePath) public static FieldMapper LoadFromFile(string jsonFilePath)
{ {
string jsonContent = File.ReadAllText(jsonFilePath); try
var options = new JsonSerializerOptions
{ {
PropertyNameCaseInsensitive = true, string jsonContent = File.ReadAllText(jsonFilePath, System.Text.Encoding.UTF8);
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping Console.WriteLine($"[DEBUG] 매핑 테이블 JSON 파일 크기: {jsonContent.Length} bytes");
};
var mappingData = JsonSerializer.Deserialize<MappingTableData>(jsonContent, options); // JSON 내용 정리 (주석 제거 등)
return new FieldMapper(mappingData); jsonContent = CleanJsonContent(jsonContent);
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
AllowTrailingCommas = true
};
var mappingData = JsonSerializer.Deserialize<MappingTableData>(jsonContent, options);
Console.WriteLine($"[DEBUG] 매핑 테이블 로드 성공: {mappingData?.MappingTable?.AilabelToSystems?.Count ?? 0}개 항목");
return new FieldMapper(mappingData!);
}
catch (JsonException jsonEx)
{
Console.WriteLine($"❌ 매핑 테이블 JSON 파싱 오류: {jsonEx.Message}");
Console.WriteLine($"❌ 파일: {jsonFilePath}");
if (File.Exists(jsonFilePath))
{
string content = File.ReadAllText(jsonFilePath);
Console.WriteLine($"❌ JSON 내용 미리보기 (첫 500자):");
Console.WriteLine(content.Length > 500 ? content.Substring(0, 500) + "..." : content);
}
throw new Exception($"매핑 테이블 JSON 파일 파싱 실패: {jsonEx.Message}\n파일: {jsonFilePath}");
}
catch (Exception ex)
{
Console.WriteLine($"❌ 매핑 테이블 로드 중 오류: {ex.Message}");
throw;
}
}
/// <summary>
/// JSON 내용을 정리하여 파싱 가능한 상태로 만듭니다.
/// 주석 제거 및 기타 무효한 문자 처리
/// </summary>
/// <param name="jsonContent">원본 JSON 내용</param>
/// <returns>정리된 JSON 내용</returns>
private static string CleanJsonContent(string jsonContent)
{
if (string.IsNullOrEmpty(jsonContent))
return jsonContent;
try
{
// 줄별로 처리하여 주석 제거
var lines = jsonContent.Split('\n');
var cleanedLines = new List<string>();
bool inMultiLineComment = false;
foreach (string line in lines)
{
string processedLine = line;
// 멀티라인 주석 처리 (/* */)
if (inMultiLineComment)
{
int endIndex = processedLine.IndexOf("*/");
if (endIndex >= 0)
{
processedLine = processedLine.Substring(endIndex + 2);
inMultiLineComment = false;
}
else
{
continue; // 전체 라인이 주석
}
}
// 멀티라인 주석 시작 확인
int multiLineStart = processedLine.IndexOf("/*");
if (multiLineStart >= 0)
{
int multiLineEnd = processedLine.IndexOf("*/", multiLineStart + 2);
if (multiLineEnd >= 0)
{
// 같은 라인에서 시작하고 끝나는 주석
processedLine = processedLine.Substring(0, multiLineStart) +
processedLine.Substring(multiLineEnd + 2);
}
else
{
// 멀티라인 주석 시작
processedLine = processedLine.Substring(0, multiLineStart);
inMultiLineComment = true;
}
}
// 싱글라인 주석 제거 (//) - 문자열 내부의 //는 제외
bool inString = false;
bool escaped = false;
int commentIndex = -1;
for (int i = 0; i < processedLine.Length - 1; i++)
{
char current = processedLine[i];
char next = processedLine[i + 1];
if (escaped)
{
escaped = false;
continue;
}
if (current == '\\')
{
escaped = true;
continue;
}
if (current == '"')
{
inString = !inString;
continue;
}
if (!inString && current == '/' && next == '/')
{
commentIndex = i;
break;
}
}
if (commentIndex >= 0)
{
processedLine = processedLine.Substring(0, commentIndex);
}
// 빈 라인이 아니면 추가
if (!string.IsNullOrWhiteSpace(processedLine))
{
cleanedLines.Add(processedLine);
}
}
string result = string.Join("\n", cleanedLines);
Console.WriteLine($"[DEBUG] 매핑 테이블 JSON 정리 완료: {jsonContent.Length} -> {result.Length} bytes");
return result;
}
catch (Exception ex)
{
Console.WriteLine($"❌ 매핑 테이블 JSON 정리 중 오류: {ex.Message}");
// 정리 실패시 원본 반환
return jsonContent;
}
} }
/// <summary> /// <summary>
/// AI 라벨을 고속도로공사 필드명으로 변환 /// AI 라벨을 고속도로공사 필드명으로 변환
/// </summary> /// </summary>
public string AilabelToExpressway(string ailabel) public string? AilabelToExpressway(string ailabel)
{ {
if (_mappingData.MappingTable.AilabelToSystems.TryGetValue(ailabel, out var systemFields)) if (_mappingData.MappingTable.AilabelToSystems.TryGetValue(ailabel, out var systemFields))
{ {
@@ -79,10 +222,22 @@ public class FieldMapper
return null; return null;
} }
/// <summary>
/// AI 라벨을 DocAiKey 값으로 변환
/// </summary>
public string? AilabelToDocAiKey(string ailabel)
{
if (_mappingData.MappingTable.AilabelToSystems.TryGetValue(ailabel, out var systemFields))
{
return systemFields.DocAiKey;
}
return null;
}
/// <summary> /// <summary>
/// 고속도로공사 필드명을 교통부 필드명으로 변환 /// 고속도로공사 필드명을 교통부 필드명으로 변환
/// </summary> /// </summary>
public string ExpresswayToTransportation(string expresswayField) public string? ExpresswayToTransportation(string expresswayField)
{ {
if (_mappingData.MappingTable.SystemMappings.ExpresswayToTransportation.TryGetValue(expresswayField, out var transportationField)) if (_mappingData.MappingTable.SystemMappings.ExpresswayToTransportation.TryGetValue(expresswayField, out var transportationField))
{ {
@@ -91,10 +246,50 @@ public class FieldMapper
return null; return null;
} }
/// <summary>
/// DocAiKey 값으로부터 해당하는 AI 라벨을 반환
/// </summary>
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;
}
/// <summary>
/// Expressway 필드값으로부터 해당하는 AI 라벨을 반환
/// </summary>
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;
}
/// <summary> /// <summary>
/// AI 라벨 → 고속도로공사 → 교통부 순서로 변환 /// AI 라벨 → 고속도로공사 → 교통부 순서로 변환
/// </summary> /// </summary>
public string AilabelToTransportationViaExpressway(string ailabel) public string? AilabelToTransportationViaExpressway(string ailabel)
{ {
var expresswayField = AilabelToExpressway(ailabel); var expresswayField = AilabelToExpressway(ailabel);
if (!string.IsNullOrEmpty(expresswayField)) if (!string.IsNullOrEmpty(expresswayField))
@@ -107,7 +302,7 @@ public class FieldMapper
/// <summary> /// <summary>
/// AI 라벨에 해당하는 모든 시스템의 필드명을 반환 /// AI 라벨에 해당하는 모든 시스템의 필드명을 반환
/// </summary> /// </summary>
public SystemFields GetAllSystemFields(string ailabel) public SystemFields? GetAllSystemFields(string ailabel)
{ {
if (_mappingData.MappingTable.AilabelToSystems.TryGetValue(ailabel, out var systemFields)) if (_mappingData.MappingTable.AilabelToSystems.TryGetValue(ailabel, out var systemFields))
{ {
@@ -119,9 +314,9 @@ public class FieldMapper
/// <summary> /// <summary>
/// 여러 AI 라벨을 한번에 고속도로공사 필드명으로 변환 /// 여러 AI 라벨을 한번에 고속도로공사 필드명으로 변환
/// </summary> /// </summary>
public Dictionary<string, string> BatchConvertAilabelToExpressway(IEnumerable<string> ailabels) public Dictionary<string, string?> BatchConvertAilabelToExpressway(IEnumerable<string> ailabels)
{ {
var results = new Dictionary<string, string>(); var results = new Dictionary<string, string?>();
foreach (var label in ailabels) foreach (var label in ailabels)
{ {
results[label] = AilabelToExpressway(label); results[label] = AilabelToExpressway(label);
@@ -132,111 +327,130 @@ public class FieldMapper
/// <summary> /// <summary>
/// 여러 고속도로공사 필드를 한번에 교통부 필드명으로 변환 /// 여러 고속도로공사 필드를 한번에 교통부 필드명으로 변환
/// </summary> /// </summary>
public Dictionary<string, string> BatchConvertExpresswayToTransportation(IEnumerable<string> expresswayFields) public Dictionary<string, string?> BatchConvertExpresswayToTransportation(IEnumerable<string> expresswayFields)
{ {
var results = new Dictionary<string, string>(); var results = new Dictionary<string, string?>();
foreach (var field in expresswayFields) foreach (var field in expresswayFields)
{ {
results[field] = ExpresswayToTransportation(field); results[field] = ExpresswayToTransportation(field);
} }
return results; return results;
} }
/// <summary>
/// 매핑 테이블에서 모든 DocAiKey 값의 목록을 반환합니다.
/// </summary>
public List<string> GetAllDocAiKeys()
{
var docAiKeys = new List<string>();
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");
Console.WriteLine("=== AI 라벨 → 고속도로공사 필드명 변환 ==="); //class Program
var testLabels = new[] { "도면명", "편철번호", "도면번호", "Main Title", "계정번호" }; //{
// static void Main(string[] args)
// {
// try
// {
// // 매핑 테이블 로드
// var mapper = FieldMapper.LoadFromFile("mapping_table.json");
foreach (var label in testLabels) // Console.WriteLine("=== AI 라벨 → 고속도로공사 필드명 변환 ===");
{ // var testLabels = new[] { "도면명", "편철번호", "도면번호", "Main Title", "계정번호" };
var expresswayField = mapper.AilabelToExpressway(label);
Console.WriteLine($"{label} → {expresswayField ?? "N/A"}");
}
Console.WriteLine("\n=== 고속도로공사 → 교통부 필드명 변환 ==="); // foreach (var label in testLabels)
var expresswayFields = new[] { "TD_DNAME_MAIN", "TD_DWGNO", "TD_DWGCODE", "TR_RNUM1" }; // {
// var expresswayField = mapper.AilabelToExpressway(label);
// Console.WriteLine($"{label} → {expresswayField ?? "N/A"}");
// }
foreach (var field in expresswayFields) // Console.WriteLine("\n=== 고속도로공사 → 교통부 필드명 변환 ===");
{ // var expresswayFields = new[] { "TD_DNAME_MAIN", "TD_DWGNO", "TD_DWGCODE", "TR_RNUM1" };
var transportationField = mapper.ExpresswayToTransportation(field);
Console.WriteLine($"{field} → {transportationField ?? "N/A"}");
}
Console.WriteLine("\n=== AI 라벨 → 고속도로공사 → 교통부 (연속 변환) ==="); // foreach (var field in expresswayFields)
foreach (var label in testLabels) // {
{ // var transportationField = mapper.ExpresswayToTransportation(field);
var expresswayField = mapper.AilabelToExpressway(label); // Console.WriteLine($"{field} → {transportationField ?? "N/A"}");
var transportationField = mapper.AilabelToTransportationViaExpressway(label); // }
Console.WriteLine($"{label} → {expresswayField ?? "N/A"} → {transportationField ?? "N/A"}");
}
Console.WriteLine("\n=== 특정 AI 라벨의 모든 시스템 필드명 ==="); // Console.WriteLine("\n=== AI 라벨 → 고속도로공사 → 교통부 (연속 변환) ===");
var allFields = mapper.GetAllSystemFields("도면명"); // foreach (var label in testLabels)
if (allFields != null) // {
{ // var expresswayField = mapper.AilabelToExpressway(label);
Console.WriteLine("도면명에 해당하는 모든 시스템 필드:"); // var transportationField = mapper.AilabelToTransportationViaExpressway(label);
Console.WriteLine($" 국토교통부: {allFields.Molit}"); // Console.WriteLine($"{label} → {expresswayField ?? "N/A"} → {transportationField ?? "N/A"}");
Console.WriteLine($" 고속도로공사: {allFields.Expressway}"); // }
Console.WriteLine($" 국가철도공단: {allFields.Railway}");
Console.WriteLine($" 문서AI키: {allFields.DocAiKey}");
}
Console.WriteLine("\n=== 배치 처리 예제 ==="); // Console.WriteLine("\n=== 특정 AI 라벨의 모든 시스템 필드명 ===");
var batchResults = mapper.BatchConvertAilabelToExpressway(testLabels); // var allFields = mapper.GetAllSystemFields("도면명");
foreach (var result in batchResults) // if (allFields != null)
{ // {
Console.WriteLine($"배치 변환: {result.Key} → {result.Value ?? "N/A"}"); // Console.WriteLine("도면명에 해당하는 모든 시스템 필드:");
} // Console.WriteLine($" 국토교통부: {allFields.Molit}");
} // Console.WriteLine($" 고속도로공사: {allFields.Expressway}");
catch (Exception ex) // Console.WriteLine($" 국가철도공단: {allFields.Railway}");
{ // Console.WriteLine($" 문서AI키: {allFields.DocAiKey}");
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 class FieldMapperExtensions
{ //{
/// <summary> // /// <summary>
/// 특정 시스템의 필드명을 다른 시스템으로 변환 // /// 특정 시스템의 필드명을 다른 시스템으로 변환
/// </summary> // /// </summary>
public static string ConvertBetweenSystems(this FieldMapper mapper, string sourceField, string sourceSystem, string targetSystem) // public static string ConvertBetweenSystems(this FieldMapper mapper, string sourceField, string sourceSystem, string targetSystem)
{ // {
// 역방향 조회를 위한 확장 메서드 // // 역방향 조회를 위한 확장 메서드
foreach (var kvp in mapper._mappingData.MappingTable.AilabelToSystems) // foreach (var kvp in mapper._mappingData.MappingTable.AilabelToSystems)
{ // {
var systemFields = kvp.Value; // var systemFields = kvp.Value;
string sourceFieldValue = sourceSystem switch // string sourceFieldValue = sourceSystem switch
{ // {
"molit" => systemFields.Molit, // "molit" => systemFields.Molit,
"expressway" => systemFields.Expressway, // "expressway" => systemFields.Expressway,
"railway" => systemFields.Railway, // "railway" => systemFields.Railway,
"docaikey" => systemFields.DocAiKey, // "docaikey" => systemFields.DocAiKey,
_ => null // _ => null
}; // };
if (sourceFieldValue == sourceField) // if (sourceFieldValue == sourceField)LL
{ // {
return targetSystem switch // return targetSystem switch
{ // {
"molit" => systemFields.Molit, // "molit" => systemFields.Molit,
"expressway" => systemFields.Expressway, // "expressway" => systemFields.Expressway,
"railway" => systemFields.Railway, // "railway" => systemFields.Railway,
"docaikey" => systemFields.DocAiKey, // "docaikey" => systemFields.DocAiKey,
_ => null // _ => null
}; // };
} // }
} // }
return null; // return null;
} // }
} //}

19
fletimageanalysis/.env Normal file
View File

@@ -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 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.

View File

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

View File

@@ -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())

View File

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

View File

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

View File

@@ -0,0 +1,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}")

View File

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

View File

@@ -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이 생략된 경우입니다. 따라서 두 줄인 경우 line0의 값은 비어있어야 하고 line1, line2의 값은 채워져 있어야 합니다."
"{ }_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

View File

@@ -1,77 +1,89 @@
{ {
"mapping_table": { "mapping_table": {
"ailabel_to_systems": { "ailabel_to_systems": {
"도면명": { "도면명_line0": {
"molit": "",
"expressway": "TD_DNAME_TOP",
"railway": "",
"docaikey": "DNAME_TOP"
},
"도면명_line1": {
"molit": "DI_TITLE", "molit": "DI_TITLE",
"expressway": "TD_DNAME_MAIN", "expressway": "TD_DNAME_MAIN",
"railway": "TD_DNAME_MAIN", "railway": "TD_DNAME_MAIN",
"docaikey": "DNAME_MAIN" "docaikey": "DNAME_MAIN"
}, },
"편철번호": { "도면명_line2": {
"molit": "DI_SUBTITLE", "molit": "DI_SUBTITLE",
"expressway": "TD_DNAME_BOT", "expressway": "TD_DNAME_BOT",
"railway": "TD_DNAME_BOT", "railway": "TD_DNAME_BOT",
"docaikey": "DNAME_BOT" "docaikey": "DNAME_BOT"
}, },
"도면번호": { "편철번호": {
"molit": "DA_PAGENO", "molit": "DA_PAGENO",
"expressway": "TD_DWGNO", "expressway": "TD_DWGNO",
"railway": "TD_DWGNO", "railway": "TD_DWGNO",
"docaikey": "DWGNO" "docaikey": "DWGNO"
}, },
"Main Title": { "도면번호": {
"molit": "DI_DRWNO", "molit": "DI_DRWNO",
"expressway": "TD_DWGCODE", "expressway": "TD_DWGCODE",
"railway": "TD_DWGCODE", "railway": "TD_DWGCODE",
"docaikey": "DWGCODE" "docaikey": "DWGCODE"
}, },
"Sub Title": { "Main_Title": {
"molit": "UD_TITLE", "molit": "UD_TITLE",
"expressway": "TB_MTITIL", "expressway": "TB_MTITIL",
"railway": "TB_MTITIL", "railway": "TB_MTITIL",
"docaikey": "MTITIL" "docaikey": "MTITIL"
}, },
"수평축척": { "Sub_Title": {
"molit": "UD_SUBTITLE", "molit": "UD_SUBTITLE",
"expressway": "TB_STITL", "expressway": "TB_STITL",
"railway": "TB_STITL", "railway": "TB_STITL",
"docaikey": "STITL" "docaikey": "STITL"
}, },
"수축척": { "수평_도면_축척": {
"molit": "",
"expressway": "TD_DWGCODE_PREV",
"railway": "",
"docaikey": "DWGCODE_PREV"
},
"도면축척": {
"molit": "DA_HSCALE", "molit": "DA_HSCALE",
"expressway": "TD_HSCAL", "expressway": "TD_HSCAL",
"railway": "", "railway": "",
"docaikey": "HSCAL" "docaikey": "HSCAL"
}, },
"적용표준버전": { "수직_도면_축척": {
"molit": "DA_STDNAME", "molit": "DA_VSCALE",
"expressway": "STDNAME", "expressway": "TD_VSCAL",
"railway": "", "railway": "",
"docaikey": "" "docaikey": "VSCAL"
}, },
"사업명": { "적용표준버전": {
"molit": "DA_STDVER", "molit": "DA_STDVER",
"expressway": "TD_VERSION", "expressway": "TD_VERSION",
"railway": "TD_VERSION", "railway": "TD_VERSION",
"docaikey": "VERSION" "docaikey": "VERSION"
}, },
"시설_공구": { "사업명_top": {
"molit": "PI_CNAME", "molit": "PI_CNAME",
"expressway": "TB_CNAME", "expressway": "TB_CNAME",
"railway": "", "railway": "",
"docaikey": "TBCNAME" "docaikey": "TBCNAME"
}, },
"설계공구_Station": { "시설_공구": {
"molit": "UD_CDNAME", "molit": "UD_CDNAME",
"expressway": "TB_CSCOP", "expressway": "TB_CSCOP",
"railway": "", "railway": "",
"docaikey": "CSCOP" "docaikey": "CSCOP"
},
"설계공구_공구명": {
"molit": "",
"expressway": "TD_DSECT",
"railway": "",
"docaikey": "DSECT"
},
"시공공구_공구명": {
"molit": "",
"expressway": "TD_CSECT",
"railway": "",
"docaikey": "CSECT"
}, },
"건설분야": { "건설분야": {
"molit": "PA_CCLASS", "molit": "PA_CCLASS",
@@ -86,54 +98,66 @@
"docaikey": "CSTEP" "docaikey": "CSTEP"
}, },
"설계사": { "설계사": {
"molit": "TD_DCOMP", "molit": "",
"expressway": "TD_DCOMP", "expressway": "TD_DCOMP",
"railway": "", "railway": "TD_DCOMP",
"docaikey": "DCOMP" "docaikey": "DCOMP"
}, },
"시공사": { "시공사": {
"molit": "TD_CCOMP", "molit": "",
"expressway": "TD_CCOMP", "expressway": "TD_CCOMP",
"railway": "", "railway": "TD_CCOMP",
"docaikey": "CCOMP" "docaikey": "CCOMP"
}, },
"노선이정": { "노선이정": {
"molit": "TD_LNDST", "molit": "",
"expressway": "", "expressway": "TD_LNDST",
"railway": "", "railway": "",
"docaikey": "LNDST" "docaikey": "LNDST"
}, },
"계정번호": { "설계공구_범위": {
"molit": "",
"expressway": "TD_DDIST",
"railway": "",
"docaikey": "DDIST"
},
"시공공구_범위": {
"molit": "",
"expressway": "TD_CDIST",
"railway": "",
"docaikey": "CDIST"
},
"개정번호_1": {
"molit": "DC_RNUM1", "molit": "DC_RNUM1",
"expressway": "TR_RNUM1", "expressway": "TR_RNUM1",
"railway": "TR_RNUM1", "railway": "TR_RNUM1",
"docaikey": "RNUM1" "docaikey": "RNUM1"
}, },
"정날짜": { "정날짜_1": {
"molit": "DC_RDATE1", "molit": "DC_RDATE1",
"expressway": "TR_RDAT1", "expressway": "TR_RDAT1",
"railway": "TR_RDAT1", "railway": "TR_RDAT1",
"docaikey": "RDAT1" "docaikey": "RDAT1"
}, },
"개정내용": { "개정내용_1": {
"molit": "DC_RDES1", "molit": "DC_RDES1",
"expressway": "TR_RCON1", "expressway": "TR_RCON1",
"railway": "TR_RCON1", "railway": "TR_RCON1",
"docaikey": "RCON1" "docaikey": "RCON1"
}, },
"작성자": { "작성자_1": {
"molit": "DC_RDGN1", "molit": "DC_RDGN1",
"expressway": "TR_DGN1", "expressway": "TR_DGN1",
"railway": "TR_DGN1", "railway": "TR_DGN1",
"docaikey": "DGN1" "docaikey": "DGN1"
}, },
"검토자": { "검토자_1": {
"molit": "DC_RCHK1", "molit": "DC_RCHK1",
"expressway": "TR_CHK1", "expressway": "TR_CHK1",
"railway": "TR_CHK1", "railway": "TR_CHK1",
"docaikey": "CHK1" "docaikey": "CHK1"
}, },
"확인자": { "확인자_1": {
"molit": "DC_RAPP1", "molit": "DC_RAPP1",
"expressway": "TR_APP1", "expressway": "TR_APP1",
"railway": "TR_APP1", "railway": "TR_APP1",

View File

@@ -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())

View File

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

View File

@@ -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

View File

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

134
notedetectproblem.txt Normal file
View File

@@ -0,0 +1,134 @@
NOTE Detection Algorithm Context Report
Problem Summary
Successfully integrated NOTE extraction from ExportExcel_note.cs into the new modular architecture, but encountering
issues where only some NOTEs are being detected and finding their content boxes.
Current Status
✅ FIXED: No note content issue - reverted to original working cross-line intersection algorithm from ExportExcel_old.cs
🔄 ONGOING: Not detecting all NOTEs (missing notes 2 and 4 from a 4-note layout)
Architecture Overview
Key Files and Components
- Main Entry Point: Models/ExportExcel.cs:265-271 - calls note extraction in ExportAllDwgToExcelHeightSorted
- Core Algorithm: Models/DwgDataExtractor.cs:342-480 - ExtractNotesFromDrawing method
- Note Box Detection: Models/DwgDataExtractor.cs:513-569 - FindNoteBox method
- Excel Output: Models/ExcelDataWriter.cs:282-371 - WriteNoteEntities method
Current Algorithm Flow
1. Collection Phase: Gather all DBText, Polyline, and Line entities
2. NOTE Detection: Find DBText containing "NOTE" (case-insensitive)
3. Box Finding: For each NOTE, use cross-line intersection to find content box below
4. Content Extraction: Find text entities within detected boxes
5. Sorting & Grouping: Sort by coordinates (Y descending, X ascending) and group NOTE+content
6. Excel Output: Write to Excel with NOTE followed immediately by its content
Current Working Algorithm (Reverted from ExportExcel_old.cs)
FindNoteBox Method (DwgDataExtractor.cs:513-569)
// Draws horizontal search line below NOTE position
double searchY = notePos.Y - (noteHeight * 2);
var searchLineStart = new Point3d(notePos.X - noteHeight * 10, searchY, 0);
var searchLineEnd = new Point3d(notePos.X + noteHeight * 50, searchY, 0);
// 1. Check Polyline intersections
// 2. Check Line intersections and trace rectangles
// 3. Use usedBoxes HashSet to prevent duplicate assignment
IsValidNoteBox Validation (DwgDataExtractor.cs:1005-1032)
// Simple validation criteria:
// - Box must be below NOTE (box.maxPoint.Y < notePos.Y)
// - Size constraints: noteHeight < width/height < noteHeight * 100
// - Distance constraints: X distance < noteHeight * 50, Y distance < noteHeight * 10
Known Issues from Previous Sessions
Issue 1: 1/1/3/3 Duplicate Content (PREVIOUSLY FIXED)
Problem: Multiple NOTEs finding the same large spanning polyline
Root Cause: Box detection finding one large polyline spanning multiple note areas
Solution Applied: Used usedBoxes HashSet to prevent duplicate assignment
Issue 2: Reverse Note Ordering (PREVIOUSLY FIXED)
Problem: Notes written in reverse order
Solution Applied: Sort by Y descending (bigger Y = top), then X ascending
Issue 3: Wrong Note Grouping (PREVIOUSLY FIXED)
Problem: All NOTEs grouped first, then all content
Solution Applied: Group each NOTE immediately with its content
Issue 4: Missing NOTEs 2 and 4 (CURRENT ISSUE)
Problem: In a 4-note layout arranged as 1-2 (top row) and 3-4 (bottom row), only notes 1 and 3 are detected
Possible Causes:
- Search line positioning not intersecting with notes 2 and 4's content boxes
- Box validation criteria too restrictive for right-side notes
- Geometric relationship between NOTE position and content box differs for right-side notes
Debug Information Available
Last Known Debug Output (5 NOTEs detected but no content found)
[DEBUG] Note 텍스트 발견: 'NOTE' at (57.0572050838764,348.6990318186563,0)
[DEBUG] Note 텍스트 발견: 'NOTE' at (471.6194660633719,501.3393888589908,0)
[DEBUG] Note 텍스트 발견: 'NOTE' at (444.9503218738628,174.19527687737536,0)
[DEBUG] Note 텍스트 발견: 'NOTE' at (602.7327260134425,174.43523739278135,0)
[DEBUG] Note 텍스트 발견: 'NOTE' at (635.5065816693041,502.83938885945645,0)
Reference Image
- noteExample.png shows expected layout with numbered sections 1-7 in Korean text
- Shows box-structured layout where each NOTE should have corresponding content below
Key Coordinate Analysis
From debug logs, NOTEs at similar Y coordinates appear to be in pairs:
- Top Row: (444.95, 174.20) and (602.73, 174.44) - Y≈174
- Middle Row: (471.62, 501.34) and (635.51, 502.84) - Y≈502
- Single: (57.06, 348.70) - Y≈349
Pattern suggests left-right pairing where right-side NOTEs might need different search strategies.
Investigation Areas for Next Session
Priority 1: Search Line Geometry
- Analyze why horizontal search lines from right-side NOTEs don't intersect content boxes
- Consider adjusting search line direction/positioning for right-side notes
- Debug actual intersection results for missing NOTEs
Priority 2: Box Validation Criteria
- Review IsValidNoteBox distance calculations for right-side NOTEs
- Consider if content boxes for right-side NOTEs have different geometric relationships
Priority 3: Coordinate Pattern Analysis
- Investigate why NOTEs at (602.73, 174.44) and (635.51, 502.84) aren't finding content
- Compare successful vs failed NOTE positions and their content box relationships
Quick Start Commands for Next Session
1. Run existing code to see current NOTE detection results
2. Add detailed debug logging to FindNoteBox for specific coordinates: (602.73, 174.44) and (635.51, 502.84)
3. Analyze intersection results and box validation for these specific NOTEs
4. Consider geometric adjustments for right-side NOTE detection
Code State
- Current implementation in Models/DwgDataExtractor.cs uses proven cross-line intersection algorithm
- usedBoxes tracking prevents duplicate assignment
- NOTE+content grouping and Y-coordinate sorting working correctly
- Excel output formatting functional
The foundation is solid; focus should be on geometric refinements for complete NOTE detection coverage.