// ============================================================================ // app/api/ocr/utils/imageRotation.ts // 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); /** * PDF 페이지 수를 확인하는 함수 */ export async function getPDFPageCount(pdfBuffer: Buffer): Promise { const tmp = tmpdir(); const id = randomUUID(); const pdfPath = path.join(tmp, `${id}.pdf`); try { await fs.writeFile(pdfPath, pdfBuffer); // pdfinfo 명령어로 페이지 수 확인 const { stdout } = await exec('pdfinfo', [pdfPath]); const pageMatch = stdout.match(/Pages:\s+(\d+)/); const pageCount = pageMatch ? parseInt(pageMatch[1]) : 1; console.log(`📄 PDF has ${pageCount} pages`); return pageCount; } catch (error) { 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(() => {}); } } /** * 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'}` }; } } /** * 서버 사이드에서 이미지를 회전시킵니다 */ export async function rotateImageBase64(base64: string, degrees: number): Promise { 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}`); } } } /** * 이미지 품질을 개선합니다 */ export async function enhanceImageQuality(base64: string): Promise { try { 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.error('❌ Error enhancing image:', error); return base64; } } /** * PDF를 이미지로 변환합니다 (개선된 버전) */ export async function convertPDFToImage( pdfBuffer: Buffer, pageIndex = 0, dpi = 300, ): Promise { const tmp = tmpdir(); const id = randomUUID(); const pdfPath = path.join(tmp, `${id}.pdf`); const outPrefix = path.join(tmp, id); const jpgPath = `${outPrefix}.jpg`; try { console.log(`📄 Converting PDF page ${pageIndex + 1} to image (${dpi} DPI)...`); // 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'); console.log(`✅ PDF page ${pageIndex + 1} converted successfully: ${img.length} bytes`); return base64; } catch (error) { 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(() => {}); } } /** * 파일 형식을 JPEG로 정규화 */ export async function normalizeImageFormat(base64: string): Promise { 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; } } // 기존 함수들 export async function detectTextOrientation(base64: string): Promise { 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 { 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 { 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; } }