summaryrefslogtreecommitdiff
path: root/app/api/ocr
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-11 12:18:38 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-11 12:18:38 +0000
commitff902243a658067fae858a615c0629aa2e0a4837 (patch)
tree42d30e986d1cbfb282c644c01730cd053b816b7a /app/api/ocr
parent42e38f41cb4c0b4bf9c08b71ed087cd7f0c7fc18 (diff)
(대표님) 20250611 21시 15분 OCR 등
Diffstat (limited to 'app/api/ocr')
-rw-r--r--app/api/ocr/enhanced/route.ts1112
-rw-r--r--app/api/ocr/utils/imageRotation.ts583
-rw-r--r--app/api/ocr/utils/tableExtraction.ts611
3 files changed, 1301 insertions, 1005 deletions
diff --git a/app/api/ocr/enhanced/route.ts b/app/api/ocr/enhanced/route.ts
index 14a81399..06cea358 100644
--- a/app/api/ocr/enhanced/route.ts
+++ b/app/api/ocr/enhanced/route.ts
@@ -1,13 +1,15 @@
+// ============================================================================
// app/api/ocr/rotation-enhanced/route.ts
-// DB 저장 기능이 추가된 회전 감지 및 테이블 추출 API
+// 최적화된 OCR API - 통합 처리로 API 호출 최소화
+// ============================================================================
import { NextRequest, NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
-import db from '@/db/db'; // DB 인스턴스 import
-import {
- ocrSessions,
- ocrTables,
- ocrRows,
+import db from '@/db/db';
+import {
+ ocrSessions,
+ ocrTables,
+ ocrRows,
ocrRotationAttempts,
type NewOcrSession,
type NewOcrTable,
@@ -17,20 +19,36 @@ import {
type ExtractedRow
} from '@/db/schema/ocr';
import { extractTablesFromOCR, analyzeOCRQuality } from '../utils/tableExtraction';
-import {
- rotateImageBase64,
- enhanceImageQuality,
- convertPDFToImage,
- needsRotation
+import {
+ rotateImageBase64,
+ enhanceImageQuality,
+ convertPDFToImage,
+ needsRotation,
+ normalizeImageFormat,
+ validateBase64Image,
+ getPDFPageCount,
+ detectTextOrientation
} from '../utils/imageRotation';
-
-interface RotationTestResult {
- rotation: number;
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { sql } from 'drizzle-orm';
+import sharp from 'sharp';
+
+interface PageRotationInfo {
+ pageIndex: number;
+ optimalRotation: number;
confidence: number;
- tablesFound: number;
- textQuality: number;
- keywordCount: number;
- extractedTables?: ExtractedRow[][];
+ needsRotationCheck: boolean;
+}
+
+interface OptimizedProcessingResult {
+ success: boolean;
+ sessionId: string;
+ extractedTables: ExtractedRow[][];
+ totalApiCalls: number;
+ processingTime: number;
+ pageRotations: PageRotationInfo[];
+ warnings: string[];
}
interface ProcessingResult {
@@ -45,6 +63,16 @@ interface ProcessingResult {
bestRotation: number;
imageEnhanced: boolean;
pdfConverted: boolean;
+ pagesProcessed?: number;
+ totalApiCalls: number;
+ pageDetails?: Array<{
+ pageIndex: number;
+ rotation: number;
+ tablesFound: number;
+ rowsFound: number;
+ confidence: number;
+ processingTime: number;
+ }>;
};
error?: string;
warnings?: string[];
@@ -54,17 +82,29 @@ export async function POST(request: NextRequest) {
const startTime = Date.now();
const warnings: string[] = [];
let sessionId: string | null = null;
+ let file: File | null = null;
try {
- console.log('🔄 Starting rotation-enhanced OCR processing...');
-
+ console.log('🚀 === STARTING OPTIMIZED OCR PROCESSING ===');
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json(
+ { success: false, message: "인증이 필요합니다" },
+ { status: 401 }
+ )
+ }
+
+ const userId = parseInt(session.user.id)
+
+ // formData 한 번만 읽기
const formData = await request.formData();
- const file = formData.get('file') as File;
-
+ file = formData.get('file') as File;
+
if (!file) {
- return NextResponse.json({
- success: false,
- error: 'No file provided'
+ return NextResponse.json({
+ success: false,
+ error: 'No file provided'
}, { status: 400 });
}
@@ -72,16 +112,16 @@ export async function POST(request: NextRequest) {
// 파일 검증
if (!file.type.includes('pdf') && !file.type.includes('image')) {
- return NextResponse.json({
- success: false,
- error: 'Invalid file type. Only PDF and image files are supported.'
+ return NextResponse.json({
+ success: false,
+ error: 'Invalid file type. Only PDF and image files are supported.'
}, { status: 400 });
}
if (file.size > 10 * 1024 * 1024) { // 10MB 제한
- return NextResponse.json({
- success: false,
- error: 'File size too large. Maximum size is 10MB.'
+ return NextResponse.json({
+ success: false,
+ error: 'File size too large. Maximum size is 10MB.'
}, { status: 400 });
}
@@ -90,54 +130,35 @@ export async function POST(request: NextRequest) {
const fileBuffer = Buffer.from(buffer);
const fileformat = file.type.includes('pdf') ? 'pdf' : 'image';
- let base64: string;
- let imageEnhanced = false;
- let pdfConverted = false;
+ let processingResult: OptimizedProcessingResult;
- // PDF vs 이미지 처리
if (fileformat === 'pdf') {
- console.log('📄 Converting PDF to high-quality image...');
- base64 = await convertPDFToImage(fileBuffer, 0);
- pdfConverted = true;
+ console.log('📄 Processing PDF with optimized approach...');
+ processingResult = await processPDFOptimized(fileBuffer, file.name);
} else {
- base64 = fileBuffer.toString('base64');
-
- // 이미지 품질 개선
- console.log('🎨 Enhancing image quality...');
- base64 = await enhanceImageQuality(base64);
- imageEnhanced = true;
- }
-
- // 회전 테스트 수행
- console.log('🔍 Testing multiple rotations...');
- const rotationResults = await tryMultipleRotations(base64, file.name);
-
- // 최적 회전 각도 선택
- const bestResult = findBestRotation(rotationResults);
- console.log(`✅ Best rotation found: ${bestResult.rotation}° (score: ${bestResult.score?.toFixed(2)})`);
-
- if (bestResult.rotation !== 0) {
- warnings.push(`Document was auto-rotated ${bestResult.rotation}° for optimal OCR results`);
+ console.log('🖼️ Processing single image with optimized approach...');
+ processingResult = await processImageOptimized(fileBuffer, file.name);
}
- // 최적 결과 사용
- const finalTables = bestResult.extractedTables || [];
- const totalRows = finalTables.reduce((sum, table) => sum + table.length, 0);
const processingTime = Date.now() - startTime;
- // 🗃️ DB에 저장
+ console.log(`\n📊 === PROCESSING SUMMARY ===`);
+ console.log(`Total API calls: ${processingResult.totalApiCalls} (optimized!)`);
+ console.log(`Total tables found: ${processingResult.extractedTables.length}`);
+ console.log(`Total rows extracted: ${processingResult.extractedTables.reduce((sum, table) => sum + table.length, 0)}`);
+ console.log(`Total processing time: ${(processingTime / 1000).toFixed(1)}s`);
+
+ // DB에 저장
console.log('💾 Saving results to database...');
sessionId = await saveToDatabase({
+ userId,
file,
fileformat,
processingTime,
- bestRotation: bestResult.rotation,
- finalTables,
- totalRows,
- rotationResults,
- imageEnhanced,
- pdfConverted,
- warnings
+ extractedTables: processingResult.extractedTables,
+ pageRotations: processingResult.pageRotations,
+ totalApiCalls: processingResult.totalApiCalls,
+ warnings: [...warnings, ...processingResult.warnings]
});
console.log(`⏱️ Processing completed in ${(processingTime / 1000).toFixed(1)}s, saved as session ${sessionId}`);
@@ -146,41 +167,48 @@ export async function POST(request: NextRequest) {
success: true,
sessionId,
metadata: {
- totalTables: finalTables.length,
- totalRows,
+ totalTables: processingResult.extractedTables.length,
+ totalRows: processingResult.extractedTables.reduce((sum, table) => sum + table.length, 0),
processingTime,
fileName: file.name,
fileSize: file.size,
- bestRotation: bestResult.rotation,
- imageEnhanced,
- pdfConverted
+ bestRotation: processingResult.pageRotations[0]?.optimalRotation || 0,
+ imageEnhanced: fileformat === 'image',
+ pdfConverted: fileformat === 'pdf',
+ pagesProcessed: processingResult.pageRotations.length,
+ totalApiCalls: processingResult.totalApiCalls,
+ pageDetails: processingResult.pageRotations.map(p => ({
+ pageIndex: p.pageIndex,
+ rotation: p.optimalRotation,
+ tablesFound: 0, // 통합 처리로 인해 페이지별 세부사항은 후처리에서 계산
+ rowsFound: 0,
+ confidence: p.confidence,
+ processingTime: 0
+ }))
},
- warnings: warnings.length > 0 ? warnings : undefined
+ warnings: [...warnings, ...processingResult.warnings].length > 0 ? [...warnings, ...processingResult.warnings] : undefined
};
-
+
return NextResponse.json(result);
} catch (error) {
console.error('❌ OCR processing error:', error);
-
+
const processingTime = Date.now() - startTime;
-
- // 에러 발생시에도 세션 저장 (실패 기록)
- if (!sessionId) {
+
+ // 에러 발생시에도 세션 저장
+ if (!sessionId && file) {
try {
- const errorFile = await request.formData().then(fd => fd.get('file') as File);
- if (errorFile) {
- sessionId = await saveErrorToDatabase({
- file: errorFile,
- processingTime,
- error: error instanceof Error ? error.message : 'Unknown error occurred'
- });
- }
+ sessionId = await saveErrorToDatabase({
+ file,
+ processingTime,
+ error: error instanceof Error ? error.message : 'Unknown error occurred'
+ });
} catch (dbError) {
console.error('Failed to save error to database:', dbError);
}
}
-
+
return NextResponse.json({
success: false,
sessionId: sessionId || null,
@@ -189,117 +217,582 @@ export async function POST(request: NextRequest) {
processingTime,
totalTables: 0,
totalRows: 0,
- fileName: '',
- fileSize: 0,
+ fileName: file?.name || '',
+ fileSize: file?.size || 0,
bestRotation: 0,
imageEnhanced: false,
- pdfConverted: false
+ pdfConverted: false,
+ totalApiCalls: 0
}
}, { status: 500 });
}
}
-// DB 저장 함수
+// PDF 최적화 처리 - 통합 방식
+async function processPDFOptimized(
+ fileBuffer: Buffer,
+ fileName: string
+): Promise<OptimizedProcessingResult> {
+ console.log('🚀 === OPTIMIZED PDF PROCESSING ===');
+
+ const warnings: string[] = [];
+
+ try {
+ // 1단계: 페이지 수 확인
+ const pageCount = await getPDFPageCount(fileBuffer);
+ console.log(`📊 PDF contains ${pageCount} pages`);
+
+ // 2단계: 각 페이지의 최적 회전각 결정 (OCR API 호출 없이)
+ const pageRotations = await determineOptimalRotations(fileBuffer, pageCount);
+ console.log('🧭 Optimal rotations determined:', pageRotations.map(p => `Page ${p.pageIndex + 1}: ${p.optimalRotation}°`));
+
+ // 3단계: 처리 방식 결정
+ if (shouldProcessAsWhole(pageCount, fileBuffer.length)) {
+ console.log('📋 Processing as single combined document...');
+ const result = await processAsWholePDF(fileBuffer, pageRotations, fileName);
+ return {
+ success: true,
+ sessionId: '',
+ extractedTables: result.extractedTables,
+ totalApiCalls: 1, // 🎉 단 1번의 API 호출!
+ processingTime: Date.now(),
+ pageRotations,
+ warnings: [...warnings, ...result.warnings]
+ };
+ } else {
+ console.log('📄 Processing pages individually with optimal rotations...');
+ const result = await processPageByPageOptimized(fileBuffer, pageRotations, fileName);
+ return {
+ success: true,
+ sessionId: '',
+ extractedTables: result.extractedTables,
+ totalApiCalls: pageCount, // 페이지 수만큼만 호출
+ processingTime: Date.now(),
+ pageRotations,
+ warnings: [...warnings, ...result.warnings]
+ };
+ }
+
+ } catch (error) {
+ console.error('❌ PDF processing failed:', error);
+ throw error;
+ }
+}
+
+// 이미지 최적화 처리
+async function processImageOptimized(
+ fileBuffer: Buffer,
+ fileName: string
+): Promise<OptimizedProcessingResult> {
+ console.log('🖼️ === OPTIMIZED IMAGE PROCESSING ===');
+
+ const warnings: string[] = [];
+
+ try {
+ let base64 = fileBuffer.toString('base64');
+
+ // 원본 이미지 검증
+ const originalValidation = validateBase64Image(base64);
+ if (!originalValidation.isValid) {
+ throw new Error(`Invalid image file: ${originalValidation.error}`);
+ }
+
+ console.log(`✅ Original image valid: ${originalValidation.size} bytes`);
+
+ // 이미지 품질 개선
+ console.log('🎨 Enhancing image quality...');
+ base64 = await enhanceImageQuality(base64);
+
+ // 최적 회전각 결정 (OCR API 호출 없이)
+ const optimalRotation = await detectTextOrientation(base64);
+ console.log(`🧭 Optimal rotation detected: ${optimalRotation}°`);
+
+ // 최적 회전 적용
+ if (optimalRotation !== 0) {
+ console.log(`🔄 Applying optimal rotation: ${optimalRotation}°...`);
+ base64 = await rotateImageBase64(base64, optimalRotation);
+ warnings.push(`Image was auto-rotated ${optimalRotation}° for optimal OCR results`);
+ }
+
+ // 단 1번의 OCR API 호출
+ console.log('🌐 Single OCR API call for optimized image...');
+ const ocrResult = await callOCRAPI(base64, 'jpg', fileName);
+
+ // 결과 처리
+ const reportNo = extractReportNo(ocrResult);
+ const analysis = analyzeOCRQuality(ocrResult);
+ const rawTables = await extractTablesFromOCR(ocrResult) as BaseExtractedRow[][];
+
+ const extractedTables: ExtractedRow[][] = rawTables.map(table =>
+ table.map(row => ({
+ ...row,
+ reportNo
+ }))
+ );
+
+ const pageRotations: PageRotationInfo[] = [{
+ pageIndex: 0,
+ optimalRotation,
+ confidence: analysis.confidence,
+ needsRotationCheck: optimalRotation !== 0
+ }];
+
+ return {
+ success: true,
+ sessionId: '',
+ extractedTables,
+ totalApiCalls: 1, // 단 1번의 API 호출!
+ processingTime: Date.now(),
+ pageRotations,
+ warnings
+ };
+
+ } catch (error) {
+ console.error('❌ Image processing failed:', error);
+ throw error;
+ }
+}
+
+// 페이지별 최적 회전각 결정 (OCR API 없이)
+async function determineOptimalRotations(
+ fileBuffer: Buffer,
+ pageCount: number
+): Promise<PageRotationInfo[]> {
+ console.log('🔍 Determining optimal rotations without OCR calls...');
+
+ const rotations: PageRotationInfo[] = [];
+
+ for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {
+ try {
+ console.log(` 📄 Analyzing page ${pageIndex + 1}...`);
+
+ // 저해상도로 빠르게 변환 (150 DPI - 품질과 속도의 균형)
+ const lowResBase64 = await convertPDFToImage(fileBuffer, pageIndex, 150);
+
+ // 회전 필요성 확인
+ const needsRotationCheck = await needsRotation(lowResBase64);
+
+ let optimalRotation = 0;
+ let confidence = 1.0;
+
+ if (needsRotationCheck) {
+ console.log(` 🔄 Page ${pageIndex + 1} needs rotation analysis...`);
+ // 이미지 분석 기반 회전 감지
+ optimalRotation = await detectTextOrientation(lowResBase64);
+ confidence = await calculateRotationConfidence(lowResBase64, optimalRotation);
+ } else {
+ console.log(` ✅ Page ${pageIndex + 1} appears correctly oriented`);
+ }
+
+ rotations.push({
+ pageIndex,
+ optimalRotation,
+ confidence,
+ needsRotationCheck
+ });
+
+ console.log(` 📊 Page ${pageIndex + 1}: ${optimalRotation}° rotation (confidence: ${(confidence * 100).toFixed(1)}%)`);
+
+ } catch (error) {
+ console.warn(` ⚠️ Page ${pageIndex + 1}: rotation detection failed, using 0°`);
+ rotations.push({
+ pageIndex,
+ optimalRotation: 0,
+ confidence: 0.5,
+ needsRotationCheck: false
+ });
+ }
+ }
+
+ return rotations;
+}
+
+// 회전 신뢰도 계산
+async function calculateRotationConfidence(
+ base64: string,
+ rotation: number
+): Promise<number> {
+ try {
+ const rotatedBase64 = await rotateImageBase64(base64, rotation);
+
+ // 이미지 품질 지표들을 분석하여 신뢰도 계산
+ const cleanBase64 = rotatedBase64.replace(/^data:image\/[a-z]+;base64,/, '');
+ const buffer = Buffer.from(cleanBase64, 'base64');
+
+ const stats = await sharp(buffer)
+ .greyscale()
+ .stats();
+
+ const contrast = stats.channels[0].max - stats.channels[0].min;
+ const sharpness = stats.channels[0].stdev;
+
+ // 대비와 선명도를 기반으로 신뢰도 계산
+ const confidence = Math.min((contrast + sharpness) / 400, 1.0);
+
+ return confidence;
+
+ } catch (error) {
+ console.warn('Failed to calculate rotation confidence:', error);
+ return 0.5;
+ }
+}
+
+// 처리 방식 결정
+function shouldProcessAsWhole(pageCount: number, fileSize: number): boolean {
+ // 조건:
+ // 1. 파일 크기가 8MB 이하
+ // 2. 페이지 수가 5개 이하
+ // 3. 모든 페이지가 동일한 회전각을 갖는 경우 우선 고려
+
+ const maxSizeForWhole = 8 * 1024 * 1024; // 8MB
+ const maxPagesForWhole = 5;
+
+ const sizeOk = fileSize <= maxSizeForWhole;
+ const pagesOk = pageCount <= maxPagesForWhole;
+
+ console.log(`📋 Processing decision: size(${sizeOk}) + pages(${pagesOk}) = ${sizeOk && pagesOk ? 'WHOLE' : 'INDIVIDUAL'}`);
+
+ return sizeOk && pagesOk;
+}
+
+// 전체 PDF를 하나로 처리
+async function processAsWholePDF(
+ fileBuffer: Buffer,
+ pageRotations: PageRotationInfo[],
+ fileName: string
+): Promise<{
+ extractedTables: ExtractedRow[][];
+ warnings: string[];
+}> {
+ console.log('🔄 Creating single combined image with corrected rotations...');
+
+ const warnings: string[] = [];
+
+ try {
+ // 각 페이지를 올바른 회전으로 변환하여 이미지 배열 생성
+ const pageImages: string[] = [];
+
+ for (const pageInfo of pageRotations) {
+ console.log(` 📄 Processing page ${pageInfo.pageIndex + 1} with ${pageInfo.optimalRotation}° rotation...`);
+
+ // 고해상도로 변환
+ let pageBase64 = await convertPDFToImage(fileBuffer, pageInfo.pageIndex, 300);
+
+ // 필요시 회전 적용
+ if (pageInfo.optimalRotation !== 0) {
+ pageBase64 = await rotateImageBase64(pageBase64, pageInfo.optimalRotation);
+ warnings.push(`Page ${pageInfo.pageIndex + 1} was rotated ${pageInfo.optimalRotation}° for optimal results`);
+ }
+
+ pageImages.push(pageBase64);
+ }
+
+ // 모든 페이지를 하나의 긴 이미지로 합치기
+ console.log('🖼️ Combining all pages into single image...');
+ const combinedBase64 = await combineImagesVertically(pageImages);
+
+ // 한 번의 OCR API 호출
+ console.log('🌐 Single OCR API call for entire combined document...');
+ const ocrResult = await callOCRAPI(combinedBase64, 'jpg', `combined_${fileName}`);
+
+ // 결과 처리
+ const reportNo = extractReportNo(ocrResult);
+ const rawTables = await extractTablesFromOCR(ocrResult) as BaseExtractedRow[][];
+
+ const extractedTables: ExtractedRow[][] = rawTables.map(table =>
+ table.map(row => ({
+ ...row,
+ reportNo
+ }))
+ );
+
+ console.log(`✅ Combined processing complete: ${extractedTables.length} tables found`);
+
+ return {
+ extractedTables,
+ warnings
+ };
+
+ } catch (error) {
+ console.error('❌ Whole PDF processing failed:', error);
+ throw error;
+ }
+}
+
+// 페이지별 최적화 처리
+async function processPageByPageOptimized(
+ fileBuffer: Buffer,
+ pageRotations: PageRotationInfo[],
+ fileName: string
+): Promise<{
+ extractedTables: ExtractedRow[][];
+ warnings: string[];
+}> {
+ console.log('📄 Processing pages individually with predetermined rotations...');
+
+ const allTables: ExtractedRow[][] = [];
+ const warnings: string[] = [];
+
+ for (const pageInfo of pageRotations) {
+ try {
+ console.log(` 📄 Processing page ${pageInfo.pageIndex + 1} (${pageInfo.optimalRotation}° rotation)...`);
+
+ // 고해상도로 변환
+ let pageBase64 = await convertPDFToImage(fileBuffer, pageInfo.pageIndex, 300);
+
+ // 미리 결정된 회전각 적용
+ if (pageInfo.optimalRotation !== 0) {
+ pageBase64 = await rotateImageBase64(pageBase64, pageInfo.optimalRotation);
+ warnings.push(`Page ${pageInfo.pageIndex + 1} was rotated ${pageInfo.optimalRotation}° for optimal results`);
+ }
+
+ // 단 1번의 OCR API 호출 (회전 테스트 없이)
+ console.log(` 🌐 OCR API call for page ${pageInfo.pageIndex + 1}...`);
+ const ocrResult = await callOCRAPI(pageBase64, 'jpg', `${fileName}_page_${pageInfo.pageIndex + 1}`);
+
+ // 결과 처리
+ const reportNo = extractReportNo(ocrResult);
+ const rawTables = await extractTablesFromOCR(ocrResult) as BaseExtractedRow[][];
+
+ const pageTables: ExtractedRow[][] = rawTables.map(table =>
+ table.map(row => ({
+ ...row,
+ reportNo
+ }))
+ );
+
+ allTables.push(...pageTables);
+
+ console.log(` ✅ Page ${pageInfo.pageIndex + 1}: ${pageTables.length} tables found`);
+
+ } catch (error) {
+ console.error(` ❌ Error processing page ${pageInfo.pageIndex + 1}:`, error);
+ warnings.push(`Page ${pageInfo.pageIndex + 1} processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ return {
+ extractedTables: allTables,
+ warnings
+ };
+}
+
+// 이미지들을 세로로 합치기
+async function combineImagesVertically(base64Images: string[]): Promise<string> {
+ console.log(`🖼️ Combining ${base64Images.length} images vertically...`);
+
+ try {
+ if (base64Images.length === 0) {
+ throw new Error('No images to combine');
+ }
+
+ if (base64Images.length === 1) {
+ return base64Images[0];
+ }
+
+ // 각 이미지를 Sharp 객체로 변환
+ const imageBuffers = base64Images.map(base64 => {
+ const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, '');
+ return Buffer.from(cleanBase64, 'base64');
+ });
+
+ // 첫 번째 이미지의 메타데이터 확인
+ const firstImage = sharp(imageBuffers[0]);
+ const firstMetadata = await firstImage.metadata();
+
+ if (!firstMetadata.width || !firstMetadata.height) {
+ throw new Error('Invalid first image metadata');
+ }
+
+ const targetWidth = firstMetadata.width;
+ let totalHeight = firstMetadata.height;
+
+ // 나머지 이미지들의 높이 계산 (같은 너비로 리사이즈)
+ const resizedBuffers = [imageBuffers[0]];
+
+ for (let i = 1; i < imageBuffers.length; i++) {
+ const img = sharp(imageBuffers[i]);
+ const metadata = await img.metadata();
+
+ if (metadata.width && metadata.height) {
+ const aspectRatio = metadata.height / metadata.width;
+ const newHeight = Math.round(targetWidth * aspectRatio);
+
+ const resizedBuffer = await img
+ .resize(targetWidth, newHeight)
+ .jpeg({ quality: 90 })
+ .toBuffer();
+
+ resizedBuffers.push(resizedBuffer);
+ totalHeight += newHeight;
+
+ console.log(` 📏 Image ${i + 1}: ${metadata.width}x${metadata.height} → ${targetWidth}x${newHeight}`);
+ }
+ }
+
+ console.log(` 📊 Combined size will be: ${targetWidth}x${totalHeight}`);
+
+ // 빈 캔버스 생성
+ const combinedImage = sharp({
+ create: {
+ width: targetWidth,
+ height: totalHeight,
+ channels: 3,
+ background: { r: 255, g: 255, b: 255 }
+ }
+ });
+
+ // 이미지들을 세로로 배치
+ const composite = [];
+ let currentTop = 0;
+
+ for (const buffer of resizedBuffers) {
+ composite.push({
+ input: buffer,
+ top: currentTop,
+ left: 0
+ });
+
+ const metadata = await sharp(buffer).metadata();
+ currentTop += metadata.height || 0;
+ }
+
+ const combinedBuffer = await combinedImage
+ .composite(composite)
+ .jpeg({ quality: 90 })
+ .toBuffer();
+
+ const combinedBase64 = combinedBuffer.toString('base64');
+
+ console.log(`✅ Images combined successfully: ${combinedBuffer.length} bytes`);
+
+ return combinedBase64;
+
+ } catch (error) {
+ console.error('❌ Error combining images:', error);
+ throw new Error(`Failed to combine images: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+}
+
+// DB 저장 함수 (업데이트됨)
async function saveToDatabase({
+ userId,
file,
fileformat,
processingTime,
- bestRotation,
- finalTables,
- totalRows,
- rotationResults,
- imageEnhanced,
- pdfConverted,
+ extractedTables,
+ pageRotations,
+ totalApiCalls,
warnings
}: {
+ userId: number | string,
file: File;
fileformat: string;
processingTime: number;
- bestRotation: number;
- finalTables: ExtractedRow[][];
- totalRows: number;
- rotationResults: (RotationTestResult & { score?: number })[];
- imageEnhanced: boolean;
- pdfConverted: boolean;
+ extractedTables: ExtractedRow[][];
+ pageRotations: PageRotationInfo[];
+ totalApiCalls: number;
warnings: string[];
}): Promise<string> {
-
+
return await db.transaction(async (tx) => {
- // 1. 세션 저장
+
+ // 1단계: 필터링된 행 수를 미리 계산
+ const filteredTables = extractedTables.map(table =>
+ table.filter(row => (!row.no || row.no.length < 10) && row.no !== "No." && row.weldingDate.length < 15 && row.jointType.length < 5)
+ );
+ const totalTablesFiltered = filteredTables.length;
+ const totalRowsFiltered = filteredTables.reduce((sum, t) => sum + t.length, 0);
+
+ // 2단계: 세션 저장
const sessionData: NewOcrSession = {
fileName: file.name,
fileSize: file.size,
fileType: fileformat,
processingTime,
- bestRotation,
- totalTables: finalTables.length,
- totalRows,
- imageEnhanced,
- pdfConverted,
+ bestRotation: pageRotations[0]?.optimalRotation || 0,
+ totalTables: totalTablesFiltered,
+ totalRows: totalRowsFiltered,
+ imageEnhanced: fileformat === 'image',
+ pdfConverted: fileformat === 'pdf',
success: true,
- warnings: warnings.length > 0 ? warnings : null,
+ warnings: warnings.length ? warnings : null,
};
- const [session] = await tx.insert(ocrSessions).values(sessionData).returning({ id: ocrSessions.id });
- const sessionId = session.id;
+ const [sessionTable] = await tx.insert(ocrSessions)
+ .values(sessionData)
+ .returning({ id: ocrSessions.id });
- // 2. 테이블들 저장
- const tableIds: string[] = [];
- for (let tableIndex = 0; tableIndex < finalTables.length; tableIndex++) {
- const table = finalTables[tableIndex];
-
- const tableData: NewOcrTable = {
+ const sessionId = sessionTable.id;
+
+ // 3단계: 테이블 & 행 저장
+ for (let tableIndex = 0; tableIndex < filteredTables.length; tableIndex++) {
+ const table = filteredTables[tableIndex];
+
+ const [savedTable] = await tx.insert(ocrTables).values({
sessionId,
tableIndex,
rowCount: table.length,
- };
-
- const [savedTable] = await tx.insert(ocrTables).values(tableData).returning({ id: ocrTables.id });
- tableIds.push(savedTable.id);
+ }).returning({ id: ocrTables.id });
- // 3. 각 테이블의 행들 저장
if (table.length > 0) {
const rowsData: NewOcrRow[] = table.map((row, rowIndex) => ({
tableId: savedTable.id,
sessionId,
rowIndex,
- reportNo: row.reportNo || null,
- no: row.no || null,
- identificationNo: row.identificationNo || null,
- tagNo: row.tagNo || null,
- jointNo: row.jointNo || null,
- jointType: row.jointType || null,
- weldingDate: row.weldingDate || null,
+ reportNo: row.reportNo ?? null,
+ no: row.no ?? null,
+ identificationNo: row.identificationNo ?? null,
+ tagNo: row.tagNo ?? null,
+ jointNo: row.jointNo ?? null,
+ jointType: row.jointType ?? null,
+ weldingDate: row.weldingDate ?? null,
confidence: row.confidence.toString(),
sourceTable: row.sourceTable,
sourceRow: row.sourceRow,
+ userId: userId.toString()
}));
- await tx.insert(ocrRows).values(rowsData);
+ console.log(`💾 Inserting ${rowsData.length} rows for table ${tableIndex}`);
+
+ await tx.insert(ocrRows)
+ .values(rowsData)
+ .onConflictDoUpdate({
+ target: [ocrRows.reportNo, ocrRows.no,ocrRows.no,ocrRows.tagNo,ocrRows.jointNo,ocrRows.jointType], // 🚀 변경: 두 컬럼만!
+ set: {
+ identificationNo: sql`EXCLUDED.identification_no`,
+ tagNo: sql`EXCLUDED.tag_no`,
+ jointNo: sql`EXCLUDED.joint_no`,
+ jointType: sql`EXCLUDED.joint_type`,
+ weldingDate: sql`EXCLUDED.welding_date`,
+ confidence: sql`EXCLUDED.confidence`,
+ sourceTable: sql`EXCLUDED.source_table`,
+ sourceRow: sql`EXCLUDED.source_row`,
+ userId: sql`EXCLUDED.user_id`,
+ },
+ });
+
+ console.log(`✅ Successfully saved/updated ${rowsData.length} rows for table ${tableIndex}`);
}
}
- // 4. 회전 시도 결과들 저장
- const rotationAttemptsData: NewOcrRotationAttempt[] = rotationResults.map((result) => {
- const extractedRowsCount = result.extractedTables?.reduce((sum, table) => sum + table.length, 0) || 0;
-
- return {
- sessionId,
- rotation: result.rotation,
- confidence: result.confidence.toString(),
- tablesFound: result.tablesFound,
- textQuality: result.textQuality.toString(),
- keywordCount: result.keywordCount,
- score: result.score?.toString() || '0',
- extractedRowsCount,
- };
- });
+ // 4단계: 최적화된 회전 시도 결과 저장
+ const rotationAttemptsData: NewOcrRotationAttempt[] = pageRotations.map((pageInfo) => ({
+ sessionId,
+ rotation: pageInfo.optimalRotation,
+ confidence: pageInfo.confidence.toString(),
+ tablesFound: 0, // 통합 처리로 인해 개별 페이지별 테이블 수는 미상
+ textQuality: pageInfo.confidence.toString(),
+ keywordCount: 0,
+ score: pageInfo.confidence.toString(),
+ extractedRowsCount: 0,
+ }));
if (rotationAttemptsData.length > 0) {
await tx.insert(ocrRotationAttempts).values(rotationAttemptsData);
}
- console.log(`✅ Successfully saved session ${sessionId} with ${finalTables.length} tables and ${totalRows} rows`);
+ console.log(`✅ Successfully saved optimized session ${sessionId} with ${totalTablesFiltered} tables and ${totalRowsFiltered} rows (${totalApiCalls} API calls)`);
return sessionId;
});
}
@@ -314,7 +807,7 @@ async function saveErrorToDatabase({
processingTime: number;
error: string;
}): Promise<string> {
-
+
const sessionData: NewOcrSession = {
fileName: file.name,
fileSize: file.size,
@@ -337,241 +830,150 @@ async function saveErrorToDatabase({
// OCR 결과에서 Report No 추출
function extractReportNo(ocrResult: any): string {
try {
- console.log('📊 Extracting Report No from OCR result...');
-
- // OCR 결과에서 tables와 fields 모두 확인
- const allTexts: string[] = [];
-
- // tables에서 텍스트 추출
- if (ocrResult.images?.[0]?.tables) {
- for (const table of ocrResult.images[0].tables) {
- if (table.cells) {
- for (const cell of table.cells) {
- if (cell.cellTextLines) {
- for (const textLine of cell.cellTextLines) {
- if (textLine.cellWords) {
- for (const word of textLine.cellWords) {
- if (word.inferText) {
- allTexts.push(word.inferText.trim());
- }
- }
- }
- }
- }
- }
- }
- }
- }
-
- // fields에서 텍스트 추출
- if (ocrResult.images?.[0]?.fields) {
- for (const field of ocrResult.images[0].fields) {
- if (field.inferText) {
- allTexts.push(field.inferText.trim());
- }
- }
- }
-
- // Report No. 패턴을 찾기
- let reportNo = '';
- let foundReportNoLabel = false;
-
- for (let i = 0; i < allTexts.length; i++) {
- const text = allTexts[i];
-
- // "Report", "No.", "Report No." 등의 패턴 찾기
- if (text.toLowerCase().includes('report') &&
- (text.toLowerCase().includes('no') ||
- (i + 1 < allTexts.length && allTexts[i + 1].toLowerCase().includes('no')))) {
- foundReportNoLabel = true;
- console.log(`📋 Found Report No label at index ${i}: "${text}"`);
- continue;
- }
-
- // Report No 라벨 다음에 오는 값이나 특정 패턴 (예: SN2661FT20250526) 찾기
- if (foundReportNoLabel || /^[A-Z]{2}\d{4}[A-Z]{2}\d{8}$/.test(text)) {
- // Report No 형식 패턴 체크 (예: SN2661FT20250526)
- if (/^[A-Z]{2}\d{4}[A-Z]{2}\d{8}$/.test(text) ||
- /^[A-Z0-9]{10,20}$/.test(text)) {
- reportNo = text;
- console.log(`✅ Found Report No: "${reportNo}"`);
- break;
- }
- }
- }
-
- // 패턴이 없으면 더 관대한 검색
- if (!reportNo) {
- // Report나 No 근처의 영숫자 조합 찾기
- for (let i = 0; i < allTexts.length; i++) {
- const text = allTexts[i];
- if ((text.toLowerCase().includes('report') || text.toLowerCase().includes('no')) &&
- i + 1 < allTexts.length) {
- const nextText = allTexts[i + 1];
- if (/^[A-Z0-9]{6,}$/.test(nextText)) {
- reportNo = nextText;
- console.log(`✅ Found Report No (fallback): "${reportNo}"`);
- break;
- }
- }
- }
- }
-
- // 기본값 설정
- if (!reportNo) {
- console.warn('⚠️ Could not extract Report No, using default');
- reportNo = 'UNKNOWN';
- }
-
- return reportNo;
-
- } catch (error) {
- console.error('❌ Error extracting Report No:', error);
- return 'UNKNOWN';
- }
-}
+ const table = ocrResult.images?.[0]?.tables?.[0];
+ if (!table?.cells?.length) return 'UNKNOWN';
-// 여러 회전 각도 테스트
-async function tryMultipleRotations(base64: string, filename: string): Promise<(RotationTestResult & { score?: number })[]> {
- const rotations = [0, 90, 180, 270];
- const results: RotationTestResult[] = [];
+ const target = table.cells.find(
+ (c: any) => c.rowIndex === 0 && c.columnIndex === 3
+ );
+ if (!target) return 'UNKNOWN';
- console.log('🔍 Testing rotations with server-side image processing...');
+ const reportNo = cellText(target).replace(/\s+/g, '');
+ return reportNo || 'UNKNOWN';
- for (const rotation of rotations) {
- try {
- console.log(` Testing ${rotation}° rotation...`);
-
- // 🔄 서버 사이드에서 실제 이미지 회전
- const rotatedBase64 = await rotateImageBase64(base64, rotation);
+ } catch (e) {
+ console.error('extractReportNo 오류:', e);
+ return 'UNKNOWN';
+ }
- // OCR API 호출
- const ocrResult = await callOCRAPI(rotatedBase64, 'jpg', `${rotation}_${filename}`);
+ function cellText(cell: any): string {
+ return (cell.cellTextLines ?? [])
+ .flatMap((l: any) =>
+ (l.cellWords ?? []).map((w: any) => (w.inferText ?? '').trim())
+ )
+ .join(' ');
+ }
+}
- console.log(ocrResult)
-
- // Report No 추출
- const reportNo = extractReportNo(ocrResult);
- console.log(` ${rotation}°: Report No = "${reportNo}"`);
-
- const analysis = analyzeOCRQuality(ocrResult);
- const rawTables = await extractTablesFromOCR(ocrResult) as BaseExtractedRow[][]; // 기존 함수 호출
-
- // reportNo를 각 행에 추가
- const extractedTables: ExtractedRow[][] = rawTables.map(table =>
- table.map(row => ({
- ...row,
- reportNo // reportNo 필드 추가
- }))
- );
-
- const result: RotationTestResult = {
- rotation,
- confidence: analysis.confidence,
- tablesFound: analysis.tablesFound,
- textQuality: analysis.textQuality,
- keywordCount: analysis.keywordCount,
- extractedTables
- };
+// OCR API 호출 (기존과 동일)
+async function callOCRAPI(base64: string, format: string, filename: string, rotation?: number): Promise<any> {
+ console.log('🌐 === OCR API CALL DEBUG ===');
+ console.log(`📁 Filename: ${filename}`);
+ console.log(`🔄 Rotation: ${rotation || 0}°`);
+ console.log(`📊 Format: ${format}`);
+
+ const validation = validateBase64Image(base64);
+ if (!validation.isValid) {
+ throw new Error(`Invalid base64 data: ${validation.error}`);
+ }
- results.push(result);
-
- const extractedRows = extractedTables.reduce((sum, table) => sum + table.length, 0);
- console.log(` ${rotation}°: confidence=${(analysis.confidence * 100).toFixed(1)}%, tables=${analysis.tablesFound}, quality=${(analysis.textQuality * 100).toFixed(1)}%, keywords=${analysis.keywordCount}, rows=${extractedRows}`);
-
- } catch (error) {
- console.warn(` ${rotation}°: Failed -`, error instanceof Error ? error.message : 'Unknown error');
- results.push({
- rotation,
- confidence: 0,
- tablesFound: 0,
- textQuality: 0,
- keywordCount: 0,
- extractedTables: []
- });
- }
+ console.log(`✅ Base64 validation passed: ${validation.size} bytes`);
+
+ if (validation.size! > 52428800) {
+ throw new Error(`Image too large: ${validation.size} bytes (max: 50MB)`);
}
- return results.map(result => ({ ...result, score: 0 })); // score는 findBestRotation에서 계산
-}
+ if (validation.size! < 1000) {
+ throw new Error(`Image too small: ${validation.size} bytes (min: 1KB)`);
+ }
-// 최적 회전 각도 찾기
-function findBestRotation(results: (RotationTestResult & { score?: number })[]): RotationTestResult & { score?: number } {
- console.log('🎯 Analyzing rotation results...');
-
- const scoredResults = results.map(result => {
- const extractedRows = result.extractedTables?.reduce((sum, table) => sum + table.length, 0) || 0;
-
- // 개선된 점수 계산 알고리즘
- let score = 0;
-
- // 1. OCR 신뢰도 (25%)
- score += result.confidence * 0.25;
-
- // 2. 발견된 테이블 수 (25%)
- score += Math.min(result.tablesFound / 3, 1) * 0.25; // 최대 3개 테이블까지 점수
-
- // 3. 텍스트 품질 (20%)
- score += result.textQuality * 0.20;
-
- // 4. 키워드 개수 (15%)
- score += Math.min(result.keywordCount / 10, 1) * 0.15; // 최대 10개 키워드까지 점수
-
- // 5. 추출된 행 수 (15%)
- score += Math.min(extractedRows / 20, 1) * 0.15; // 최대 20행까지 점수
-
- // 보너스: 실제 데이터가 추출된 경우
- if (extractedRows > 0) {
- score += 0.1; // 10% 보너스
- }
-
- console.log(` ${result.rotation}°: score=${score.toFixed(3)} (conf=${(result.confidence * 100).toFixed(1)}%, tables=${result.tablesFound}, quality=${(result.textQuality * 100).toFixed(1)}%, keywords=${result.keywordCount}, rows=${extractedRows})`);
-
- return { ...result, score };
- });
+ const ocrSecretKey = process.env.OCR_SECRET_KEY;
+ if (!ocrSecretKey) {
+ throw new Error('OCR_SECRET_KEY environment variable is not set');
+ }
- const bestResult = scoredResults.reduce((best, current) =>
- current.score > best.score ? current : best
- );
+ let processedBase64: string;
+ try {
+ console.log('🔧 Normalizing image format...');
+ processedBase64 = await normalizeImageFormat(base64);
+ console.log('✅ Image format normalized to JPEG');
+ } catch (error) {
+ console.warn('⚠️ Format normalization failed, using original:', error);
+ processedBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, '');
+ }
- console.log(`🏆 Winner: ${bestResult.rotation}° with score ${bestResult.score?.toFixed(3)}`);
-
- return bestResult;
-}
+ const requestId = uuidv4();
+ const timestamp = Math.floor(Date.now() / 1000);
-// OCR API 호출
-async function callOCRAPI(base64: string, format: string, filename: string, rotation?: number): Promise<any> {
const ocrBody = {
version: "V2",
- requestId: uuidv4(),
- timestamp: Math.floor(Date.now() / 1000),
+ requestId,
+ timestamp,
lang: "ko",
images: [{
- format,
+ format: "jpg",
url: null,
- data: base64,
+ data: processedBase64,
name: filename,
...(rotation !== undefined && rotation !== 0 && { rotation })
}],
enableTableDetection: true
};
- const response = await fetch(
- "https://n4a9z5rb5r.apigw.ntruss.com/custom/v1/35937/41dcc5202686a37ee5f4fae85bd07f599e3627e9c00d33bf6e469f409fe0ca62/general",
- {
+ console.log('📤 Request details:', {
+ requestId,
+ timestamp,
+ dataSize: processedBase64.length,
+ enableTableDetection: ocrBody.enableTableDetection,
+ hasRotation: rotation !== undefined && rotation !== 0
+ });
+
+ const apiUrl = "https://n4a9z5rb5r.apigw.ntruss.com/custom/v1/35937/41dcc5202686a37ee5f4fae85bd07f599e3627e9c00d33bf6e469f409fe0ca62/general";
+
+ try {
+ console.log('🚀 Sending OCR request...');
+
+ const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-OCR-SECRET': process.env.OCR_SECRET_KEY || '',
+ 'X-OCR-SECRET': ocrSecretKey,
},
body: JSON.stringify(ocrBody),
- signal: AbortSignal.timeout(60000) // 60초 타임아웃
+ signal: AbortSignal.timeout(60000)
+ });
+
+ console.log(`📨 Response: ${response.status} ${response.statusText}`);
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error(`❌ OCR API Error: ${errorText}`);
+
+ let parsedError;
+ try {
+ parsedError = JSON.parse(errorText);
+ } catch {
+ parsedError = { message: errorText };
+ }
+
+ if (parsedError.code === "0011") {
+ throw new Error(`Image data size error: ${parsedError.message} (actual size: ${validation.size} bytes)`);
+ } else if (response.status === 401) {
+ throw new Error('Authentication failed - check OCR_SECRET_KEY');
+ } else if (response.status === 403) {
+ throw new Error('Access forbidden - check API permissions');
+ } else if (response.status === 429) {
+ throw new Error('Rate limit exceeded - too many requests');
+ }
+
+ throw new Error(`OCR API error: ${response.status} ${response.statusText} - ${errorText}`);
}
- );
- if (!response.ok) {
- throw new Error(`OCR API error: ${response.status} ${response.statusText}`);
- }
+ const result = await response.json();
+ console.log('✅ OCR API Success!');
+ console.log('📊 Response structure:', {
+ hasImages: !!result.images,
+ imageCount: result.images?.length || 0,
+ firstImageHasTables: !!result.images?.[0]?.tables,
+ firstImageTableCount: result.images?.[0]?.tables?.length || 0,
+ firstImageHasFields: !!result.images?.[0]?.fields,
+ firstImageFieldCount: result.images?.[0]?.fields?.length || 0
+ });
+
+ return result;
- return response.json();
+ } catch (error) {
+ console.error('💥 OCR API Call Failed:', error);
+ throw error;
+ }
} \ No newline at end of file
diff --git a/app/api/ocr/utils/imageRotation.ts b/app/api/ocr/utils/imageRotation.ts
index fe9cf840..6d59dace 100644
--- a/app/api/ocr/utils/imageRotation.ts
+++ b/app/api/ocr/utils/imageRotation.ts
@@ -1,244 +1,427 @@
+// ============================================================================
// app/api/ocr/utils/imageRotation.ts
-// Sharp을 사용한 서버 사이드 이미지 회전
+// PDF 페이지 처리 기능이 추가된 이미지 회전 유틸리티
+// ============================================================================
import sharp from 'sharp';
+import { promises as fs } from 'fs';
+import path from 'path';
+import { randomUUID } from 'crypto';
+import { execFile } from 'child_process';
+import { promisify } from 'util';
+import { tmpdir } from 'os';
+
+const exec = promisify(execFile);
/**
- * 서버 사이드에서 이미지를 회전시킵니다
- * @param base64 - base64 인코딩된 이미지 데이터
- * @param degrees - 회전 각도 (0, 90, 180, 270)
- * @returns Promise<string> - 회전된 이미지의 base64 데이터
+ * PDF 페이지 수를 확인하는 함수
*/
-export async function rotateImageBase64(base64: string, degrees: number): Promise<string> {
+export async function getPDFPageCount(pdfBuffer: Buffer): Promise<number> {
+ const tmp = tmpdir();
+ const id = randomUUID();
+ const pdfPath = path.join(tmp, `${id}.pdf`);
+
try {
- console.log(`🔄 Rotating image by ${degrees} degrees...`);
+ await fs.writeFile(pdfPath, pdfBuffer);
- // base64를 Buffer로 변환
- const inputBuffer = Buffer.from(base64, 'base64');
+ // pdfinfo 명령어로 페이지 수 확인
+ const { stdout } = await exec('pdfinfo', [pdfPath]);
+ const pageMatch = stdout.match(/Pages:\s+(\d+)/);
+ const pageCount = pageMatch ? parseInt(pageMatch[1]) : 1;
- // 회전 각도에 따른 처리
- let rotatedBuffer: Buffer;
-
- switch (degrees) {
- case 0:
- // 회전 없음
- rotatedBuffer = inputBuffer;
- break;
-
- case 90:
- rotatedBuffer = await sharp(inputBuffer)
- .rotate(90)
- .jpeg({
- quality: 90,
- progressive: true
- })
- .toBuffer();
- break;
-
- case 180:
- rotatedBuffer = await sharp(inputBuffer)
- .rotate(180)
- .jpeg({
- quality: 90,
- progressive: true
- })
- .toBuffer();
- break;
-
- case 270:
- rotatedBuffer = await sharp(inputBuffer)
- .rotate(270)
- .jpeg({
- quality: 90,
- progressive: true
- })
- .toBuffer();
- break;
-
- default:
- console.warn(`⚠️ Unsupported rotation angle: ${degrees}°. Using original image.`);
- rotatedBuffer = inputBuffer;
- }
-
- // Buffer를 다시 base64로 변환
- const rotatedBase64 = rotatedBuffer.toString('base64');
-
- console.log(`✅ Image rotated successfully (${degrees}°)`);
- return rotatedBase64;
+ console.log(`📄 PDF has ${pageCount} pages`);
+ return pageCount;
} catch (error) {
- console.error(`❌ Error rotating image by ${degrees}°:`, error);
- console.warn('Using original image due to rotation error');
- return base64; // 실패시 원본 반환
+ console.warn('❌ Could not get PDF page count, trying alternative method:', error);
+
+ // pdfinfo가 실패하면 pdftoppm으로 테스트
+ try {
+ await exec('pdftoppm', ['-l', '1', '-null', pdfPath]);
+ return 1; // 최소 1페이지는 있음
+ } catch {
+ console.warn('⚠️ Could not determine page count, assuming 1 page');
+ return 1;
+ }
+ } finally {
+ await fs.rm(pdfPath, { force: true }).catch(() => {});
}
}
/**
- * 이미지 품질을 개선합니다
- * @param base64 - base64 인코딩된 이미지 데이터
- * @returns Promise<string> - 개선된 이미지의 base64 데이터
+ * base64 데이터 유효성 검증
*/
-export async function enhanceImageQuality(base64: string): Promise<string> {
- try {
- console.log('🎨 Enhancing image quality...');
-
- const inputBuffer = Buffer.from(base64, 'base64');
-
- const enhancedBuffer = await sharp(inputBuffer)
- .resize(2000, 2000, {
- fit: 'inside',
- withoutEnlargement: true
- })
- // 개별 매개변수 방식으로 수정
- .sharpen(1, 1, 2) // sigma, m1(flat), m2(jagged)
- .normalize() // 히스토그램 정규화
- .gamma(1.1) // 약간의 감마 보정
- .jpeg({
- quality: 95,
- progressive: true,
- mozjpeg: true
- })
- .toBuffer();
-
- const enhancedBase64 = enhancedBuffer.toString('base64');
-
- console.log('✅ Image quality enhanced');
- return enhancedBase64;
-
- } catch (error) {
- console.error('❌ Error enhancing image:', error);
- return base64;
- }
+export function validateBase64Image(base64: string): { isValid: boolean; error?: string; size?: number } {
+ try {
+ if (!base64 || typeof base64 !== 'string') {
+ return { isValid: false, error: 'Base64 data is empty or invalid type' };
+ }
+
+ const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, '');
+
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(cleanBase64)) {
+ return { isValid: false, error: 'Invalid base64 format' };
+ }
+
+ if (cleanBase64.length < 100) {
+ return { isValid: false, error: `Base64 too short: ${cleanBase64.length} characters` };
+ }
+
+ const buffer = Buffer.from(cleanBase64, 'base64');
+ const bufferSize = buffer.length;
+
+ if (bufferSize < 1) {
+ return { isValid: false, error: `Buffer too small: ${bufferSize} bytes` };
+ }
+
+ if (bufferSize > 52428800) { // 50MB
+ return { isValid: false, error: `Buffer too large: ${bufferSize} bytes (max: 50MB)` };
+ }
+
+ return { isValid: true, size: bufferSize };
+
+ } catch (error) {
+ return {
+ isValid: false,
+ error: `Base64 validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
+ };
+ }
}
/**
- * PDF를 고품질 이미지로 변환합니다
- * @param pdfBuffer - PDF Buffer 데이터
- * @param pageIndex - 변환할 페이지 인덱스 (0부터 시작)
- * @returns Promise<string> - 변환된 이미지의 base64 데이터
+ * 서버 사이드에서 이미지를 회전시킵니다
*/
-export async function convertPDFToImage(pdfBuffer: Buffer, pageIndex: number = 0): Promise<string> {
- try {
- console.log(`📄 Converting PDF page ${pageIndex + 1} to image...`);
-
- // pdf2pic 라이브러리 사용
- const pdf2pic = require('pdf2pic');
-
- const convert = pdf2pic.fromBuffer(pdfBuffer, {
- density: 300, // 300 DPI for high quality
- saveFilename: "page",
- savePath: "/tmp", // 임시 경로
- format: "jpeg",
- width: 2480, // A4 크기 @ 300 DPI
- height: 3508,
- quality: 100
- });
-
- const result = await convert(pageIndex + 1, { responseType: "buffer" });
- const base64 = result.buffer.toString('base64');
-
- console.log('✅ PDF converted to image successfully');
- return base64;
-
- } catch (error) {
- console.error('❌ Error converting PDF to image:', error);
- throw new Error('Failed to convert PDF to image');
- }
+export async function rotateImageBase64(base64: string, degrees: number): Promise<string> {
+ try {
+ console.log(`🔄 === ROTATING IMAGE BY ${degrees}° ===`);
+
+ const validation = validateBase64Image(base64);
+ if (!validation.isValid) {
+ throw new Error(`Invalid input base64: ${validation.error}`);
+ }
+
+ console.log(`✅ Input validation passed - size: ${validation.size} bytes`);
+
+ const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, '');
+ const inputBuffer = Buffer.from(cleanBase64, 'base64');
+
+ console.log(`📊 Input buffer created: ${inputBuffer.length} bytes`);
+
+ const normalizedDegrees = ((degrees % 360) + 360) % 360;
+ console.log(`📐 Normalized rotation: ${normalizedDegrees}°`);
+
+ let rotatedBuffer: Buffer;
+
+ if (normalizedDegrees === 0) {
+ console.log(' ↻ No rotation needed, applying quality enhancement...');
+ rotatedBuffer = await sharp(inputBuffer)
+ .jpeg({
+ quality: 90,
+ progressive: true,
+ mozjpeg: true
+ })
+ .toBuffer();
+ } else {
+ console.log(` 🔄 Applying ${normalizedDegrees}° rotation...`);
+
+ const sharpInstance = sharp(inputBuffer);
+ const metadata = await sharpInstance.metadata();
+ console.log(` 📏 Original image: ${metadata.width}x${metadata.height}, format: ${metadata.format}`);
+
+ rotatedBuffer = await sharpInstance
+ .rotate(normalizedDegrees)
+ .jpeg({
+ quality: 90,
+ progressive: true,
+ mozjpeg: true
+ })
+ .toBuffer();
+ }
+
+ console.log(`📊 Output buffer created: ${rotatedBuffer.length} bytes`);
+
+ if (rotatedBuffer.length === 0) {
+ throw new Error('Rotation resulted in empty buffer');
+ }
+
+ if (rotatedBuffer.length > 52428800) {
+ throw new Error(`Rotated image too large: ${rotatedBuffer.length} bytes`);
+ }
+
+ const rotatedBase64 = rotatedBuffer.toString('base64');
+
+ const outputValidation = validateBase64Image(rotatedBase64);
+ if (!outputValidation.isValid) {
+ throw new Error(`Invalid output base64: ${outputValidation.error}`);
+ }
+
+ console.log(`✅ Image rotated successfully: ${outputValidation.size} bytes`);
+ console.log(`📈 Size change: ${inputBuffer.length} → ${outputValidation.size} bytes`);
+
+ return rotatedBase64;
+
+ } catch (error) {
+ console.error(`❌ Error rotating image by ${degrees}°:`, error);
+
+ if (error instanceof Error) {
+ if (error.message.includes('Input buffer contains unsupported image format')) {
+ console.error(' 🖼️ Unsupported image format - try converting to JPEG first');
+ } else if (error.message.includes('Input image exceeds pixel limit')) {
+ console.error(' 📏 Image too large for processing');
+ } else if (error.message.includes('premature close')) {
+ console.error(' 🔧 Corrupted image data');
+ }
+ }
+
+ const originalValidation = validateBase64Image(base64);
+ if (originalValidation.isValid) {
+ console.warn(' ↩️ Using original image due to rotation error');
+ return base64;
+ } else {
+ throw new Error(`Rotation failed and original image is invalid: ${originalValidation.error}`);
+ }
+ }
}
/**
- * 이미지에서 텍스트 방향을 감지합니다
- * @param base64 - base64 인코딩된 이미지 데이터
- * @returns Promise<number> - 감지된 올바른 회전 각도
+ * 이미지 품질을 개선합니다
*/
-export async function detectTextOrientation(base64: string): Promise<number> {
- // 이 함수는 간단한 방향 감지를 시뮬레이션합니다
- // 실제로는 더 정교한 알고리즘이 필요할 수 있습니다
-
- console.log('🧭 Detecting text orientation...');
-
- const rotations = [0, 90, 180, 270];
- const scores: { rotation: number; score: number }[] = [];
-
- for (const rotation of rotations) {
+export async function enhanceImageQuality(base64: string): Promise<string> {
try {
- const rotatedBase64 = await rotateImageBase64(base64, rotation);
-
- // 간단한 품질 측정 (실제로는 OCR API 호출이나 다른 방법 사용)
- const score = await estimateTextQuality(rotatedBase64);
- scores.push({ rotation, score });
-
- console.log(` ${rotation}°: quality score = ${score.toFixed(3)}`);
-
+ console.log('🎨 === ENHANCING IMAGE QUALITY ===');
+
+ const validation = validateBase64Image(base64);
+ if (!validation.isValid) {
+ console.warn(`⚠️ Invalid input for enhancement: ${validation.error}`);
+ return base64;
+ }
+
+ console.log(`✅ Enhancement input valid: ${validation.size} bytes`);
+
+ const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, '');
+ const inputBuffer = Buffer.from(cleanBase64, 'base64');
+
+ const sharpInstance = sharp(inputBuffer);
+ const metadata = await sharpInstance.metadata();
+
+ console.log(`📏 Original: ${metadata.width}x${metadata.height}, ${metadata.format}`);
+
+ const maxDimension = 2000;
+ let needsResize = false;
+
+ if (metadata.width && metadata.height) {
+ needsResize = metadata.width > maxDimension || metadata.height > maxDimension;
+ }
+
+ let enhancedBuffer: Buffer;
+
+ if (needsResize) {
+ console.log(`📐 Resizing to fit ${maxDimension}px...`);
+ enhancedBuffer = await sharpInstance
+ .resize(maxDimension, maxDimension, {
+ fit: 'inside',
+ withoutEnlargement: true
+ })
+ .sharpen(0.5, 1, 2)
+ .normalize()
+ .gamma(1.1)
+ .jpeg({
+ quality: 95,
+ progressive: true,
+ mozjpeg: true
+ })
+ .toBuffer();
+ } else {
+ console.log('📐 No resize needed, applying enhancement only...');
+ enhancedBuffer = await sharpInstance
+ .sharpen(0.5, 1, 2)
+ .normalize()
+ .gamma(1.1)
+ .jpeg({
+ quality: 95,
+ progressive: true,
+ mozjpeg: true
+ })
+ .toBuffer();
+ }
+
+ const enhancedBase64 = enhancedBuffer.toString('base64');
+
+ const outputValidation = validateBase64Image(enhancedBase64);
+ if (!outputValidation.isValid) {
+ console.warn(`⚠️ Enhancement resulted in invalid image: ${outputValidation.error}`);
+ return base64;
+ }
+
+ console.log(`✅ Image enhanced: ${validation.size} → ${outputValidation.size} bytes`);
+ return enhancedBase64;
+
} catch (error) {
- console.warn(` ${rotation}°: Failed to test orientation`);
- scores.push({ rotation, score: 0 });
+ console.error('❌ Error enhancing image:', error);
+ return base64;
}
- }
-
- const bestOrientation = scores.reduce((best, current) =>
- current.score > best.score ? current : best
- );
-
- console.log(`🎯 Best orientation detected: ${bestOrientation.rotation}°`);
- return bestOrientation.rotation;
}
/**
- * 이미지의 텍스트 품질을 추정합니다 (간단한 버전)
+ * PDF를 이미지로 변환합니다 (개선된 버전)
*/
-async function estimateTextQuality(base64: string): Promise<number> {
+export async function convertPDFToImage(
+ pdfBuffer: Buffer,
+ pageIndex = 0,
+ dpi = 300,
+): Promise<string> {
+ const tmp = tmpdir();
+ const id = randomUUID();
+ const pdfPath = path.join(tmp, `${id}.pdf`);
+ const outPrefix = path.join(tmp, id);
+ const jpgPath = `${outPrefix}.jpg`;
+
try {
- const buffer = Buffer.from(base64, 'base64');
+ console.log(`📄 Converting PDF page ${pageIndex + 1} to image (${dpi} DPI)...`);
- // Sharp을 사용해 이미지 통계 분석
- const stats = await sharp(buffer)
- .greyscale()
- .stats();
-
- // 간단한 품질 지표 계산
- // 실제로는 더 복잡한 알고리즘이 필요합니다
- const contrast = stats.channels[0].max - stats.channels[0].min;
- const sharpness = stats.channels[0].stdev;
+ // 1) PDF 임시 저장
+ await fs.writeFile(pdfPath, pdfBuffer);
+
+ // 2) pdftoppm 실행
+ const page = pageIndex + 1; // pdftoppm은 1-based
+ await exec('pdftoppm', [
+ '-jpeg',
+ '-singlefile',
+ '-r', dpi.toString(),
+ '-f', page.toString(),
+ '-l', page.toString(),
+ pdfPath,
+ outPrefix,
+ ], { maxBuffer: 1024 * 1024 * 50 }); // 50MB 버퍼
+
+ // 3) 결과 읽어 base64 변환
+ const img = await fs.readFile(jpgPath);
+ const base64 = img.toString('base64');
- return (contrast + sharpness) / 510; // 0-1 범위로 정규화
+ console.log(`✅ PDF page ${pageIndex + 1} converted successfully: ${img.length} bytes`);
+ return base64;
+
} catch (error) {
- return 0;
+ console.error(`❌ Error converting PDF page ${pageIndex + 1}:`, error);
+ throw new Error(`Failed to convert PDF page ${pageIndex + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ } finally {
+ // 4) 임시 파일 정리
+ await fs.rm(pdfPath, { force: true }).catch(() => {});
+ await fs.rm(jpgPath, { force: true }).catch(() => {});
}
}
/**
- * 이미지가 회전이 필요한지 빠르게 체크합니다
- * @param base64 - base64 인코딩된 이미지 데이터
- * @returns Promise<boolean> - 회전이 필요하면 true
+ * 파일 형식을 JPEG로 정규화
*/
-export async function needsRotation(base64: string): Promise<boolean> {
- try {
- const buffer = Buffer.from(base64, 'base64');
-
- // 이미지 메타데이터 확인
- const metadata = await sharp(buffer).metadata();
-
- // EXIF 방향 정보가 있으면 회전 필요
- if (metadata.orientation && metadata.orientation > 1) {
- console.log(`📐 EXIF orientation detected: ${metadata.orientation}`);
- return true;
+export async function normalizeImageFormat(base64: string): Promise<string> {
+ try {
+ console.log('🔄 Normalizing image format to JPEG...');
+
+ const validation = validateBase64Image(base64);
+ if (!validation.isValid) {
+ throw new Error(`Cannot normalize invalid image: ${validation.error}`);
+ }
+
+ const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, '');
+ const inputBuffer = Buffer.from(cleanBase64, 'base64');
+
+ const normalizedBuffer = await sharp(inputBuffer)
+ .jpeg({
+ quality: 90,
+ progressive: true
+ })
+ .toBuffer();
+
+ const normalizedBase64 = normalizedBuffer.toString('base64');
+
+ const outputValidation = validateBase64Image(normalizedBase64);
+ if (!outputValidation.isValid) {
+ throw new Error(`Normalization failed: ${outputValidation.error}`);
+ }
+
+ console.log(`✅ Format normalized: ${validation.size} → ${outputValidation.size} bytes`);
+ return normalizedBase64;
+
+ } catch (error) {
+ console.error('❌ Error normalizing image format:', error);
+ throw error;
}
-
- // 가로/세로 비율 체크 (일반적으로 문서는 세로가 더 긺)
- if (metadata.width && metadata.height) {
- const aspectRatio = metadata.width / metadata.height;
- if (aspectRatio > 1.5) {
- console.log(`📐 Wide aspect ratio detected: ${aspectRatio.toFixed(2)}`);
- return true; // 너무 가로로 긴 이미지는 회전 필요할 가능성
- }
+}
+
+// 기존 함수들
+export async function detectTextOrientation(base64: string): Promise<number> {
+ console.log('🧭 Detecting text orientation...');
+
+ const rotations = [0, 90, 180, 270];
+ const scores: { rotation: number; score: number }[] = [];
+
+ for (const rotation of rotations) {
+ try {
+ const rotatedBase64 = await rotateImageBase64(base64, rotation);
+ const score = await estimateTextQuality(rotatedBase64);
+ scores.push({ rotation, score });
+
+ console.log(` ${rotation}°: quality score = ${score.toFixed(3)}`);
+
+ } catch (error) {
+ console.warn(` ${rotation}°: Failed to test orientation`);
+ scores.push({ rotation, score: 0 });
+ }
+ }
+
+ const bestOrientation = scores.reduce((best, current) =>
+ current.score > best.score ? current : best
+ );
+
+ console.log(`🎯 Best orientation detected: ${bestOrientation.rotation}°`);
+ return bestOrientation.rotation;
+}
+
+async function estimateTextQuality(base64: string): Promise<number> {
+ try {
+ const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, '');
+ const buffer = Buffer.from(cleanBase64, 'base64');
+
+ const stats = await sharp(buffer)
+ .greyscale()
+ .stats();
+
+ const contrast = stats.channels[0].max - stats.channels[0].min;
+ const sharpness = stats.channels[0].stdev;
+
+ return (contrast + sharpness) / 510;
+
+ } catch (error) {
+ return 0;
+ }
+}
+
+export async function needsRotation(base64: string): Promise<boolean> {
+ try {
+ const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, '');
+ const buffer = Buffer.from(cleanBase64, 'base64');
+
+ const metadata = await sharp(buffer).metadata();
+
+ if (metadata.orientation && metadata.orientation > 1) {
+ console.log(`📐 EXIF orientation detected: ${metadata.orientation}`);
+ return true;
+ }
+
+ if (metadata.width && metadata.height) {
+ const aspectRatio = metadata.width / metadata.height;
+ if (aspectRatio > 1.5) {
+ console.log(`📐 Wide aspect ratio detected: ${aspectRatio.toFixed(2)}`);
+ return true;
+ }
+ }
+
+ return false;
+
+ } catch (error) {
+ console.warn('Error checking if rotation needed:', error);
+ return false;
}
-
- return false;
-
- } catch (error) {
- console.warn('Error checking if rotation needed:', error);
- return false;
- }
} \ No newline at end of file
diff --git a/app/api/ocr/utils/tableExtraction.ts b/app/api/ocr/utils/tableExtraction.ts
index ea543f8e..720e5a5f 100644
--- a/app/api/ocr/utils/tableExtraction.ts
+++ b/app/api/ocr/utils/tableExtraction.ts
@@ -1,7 +1,11 @@
// app/api/ocr/utils/tableExtraction.ts
-// 완전한 테이블 추출 로직 구현
+// 개선된 완전 테이블 추출 로직 – Format‑1 식별번호 파싱 보강 & 중복 행 제거
-interface ExtractedRow {
+/* -------------------------------------------------------------------------- */
+/* 타입 */
+/* -------------------------------------------------------------------------- */
+
+export interface ExtractedRow {
no: string;
identificationNo: string;
tagNo: string;
@@ -41,516 +45,223 @@ interface ColumnMapping {
weldingDate: number;
}
-// 메인 테이블 추출 함수
-export async function extractTablesFromOCR(ocrResult: any): Promise<ExtractedRow[][]> {
- const extractedTables: ExtractedRow[][] = [];
- const warnings: string[] = [];
+/* -------------------------------------------------------------------------- */
+/* 메인 */
+/* -------------------------------------------------------------------------- */
- if (!ocrResult || !ocrResult.images) {
- console.warn('No OCR images found in result');
- return [];
- }
+export async function extractTablesFromOCR (ocrResult: any): Promise<ExtractedRow[][]> {
+ const tables: ExtractedRow[][] = [];
+ if (!ocrResult?.images) return tables;
- for (let imageIndex = 0; imageIndex < ocrResult.images.length; imageIndex++) {
- const image = ocrResult.images[imageIndex];
-
- if (!image.tables || image.tables.length === 0) {
- console.warn(`No tables found in image ${imageIndex}`);
- continue;
- }
+ ocrResult.images.forEach((image: any, imgIdx: number) => {
+ image.tables?.forEach((table: OCRTable, tblIdx: number) => {
+ if (!isRelevantTable(table)) return;
+ const rows = extractTableData(table, imgIdx, tblIdx);
+ if (rows.length) tables.push(rows);
+ });
+ });
+ return tables;
+}
- for (let tableIndex = 0; tableIndex < image.tables.length; tableIndex++) {
- const table = image.tables[tableIndex];
-
- try {
- if (isRelevantTable(table)) {
- const extractedRows = extractTableData(table, imageIndex, tableIndex);
-
- if (extractedRows.length > 0) {
- extractedTables.push(extractedRows);
- console.log(`Successfully extracted ${extractedRows.length} rows from table ${tableIndex + 1} in image ${imageIndex + 1}`);
- } else {
- console.warn(`Table ${tableIndex + 1} in image ${imageIndex + 1} was identified as relevant but no data could be extracted`);
- }
- } else {
- console.log(`Table ${tableIndex + 1} in image ${imageIndex + 1} is not relevant (no required headers found)`);
- }
- } catch (error) {
- console.error(`Error processing table ${tableIndex + 1} in image ${imageIndex + 1}:`, error);
- }
- }
- }
+/* -------------------------------------------------------------------------- */
+/* 관련 테이블 판별 */
+/* -------------------------------------------------------------------------- */
- console.log(`Total extracted tables: ${extractedTables.length}`);
- return extractedTables;
+function isRelevantTable (table: OCRTable): boolean {
+ const headers = table.cells.filter(c => c.rowIndex < 3).map(getCellText).join(' ').toLowerCase();
+ return /\bno\b|번호/.test(headers) && /identification|식별|ident|id/.test(headers);
}
-// 관련 테이블인지 확인
-function isRelevantTable(table: OCRTable): boolean {
- if (!table.cells || table.cells.length === 0) {
- return false;
- }
+/* -------------------------------------------------------------------------- */
+/* 표 해석 */
+/* -------------------------------------------------------------------------- */
- // 첫 3행에서 헤더 찾기
- const headerCells = table.cells.filter(cell => cell.rowIndex <= 2);
- const headerTexts = headerCells
- .map(cell => getCellText(cell).toLowerCase())
- .filter(text => text.length > 0);
-
- console.log('Header texts found:', headerTexts);
-
- // 필수 키워드 확인
- const hasNo = headerTexts.some(text =>
- text.includes('no.') ||
- text === 'no' ||
- text.includes('번호') ||
- text.match(/^no\.?$/i)
- );
-
- const hasIdentification = headerTexts.some(text =>
- text.includes('identification') ||
- text.includes('식별') ||
- text.includes('ident') ||
- text.includes('id')
- );
-
- // 테이블 품질 확인
- const hasMinimumCells = table.cells.length >= 6; // 최소 헤더 + 데이터
- const hasReasonableConfidence = table.inferConfidence >= 0.5; // 신뢰도 기준 완화
-
- const isRelevant = hasNo && hasIdentification && hasMinimumCells && hasReasonableConfidence;
-
- console.log(`Table relevance check: hasNo=${hasNo}, hasIdentification=${hasIdentification}, minCells=${hasMinimumCells}, confidence=${hasReasonableConfidence} => ${isRelevant}`);
-
- return isRelevant;
-}
+function extractTableData (table: OCRTable, imgIdx: number, tblIdx: number): ExtractedRow[] {
+ const grid = buildGrid(table);
+ const headerRowIdx = findHeaderRow(grid);
+ if (headerRowIdx === -1) return [];
-// 테이블 데이터 추출
-function extractTableData(table: OCRTable, imageIndex: number, tableIndex: number): ExtractedRow[] {
- console.log(`Processing table ${tableIndex + 1} in image ${imageIndex + 1}`);
-
- // 테이블 그리드 구축
- const tableGrid = buildTableGrid(table);
-
- if (tableGrid.length < 2) {
- console.warn('Table has less than 2 rows (need header + data)');
- return [];
- }
+ const format = detectFormat(grid[headerRowIdx]);
+ const mapping = mapColumns(grid[headerRowIdx]);
- console.log(`Table grid built: ${tableGrid.length} rows, ${tableGrid[0]?.length || 0} columns`);
+ const seen = new Set<string>();
+ const data: ExtractedRow[] = [];
- // 헤더 행 찾기
- const headerRowIndex = findHeaderRow(tableGrid);
- if (headerRowIndex === -1) {
- console.warn('No header row found');
- return [];
- }
+ for (let r = headerRowIdx + 1; r < grid.length; r++) {
+ const row = grid[r];
+ if (isBlankRow(row)) continue;
- console.log(`Header row found at index: ${headerRowIndex}`);
-
- // 테이블 형식 결정
- const headerRow = tableGrid[headerRowIndex];
- const tableFormat = determineTableFormat(headerRow);
- console.log(`Table format detected: ${tableFormat}`);
-
- // 컬럼 매핑 찾기
- const columnMapping = findColumnMapping(headerRow, tableFormat);
- console.log('Column mapping:', columnMapping);
-
- // 데이터 행 추출
- const dataRows: ExtractedRow[] = [];
-
- for (let i = headerRowIndex + 1; i < tableGrid.length; i++) {
- const row = tableGrid[i];
-
- if (row && row.length > 0 && !isEmptyRow(row)) {
- try {
- const extractedRow = extractRowData(row, tableFormat, columnMapping, imageIndex, tableIndex, i);
- if (extractedRow && isValidRow(extractedRow)) {
- dataRows.push(extractedRow);
- }
- } catch (error) {
- console.warn(`Error processing row ${i}:`, error);
- }
- }
+ const parsed = buildRow(row, format, mapping, tblIdx, r);
+ if (!parsed || !isValidRow(parsed)) continue;
+
+ const key = `${parsed.no}-${parsed.identificationNo}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ data.push(parsed);
}
-
- console.log(`Extracted ${dataRows.length} valid rows from table`);
- return dataRows;
+ return data;
}
-// 테이블 그리드 구축
-function buildTableGrid(table: OCRTable): string[][] {
- if (!table.cells || table.cells.length === 0) {
- return [];
- }
+/* -------------------------------------------------------------------------- */
+/* Grid & Header */
+/* -------------------------------------------------------------------------- */
+
+function buildGrid (table: OCRTable): string[][] {
+ const maxR = Math.max(...table.cells.map(c => c.rowIndex + c.rowSpan - 1));
+ const maxC = Math.max(...table.cells.map(c => c.columnIndex + c.columnSpan - 1));
+ const grid = Array.from({ length: maxR + 1 }, () => Array(maxC + 1).fill(''));
- const maxRow = Math.max(...table.cells.map(cell => cell.rowIndex + cell.rowSpan - 1)) + 1;
- const maxCol = Math.max(...table.cells.map(cell => cell.columnIndex + cell.columnSpan - 1)) + 1;
-
- const grid: string[][] = Array(maxRow).fill(null).map(() => Array(maxCol).fill(''));
-
- // 셀 내용으로 그리드 채우기
table.cells.forEach(cell => {
- const text = getCellText(cell);
-
+ const txt = getCellText(cell);
for (let r = cell.rowIndex; r < cell.rowIndex + cell.rowSpan; r++) {
for (let c = cell.columnIndex; c < cell.columnIndex + cell.columnSpan; c++) {
- if (grid[r] && grid[r][c] !== undefined) {
- // 기존 텍스트가 있으면 결합
- grid[r][c] = grid[r][c] ? `${grid[r][c]} ${text}`.trim() : text;
- }
+ grid[r][c] = grid[r][c] ? `${grid[r][c]} ${txt}` : txt;
}
}
});
-
return grid;
}
-// 셀 텍스트 추출
-function getCellText(cell: TableCell): string {
- if (!cell.cellTextLines || cell.cellTextLines.length === 0) {
- return '';
- }
-
- return cell.cellTextLines
- .map(line =>
- line.cellWords
- .map(word => word.inferText || '')
- .join(' ')
- )
- .join('\n')
- .trim();
+function getCellText (cell: TableCell): string {
+ return cell.cellTextLines?.flatMap(l => l.cellWords.map(w => w.inferText.trim())).filter(Boolean).join(' ') ?? '';
}
-// 헤더 행 찾기
-function findHeaderRow(tableGrid: string[][]): number {
- for (let i = 0; i < Math.min(3, tableGrid.length); i++) {
- const row = tableGrid[i];
- const rowText = row.join(' ').toLowerCase();
-
- console.log(`Checking row ${i}: "${rowText}"`);
-
- const hasNo = rowText.includes('no.') || rowText.includes('번호') || /\bno\b/.test(rowText);
- const hasIdent = rowText.includes('identification') || rowText.includes('식별') || rowText.includes('ident');
-
- if (hasNo && hasIdent) {
- console.log(`Header row found at ${i}`);
- return i;
- }
+function findHeaderRow (grid: string[][]): number {
+ for (let i = 0; i < Math.min(3, grid.length); i++) {
+ const t = grid[i].join(' ').toLowerCase();
+ if (/\bno\b|번호/.test(t) && /identification|식별|ident/.test(t)) return i;
}
return -1;
}
-// 테이블 형식 결정
-function determineTableFormat(headerRow: string[]): 'format1' | 'format2' {
- const headerText = headerRow.join(' ').toLowerCase();
-
- // Format 2: Tag No와 Joint No가 분리된 컬럼
- const hasTagNoColumn = headerText.includes('tag') && headerText.includes('no');
- const hasJointNoColumn = headerText.includes('joint') && headerText.includes('no');
-
- if (hasTagNoColumn && hasJointNoColumn) {
- return 'format2';
- }
-
- // Format 1: Identification No에 통합
- return 'format1';
-}
+/* -------------------------------------------------------------------------- */
+/* Column Mapping */
+/* -------------------------------------------------------------------------- */
-// 컬럼 매핑 찾기
-function findColumnMapping(headerRow: string[], format: 'format1' | 'format2'): ColumnMapping {
- const mapping: ColumnMapping = {
- no: -1,
- identification: -1,
- tagNo: -1,
- jointNo: -1,
- jointType: -1,
- weldingDate: -1
- };
+function detectFormat (header: string[]): 'format1' | 'format2' {
+ const h = header.join(' ').toLowerCase();
+ return h.includes('tag') && h.includes('joint') ? 'format2' : 'format1';
+}
- headerRow.forEach((header, index) => {
- const lowerHeader = header.toLowerCase().trim();
-
- console.log(`Column ${index}: "${header}" -> "${lowerHeader}"`);
-
- if ((lowerHeader.includes('no.') || lowerHeader === 'no') &&
- !lowerHeader.includes('identification') &&
- !lowerHeader.includes('tag') &&
- !lowerHeader.includes('joint')) {
- mapping.no = index;
- console.log(` -> Mapped to 'no'`);
- } else if (lowerHeader.includes('identification') || lowerHeader.includes('ident')) {
- mapping.identification = index;
- console.log(` -> Mapped to 'identification'`);
- } else if (lowerHeader.includes('tag') && lowerHeader.includes('no')) {
- mapping.tagNo = index;
- console.log(` -> Mapped to 'tagNo'`);
- } else if (lowerHeader.includes('joint') && lowerHeader.includes('no')) {
- mapping.jointNo = index;
- console.log(` -> Mapped to 'jointNo'`);
- } else if (lowerHeader.includes('joint') && lowerHeader.includes('type')) {
- mapping.jointType = index;
- console.log(` -> Mapped to 'jointType'`);
- } else if (lowerHeader.includes('type') && !lowerHeader.includes('joint')) {
- mapping.jointType = index;
- console.log(` -> Mapped to 'jointType'`);
- } else if (lowerHeader.includes('welding') || lowerHeader.includes('date')) {
- mapping.weldingDate = index;
- console.log(` -> Mapped to 'weldingDate'`);
- }
+function mapColumns (header: string[]): ColumnMapping {
+ const mp: ColumnMapping = { no: -1, identification: -1, tagNo: -1, jointNo: -1, jointType: -1, weldingDate: -1 };
+
+ header.forEach((h, i) => {
+ const t = h.toLowerCase();
+ if (/^no\.?$/.test(t) && !/ident|tag|joint/.test(t)) mp.no = i;
+ else if (/identification|ident/.test(t)) mp.identification = i;
+ else if (/tag.*no/.test(t)) mp.tagNo = i;
+ else if (/joint.*no/.test(t)) mp.jointNo = i;
+ else if (/joint.*type/.test(t) || (/^type$/.test(t) && mp.jointType === -1)) mp.jointType = i;
+ else if (/welding|date/.test(t)) mp.weldingDate = i;
});
-
- console.log('Final column mapping:', mapping);
- return mapping;
+ return mp;
}
-// 행 데이터 추출
-function extractRowData(
- row: string[],
- format: 'format1' | 'format2',
- columnMapping: ColumnMapping,
- imageIndex: number,
- tableIndex: number,
- rowIndex: number
+/* -------------------------------------------------------------------------- */
+/* Row Extraction */
+/* -------------------------------------------------------------------------- */
+
+function buildRow (
+ row: string[],
+ format: 'format1' | 'format2',
+ mp: ColumnMapping,
+ tblIdx: number,
+ rowIdx: number
): ExtractedRow | null {
-
- const extractedRow: ExtractedRow = {
- no: '',
+ const out: ExtractedRow = {
+ no: mp.no >= 0 ? clean(row[mp.no]) : '',
identificationNo: '',
tagNo: '',
jointNo: '',
- jointType: '',
+ jointType: mp.jointType >= 0 ? clean(row[mp.jointType]) : '',
weldingDate: '',
confidence: 0,
- sourceTable: tableIndex,
- sourceRow: rowIndex
+ sourceTable: tblIdx,
+ sourceRow: rowIdx,
};
- console.log(`Processing row ${rowIndex}: [${row.map(cell => `"${cell}"`).join(', ')}]`);
-
- // No. 추출
- if (columnMapping.no >= 0 && columnMapping.no < row.length) {
- extractedRow.no = cleanText(row[columnMapping.no]);
+ if (mp.weldingDate >= 0) out.weldingDate = clean(row[mp.weldingDate]);
+ else {
+ const idx = row.findIndex(col => /\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}/.test(col));
+ if (idx >= 0) out.weldingDate = clean(row[idx]);
}
- if (format === 'format1') {
- // Format 1: 통합된 identification 데이터
- if (columnMapping.identification >= 0 && columnMapping.identification < row.length) {
- const combinedText = row[columnMapping.identification];
- const parsedData = parseIdentificationData(combinedText);
- extractedRow.identificationNo = parsedData.identificationNo;
- extractedRow.tagNo = parsedData.tagNo;
- extractedRow.jointNo = parsedData.jointNo;
-
- console.log(` Parsed identification: "${combinedText}" -> `, parsedData);
- }
+ if (format === 'format2') {
+ if (mp.identification >= 0) out.identificationNo = clean(row[mp.identification]);
+ if (mp.jointNo >= 0) out.jointNo = clean(row[mp.jointNo]);
+ if (mp.tagNo >= 0) out.tagNo = clean(row[mp.tagNo]);
} else {
- // Format 2: 분리된 컬럼들
- if (columnMapping.identification >= 0 && columnMapping.identification < row.length) {
- extractedRow.identificationNo = cleanText(row[columnMapping.identification]);
- }
- if (columnMapping.tagNo >= 0 && columnMapping.tagNo < row.length) {
- extractedRow.tagNo = cleanText(row[columnMapping.tagNo]);
- }
- if (columnMapping.jointNo >= 0 && columnMapping.jointNo < row.length) {
- extractedRow.jointNo = cleanText(row[columnMapping.jointNo]);
- }
+ const combined = mp.identification >= 0 ? row[mp.identification] : '';
+ const parsed = parseIdentificationData(combined);
+ out.identificationNo = parsed.identificationNo;
+ out.jointNo = parsed.jointNo;
+ out.tagNo = parsed.tagNo;
}
- // Joint Type 추출
- if (columnMapping.jointType >= 0 && columnMapping.jointType < row.length) {
- extractedRow.jointType = cleanText(row[columnMapping.jointType]);
- }
-
- // Welding Date 추출 (컬럼 매핑이 있으면 사용, 없으면 날짜 패턴으로 찾기)
- if (columnMapping.weldingDate >= 0 && columnMapping.weldingDate < row.length) {
- extractedRow.weldingDate = cleanText(row[columnMapping.weldingDate]);
- } else {
- const dateIndex = findDateColumn(row);
- if (dateIndex >= 0) {
- extractedRow.weldingDate = cleanText(row[dateIndex]);
- }
- }
-
- // 신뢰도 계산
- extractedRow.confidence = calculateRowConfidence(extractedRow);
-
- console.log(` Extracted row:`, extractedRow);
-
- return extractedRow;
+ out.confidence = scoreRow(out);
+ return out;
}
-// Identification 데이터 파싱 (Format 1용)
-function parseIdentificationData(combinedText: string): {
- identificationNo: string;
- tagNo: string;
- jointNo: string;
-} {
- const cleanedText = cleanText(combinedText);
-
- console.log(`Parsing identification data: "${cleanedText}"`);
-
- // 줄바꿈으로 먼저 분리
- const lines = cleanedText.split(/[\r\n]+/).map(line => line.trim()).filter(line => line.length > 0);
-
- const allParts: string[] = [];
- lines.forEach(line => {
- // 공백과 특수문자로 분리
- const parts = line.split(/[\s\-_]+/).filter(part => part.length > 0);
- allParts.push(...parts);
- });
+/* -------------------------------------------------------------------------- */
+/* Format‑1 셀 파싱 */
+/* -------------------------------------------------------------------------- */
- console.log(` Split into parts:`, allParts);
+function parseIdentificationData (txt: string): { identificationNo: string; jointNo: string; tagNo: string } {
+ const cleaned = clean(txt);
+ if (!cleaned) return { identificationNo: '', jointNo: '', tagNo: '' };
- if (allParts.length === 0) {
- return { identificationNo: cleanedText, tagNo: '', jointNo: '' };
- }
+ const tokens = cleaned.split(/\s+/).map(clean).filter(Boolean);
- if (allParts.length === 1) {
- return { identificationNo: allParts[0], tagNo: '', jointNo: '' };
- }
+ // Identification 후보: 하이픈이 2개 이상 포함된 토큰 가운데 가장 긴 것
+ const idCand = tokens.filter(t => t.split('-').length >= 3).sort((a, b) => b.length - a.length);
+ const identificationNo = idCand[0] || '';
- // 길이별로 정렬하여 식별
- const sortedParts = [...allParts].sort((a, b) => b.length - a.length);
-
- const identificationNo = sortedParts[0]; // 가장 긴 것
- const jointNo = allParts.find(part => part.length <= 3 && /^[A-Z0-9]+$/i.test(part)) ||
- sortedParts[sortedParts.length - 1]; // 3글자 이하 영숫자 또는 가장 짧은 것
- const tagNo = allParts.find(part => part !== identificationNo && part !== jointNo) || '';
-
- const result = { identificationNo, tagNo, jointNo };
- console.log(` Parsed result:`, result);
-
- return result;
-}
+ const residual = tokens.filter(t => t !== identificationNo);
+ if (!residual.length) return { identificationNo, jointNo: '', tagNo: '' };
-// 날짜 컬럼 찾기
-function findDateColumn(row: string[]): number {
- const datePattern = /\d{4}[.\-\/]\d{1,2}[.\-\/]\d{1,2}/;
-
- for (let i = 0; i < row.length; i++) {
- if (datePattern.test(row[i])) {
- console.log(` Found date in column ${i}: "${row[i]}"`);
- return i;
- }
- }
-
- return -1;
-}
+ residual.sort((a, b) => a.length - b.length);
+ const jointNo = residual[0] || '';
+ const tagNo = residual[residual.length - 1] || '';
-// 텍스트 정리
-function cleanText(text: string): string {
- return text
- .replace(/[\r\n\t]+/g, ' ')
- .replace(/\s+/g, ' ')
- .trim();
+ return { identificationNo, jointNo, tagNo };
}
-// 빈 행 확인
-function isEmptyRow(row: string[]): boolean {
- return row.every(cell => !cell || cell.trim().length === 0);
+/* -------------------------------------------------------------------------- */
+/* Helpers */
+/* -------------------------------------------------------------------------- */
+
+const clean = (s: string = '') => s.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
+const isBlankRow = (row: string[]) => row.every(c => !clean(c));
+const isValidRow = (r: ExtractedRow) => !!(r.no || r.identificationNo);
+
+function scoreRow (r: ExtractedRow): number {
+ const w: Record<keyof ExtractedRow, number> = {
+ no: 1, identificationNo: 3, tagNo: 2, jointNo: 2, jointType: 1, weldingDate: 1,
+ confidence: 0, sourceTable: 0, sourceRow: 0,
+ } as any;
+ let s = 0, t = 0;
+ (Object.keys(w) as (keyof ExtractedRow)[]).forEach(k => { t += w[k]; if ((r[k] as string)?.length) s += w[k]; });
+ return t ? s / t : 0;
}
-// 유효한 행 확인
-function isValidRow(row: ExtractedRow): boolean {
- // 번호나 식별번호 중 하나라도 있으면 유효
- const hasBasicData = !!(row.no || row.identificationNo);
-
- // 너무 짧은 데이터는 제외 (오인식 방지)
- const hasReasonableLength = (row.identificationNo?.length || 0) >= 3 ||
- (row.no?.length || 0) >= 1;
-
- return hasBasicData && hasReasonableLength;
-}
+/* -------------------------------------------------------------------------- */
+/* OCR 품질 분석 (기존 로직 유지) */
+/* -------------------------------------------------------------------------- */
-// 행 신뢰도 계산
-function calculateRowConfidence(row: ExtractedRow): number {
- let score = 0;
- let maxScore = 0;
-
- // 각 필드별 가중치
- const weights = {
- no: 1,
- identificationNo: 3, // 가장 중요
- tagNo: 2,
- jointNo: 2,
- jointType: 1,
- weldingDate: 1
- };
+export function analyzeOCRQuality (ocrResult: any) {
+ let conf = 0, cnt = 0, tbl = 0, kw = 0;
+ const keys = ['no.', 'identification', 'joint', 'tag', 'type', 'weld', 'date'];
- Object.entries(weights).forEach(([field, weight]) => {
- maxScore += weight;
- const value = row[field as keyof ExtractedRow] as string;
-
- if (value && value.length > 0) {
- // 기본 점수
- score += weight * 0.5;
-
- // 길이 보너스
- if (field === 'identificationNo' && value.length > 10) {
- score += weight * 0.3;
- } else if (field === 'no' && /^\d+$/.test(value)) {
- score += weight * 0.3;
- } else if (field === 'weldingDate' && /\d{4}[.\-\/]\d{1,2}[.\-\/]\d{1,2}/.test(value)) {
- score += weight * 0.3;
- } else if (value.length > 2) {
- score += weight * 0.2;
- }
- }
+ ocrResult.images?.forEach((img: any) => {
+ tbl += img.tables?.length || 0;
+ img.fields?.forEach((f: any) => {
+ conf += f.inferConfidence || 0; cnt++;
+ const t = (f.inferText || '').toLowerCase();
+ keys.forEach(k => { if (t.includes(k)) kw++; });
+ });
});
- return maxScore > 0 ? Math.min(score / maxScore, 1) : 0;
+ return { confidence: cnt ? conf / cnt : 0, tablesFound: tbl, textQuality: cnt ? kw / cnt : 0, keywordCount: kw };
}
-
-// 유틸리티: OCR 결과 품질 분석
-export function analyzeOCRQuality(ocrResult: any): {
- confidence: number;
- tablesFound: number;
- textQuality: number;
- keywordCount: number;
-} {
- let totalConfidence = 0;
- let totalFields = 0;
- let tablesFound = 0;
- let relevantKeywords = 0;
-
- const keywords = ['no.', 'identification', 'joint', 'tag', 'type', 'weld', 'date'];
-
- if (ocrResult.images) {
- ocrResult.images.forEach((image: any) => {
- // 테이블 분석
- if (image.tables) {
- tablesFound += image.tables.length;
- }
-
- // 필드 신뢰도 분석
- if (image.fields) {
- image.fields.forEach((field: any) => {
- const confidence = field.inferConfidence || 0;
- const text = (field.inferText || '').toLowerCase();
-
- totalConfidence += confidence;
- totalFields++;
-
- // 관련 키워드 확인
- keywords.forEach(keyword => {
- if (text.includes(keyword)) {
- relevantKeywords++;
- }
- });
- });
- }
- });
- }
-
- const avgConfidence = totalFields > 0 ? totalConfidence / totalFields : 0;
- const textQuality = totalFields > 0 ? relevantKeywords / totalFields : 0;
-
- return {
- confidence: avgConfidence,
- tablesFound,
- textQuality,
- keywordCount: relevantKeywords
- };
-} \ No newline at end of file