luna_ocr / server /server.js
veela4's picture
Add files using upload-large-folder tool
373c769 verified
const express = require('express');
const cors = require('cors');
const multer = require('multer');
const sharp = require('sharp');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const fs = require('fs');
const path = require('path');
// Server-side API key (more secure for public deployment)
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || null;
const pdf = require('pdf-poppler');
const app = express();
const PORT = process.env.PORT || 3002;
// Cleanup function for temporary files
function cleanupTempFiles() {
const uploadsDir = path.join(__dirname, 'uploads');
try {
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
console.log('📁 Created uploads directory');
return;
}
const files = fs.readdirSync(uploadsDir);
let cleanedCount = 0;
files.forEach(file => {
const filePath = path.join(uploadsDir, file);
const stats = fs.statSync(filePath);
const now = new Date();
const fileAge = now - stats.mtime; // Age in milliseconds
const maxAge = 30 * 60 * 1000; // 30 minutes in milliseconds
// Clean up files older than 30 minutes
if (fileAge > maxAge) {
try {
fs.unlinkSync(filePath);
cleanedCount++;
console.log(`🗑️ Cleaned up old temp file: ${file}`);
} catch (error) {
console.warn(`⚠️ Could not delete ${file}:`, error.message);
}
}
});
if (cleanedCount > 0) {
console.log(`✅ Cleaned up ${cleanedCount} temporary files`);
} else {
console.log('✅ No temporary files to clean up');
}
} catch (error) {
console.error('❌ Error during cleanup:', error.message);
}
}
// Run cleanup on server start
cleanupTempFiles();
// Schedule periodic cleanup every 15 minutes
setInterval(() => {
console.log('🔄 Running periodic cleanup...');
cleanupTempFiles();
}, 15 * 60 * 1000); // 15 minutes
// Middleware
app.use(cors());
app.use(express.json());
// Configure multer for file uploads
const upload = multer({
dest: 'uploads/',
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, WebP, and PDF are allowed.'));
}
}
});
// PDF to image conversion using pdf-poppler
async function convertPdfToImages(pdfPath) {
try {
const outputDir = path.dirname(pdfPath);
console.log('Converting PDF to images using pdf-poppler...');
const options = {
format: 'png',
out_dir: outputDir,
out_prefix: 'page',
page: null, // Convert all pages
scale: 2048 // High resolution for better OCR
};
console.log('PDF conversion options:', options);
// Convert PDF to images
const results = await pdf.convert(pdfPath, options);
console.log('PDF conversion results:', results);
// Build image paths based on the results
const imagePaths = [];
if (Array.isArray(results)) {
for (let i = 0; i < results.length; i++) {
const imagePath = path.join(outputDir, `page-${i + 1}.png`);
if (fs.existsSync(imagePath)) {
imagePaths.push(imagePath);
console.log(`Found converted page: ${imagePath}`);
}
}
}
// If no images found with the expected naming, try alternative naming
if (imagePaths.length === 0) {
console.log('Trying alternative file naming patterns...');
const files = fs.readdirSync(outputDir);
const pngFiles = files.filter(file => file.endsWith('.png') && file.startsWith('page'));
for (const file of pngFiles) {
const fullPath = path.join(outputDir, file);
imagePaths.push(fullPath);
console.log(`Found PNG file: ${fullPath}`);
}
}
console.log(`Successfully converted ${imagePaths.length} pages to images`);
return imagePaths;
} catch (error) {
console.error('PDF conversion error:', error);
throw new Error(`PDF conversion failed: ${error.message}`);
}
}
// Enhanced image preprocessing
async function enhanceImageForOCR(imagePath) {
try {
// Create a unique enhanced filename to avoid input/output conflict
const enhancedPath = imagePath + '_enhanced.png';
await sharp(imagePath)
.resize(null, 2000, {
withoutEnlargement: false,
kernel: sharp.kernel.lanczos3
})
.normalize()
.sharpen({ sigma: 1.2, flat: 1, jagged: 2 })
.gamma(1.1)
.png({ quality: 95, compressionLevel: 6 })
.toFile(enhancedPath);
return enhancedPath;
} catch (error) {
console.error('Image enhancement error:', error);
return imagePath; // Return original if enhancement fails
}
}
// Gemini OCR processing with intelligent formatting
async function processWithGemini(imagePath, apiKey, mode = 'standard') {
try {
const genAI = new GoogleGenerativeAI(apiKey);
// Choose model based on mode (using 2.5 models as specified)
const modelName = mode === 'structured' ? 'gemini-2.5-flash' : 'gemini-2.5-pro';
const model = genAI.getGenerativeModel({ model: modelName });
// Read and prepare image
const imageBuffer = fs.readFileSync(imagePath);
const imageBase64 = imageBuffer.toString('base64');
const imagePart = {
inlineData: {
data: imageBase64,
mimeType: 'image/png'
}
};
// Choose prompt based on mode
let prompt;
if (mode === 'structured') {
prompt = `🇹🇭 **THAI-FOCUSED MARKDOWN OCR - อ่านข้อความไทยและจัดรูปแบบเป็น Markdown**
**คำสั่งสำคัญ (CRITICAL INSTRUCTIONS):**
1. **อ่านทุกตัวอักษร** - แยกข้อความที่เขียนในรูปภาพทุกตัว
2. **ไม่ใช้ตัวอย่างทั่วไป** - ห้ามใช้ "Field | Value" ให้ใช้ข้อความจริงที่เห็น
3. **รักษาภาษาเดิม** - ถ้าเป็นไทยให้เป็นไทย ถ้าเป็นอังกฤษให้เป็นอังกฤษ
4. **ข้อความจริงเท่านั้น** - ห้ามตีความ แปล หรือสร้างคำอธิบายเอง
**วิธีการสแกน:**
- เริ่มจากซ้ายบน อ่านทีละบรรทัดไปขวาล่าง
- รวมข้อความทั้งหมด: หัวข้อ เนื้อหา ตัวเลข วันที่ ข้อความเล็ก
- **สำหรับข้อความไทย: เว้นวรรคระหว่างคำให้ถูกต้อง**
- สำหรับตาราง: ใช้หัวตารางและข้อมูลจริงที่เห็น
- สำหรับรายการ: ใช้รายการจริงที่เห็น
**กฎการจัดรูปแบบ MARKDOWN:**
- ## สำหรับหัวข้อใหญ่ (ใช้หัวข้อจริง)
- ### สำหรับหัวข้อย่อย (ใช้หัวข้อจริง)
- **ใช้ตารางเสมอสำหรับข้อมูลที่เป็นระบบ:**
- เห็นตาราง → สร้างตารางด้วยหัวตารางและข้อมูลจริง
- เห็นรายการ → แปลงเป็นตารางด้วยรายการจริง
- เห็นคู่ข้อมูล → ใช้คีย์และค่าจริง
- ใช้ **ตัวหนา** สำหรับข้อความที่เน้นในรูป
- ใช้ > สำหรับหมายเหตุที่ปรากฏในรูป
**ตัวอย่างตาราง:**
| รายการ | จำนวน | ราคา |
|--------|--------|------|
| กาแฟ | 2 แก้ว | 60 บาท |
| ขนมปัง | 1 ชิ้น | 25 บาท |
**ผลลัพธ์: MARKDOWN ที่มีเนื้อหาจริง - ห้ามใช้แม่แบบทั่วไป**`;
} else {
prompt = `🇹🇭 **FAST MARKDOWN OCR - SAME FEATURES AS PRO MODE (2.5 FLASH)**
**คำสั่งสำคัญ (IDENTICAL TO STRUCTURED MODE):**
1. **อ่านทุกตัวอักษร** - แยกข้อความที่เขียนในรูปภาพทุกตัว
2. **ไม่ใช้ตัวอย่างทั่วไป** - ห้ามใช้ "Field | Value" ให้ใช้ข้อความจริงที่เห็น
3. **รักษาภาษาเดิม** - ถ้าเป็นไทยให้เป็นไทย ถ้าเป็นอังกฤษให้เป็นอังกฤษ
4. **ข้อความจริงเท่านั้น** - ห้ามตีความ แปล หรือสร้างคำอธิบายเอง
**วิธีการสแกน (SAME AS PRO MODE):**
- เริ่มจากซ้ายบน อ่านทีละบรรทัดไปขวาล่าง
- รวมข้อความทั้งหมด: หัวข้อ เนื้อหา ตัวเลข วันที่ ข้อความเล็ก
- **สำหรับข้อความไทย: เว้นวรรคระหว่างคำให้ถูกต้อง**
- สำหรับตาราง: ใช้หัวตารางและข้อมูลจริงที่เห็น
- สำหรับรายการ: ใช้รายการจริงที่เห็น
**MARKDOWN FORMATTING RULES (IDENTICAL TO PRO MODE):**
- ## สำหรับหัวข้อใหญ่ (ใช้หัวข้อจริง)
- ### สำหรับหัวข้อย่อย (ใช้หัวข้อจริง)
- **ใช้ตารางเสมอสำหรับข้อมูลที่เป็นระบบ:**
- เห็นตาราง → สร้างตาราง markdown ด้วยหัวตารางและข้อมูลจริง
- เห็นรายการ → แปลงเป็นตาราง markdown ด้วยรายการจริง
- เห็นคู่ข้อมูล → ใช้คีย์และค่าจริงในตาราง markdown
- ใช้ **ตัวหนา** สำหรับข้อความที่เน้นในรูป
- ใช้ > สำหรับหมายเหตุที่ปรากฏในรูป
- ใช้ - สำหรับรายการเมื่อเหมาะสม
**การประมวลผลภาษา (SAME AS PRO MODE):**
- ข้อความไทย: เว้นวรรคให้ถูกต้อง แก้ไขข้อผิดพลาด OCR
- ข้อความอังกฤษ: รักษาการสะกดและตัวพิมพ์เดิม
- ตัวเลข: แยกตัวเลข ทศนิยม เปอร์เซ็นต์ รหัสให้แม่นยำ
- ภาษาผสม: รักษาภาษาเดิมของแต่ละส่วน
**ตัวอย่างตาราง MARKDOWN:**
| รายการ | จำนวน | ราคา |
|--------|--------|------|
| กาแฟ อเมริกาโน่ | 2 แก้ว | 120 บาท |
| ขนมปังโฮลวีท | 1 ชิ้น | 45 บาท |
**ผลลัพธ์: MARKDOWN WITH SAME FEATURES AS PRO MODE - JUST FASTER WITH 2.5 FLASH**`;
}
const result = await model.generateContent([prompt, imagePart]);
const response = await result.response;
let extractedText = response.text();
// Both modes now support the same markdown formatting
// No cleanup needed - both modes produce markdown output
return extractedText;
} catch (error) {
console.error('Gemini processing error:', error);
throw new Error(`OCR processing failed: ${error.message}`);
}
}
// Generate different format outputs
function generateFormats(text, fileName, mode) {
const baseFileName = fileName.replace(/\.[^/.]+$/, '');
const timestamp = new Date().toISOString();
const formats = {
// Plain text
txt: text,
// Markdown (always available for both modes)
md: mode === 'structured' ? text : `# ${baseFileName}\n\n${text}`,
// JSON format
json: {
metadata: {
fileName: fileName,
extractedAt: timestamp,
characterCount: text.length,
lineCount: text.split('\n').length,
wordCount: text.split(/\s+/).filter(word => word.length > 0).length,
processingMode: mode
},
content: {
rawText: text,
lines: text.split('\n'),
paragraphs: text.split('\n\n').filter(p => p.trim().length > 0)
}
}
};
return formats;
}
// Progress tracking
const progressTracking = new Map();
const consoleLogs = new Map(); // Store console logs per session
// Progress endpoint
app.get('/api/progress/:sessionId', (req, res) => {
const sessionId = req.params.sessionId;
const progress = progressTracking.get(sessionId) || { current: 0, total: 0, status: 'Not started' };
res.json(progress);
});
// Latest progress endpoint (gets the most recent progress)
app.get('/api/progress-latest', (req, res) => {
if (progressTracking.size === 0) {
return res.json({ current: 0, total: 0, status: 'No active processing' });
}
// Get the most recent progress entry
const entries = Array.from(progressTracking.entries());
const latestEntry = entries[entries.length - 1];
// Sending progress data to frontend
res.json(latestEntry[1]);
});
// Main OCR endpoint
app.post('/api/ocr', upload.single('file'), async (req, res) => {
const sessionId = Date.now().toString();
try {
const { mode = 'standard' } = req.body;
// Use server-side API key if available, otherwise require from client
const apiKey = GEMINI_API_KEY || req.body.apiKey;
if (!apiKey) {
return res.status(400).json({
success: false,
error: GEMINI_API_KEY ? 'Server API key not configured' : 'Google API Key is required'
});
}
if (!req.file) {
return res.status(400).json({
success: false,
error: 'No file uploaded'
});
}
let extractedText = '';
let imagePaths = [];
// Console log capture helper
const addConsoleLog = (message) => {
if (!consoleLogs.has(sessionId)) {
consoleLogs.set(sessionId, []);
}
const logs = consoleLogs.get(sessionId);
logs.push({
timestamp: new Date().toISOString(),
message: message
});
// Keep only last 20 logs to prevent memory issues
if (logs.length > 20) {
logs.shift();
}
console.log(message);
};
addConsoleLog(`🚀 Processing file: ${req.file.originalname} in ${mode} mode`);
// Progress update helper
const updateProgress = (current, total, status, details = {}) => {
const progressData = {
current,
total,
status,
sessionId,
fileName: req.file.originalname,
consoleLogs: consoleLogs.get(sessionId) || [],
...details
};
progressTracking.set(sessionId, progressData);
addConsoleLog(`Progress: ${current}/${total} - ${status}`);
};
// Handle PDF files
if (req.file.mimetype === 'application/pdf') {
addConsoleLog('🔄 Starting PDF processing...');
addConsoleLog(`📄 PDF file: ${req.file.originalname}`);
addConsoleLog(`📊 File size: ${(req.file.size / 1024).toFixed(2)} KB`);
updateProgress(1, 10, '🖼️ Converting PDF to images...');
const pdfImagePaths = await convertPdfToImages(req.file.path);
if (pdfImagePaths.length === 0) {
throw new Error('No pages could be extracted from PDF. The PDF might be corrupted or empty.');
}
addConsoleLog(`✅ Converted ${pdfImagePaths.length} pages to images`);
// Calculate total steps: 2 initial + (2 steps per page)
const totalSteps = 2 + (pdfImagePaths.length * 2);
updateProgress(2, totalSteps, `📋 Found ${pdfImagePaths.length} pages to process`, {
totalPages: pdfImagePaths.length,
currentPage: 0,
totalCharacters: 0
});
// Process each page with OCR
for (let i = 0; i < pdfImagePaths.length; i++) {
const currentPage = i + 1;
const totalPages = pdfImagePaths.length;
addConsoleLog(`🔍 Processing page ${currentPage}/${totalPages} (${Math.round((currentPage / totalPages) * 100)}%)`);
// Update progress for enhancement step
const enhanceStep = 2 + (i * 2) + 1;
updateProgress(enhanceStep, totalSteps, `🔧 Enhancing page ${currentPage}/${totalPages}`, {
totalPages,
currentPage,
totalCharacters: extractedText.length,
phase: 'enhancing'
});
addConsoleLog(`📝 Enhancing image: page-${currentPage}.png`);
const enhancedImagePath = await enhanceImageForOCR(pdfImagePaths[i]);
// Update progress for OCR step
const ocrStep = 2 + (i * 2) + 2;
updateProgress(ocrStep, totalSteps, `🤖 Running OCR on page ${currentPage}/${totalPages}`, {
totalPages,
currentPage,
totalCharacters: extractedText.length,
phase: 'ocr'
});
addConsoleLog(`🤖 Running OCR on page ${currentPage} with Gemini ${mode === 'structured' ? '2.5-Pro' : '2.5-Flash'}`);
const pageText = await processWithGemini(enhancedImagePath, apiKey, mode);
addConsoleLog(`✅ Page ${currentPage} processed - ${pageText.length} characters extracted`);
if (mode === 'structured') {
extractedText += `\n\n## Page ${currentPage}\n\n${pageText}`;
} else {
extractedText += `\n\n--- Page ${currentPage} ---\n\n${pageText}`;
}
// Update progress with completed page info
updateProgress(ocrStep, totalSteps, `✅ Page ${currentPage}/${totalPages} completed - ${pageText.length} chars`, {
totalPages,
currentPage,
totalCharacters: extractedText.length,
pageCharacters: pageText.length,
phase: 'completed'
});
addConsoleLog(`📊 Total extracted so far: ${extractedText.length} characters`);
imagePaths.push(enhancedImagePath);
}
addConsoleLog('🧹 Cleaning up temporary files...');
// Clean up PDF image files
pdfImagePaths.forEach((imagePath, index) => {
try {
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
addConsoleLog(`🗑️ Cleaned up page-${index + 1}.png`);
}
} catch (error) {
addConsoleLog(`⚠️ Cleanup warning: ${error.message}`);
}
});
} else {
// Handle regular image files
addConsoleLog('🖼️ Processing single image file...');
addConsoleLog(`📄 File: ${req.file.originalname}`);
addConsoleLog(`📊 Size: ${(req.file.size / 1024).toFixed(2)} KB`);
updateProgress(1, 3, '🔧 Enhancing image for better OCR...', {
totalPages: 1,
currentPage: 1,
totalCharacters: 0,
phase: 'enhancing'
});
const enhancedImagePath = await enhanceImageForOCR(req.file.path);
updateProgress(2, 3, `🤖 Running OCR with Gemini ${mode === 'structured' ? '2.5-Pro' : '2.5-Flash'}...`, {
totalPages: 1,
currentPage: 1,
totalCharacters: 0,
phase: 'ocr'
});
extractedText = await processWithGemini(enhancedImagePath, apiKey, mode);
updateProgress(3, 3, `✅ OCR completed - ${extractedText.length} characters extracted`, {
totalPages: 1,
currentPage: 1,
totalCharacters: extractedText.length,
pageCharacters: extractedText.length,
phase: 'completed'
});
addConsoleLog(`✅ OCR completed - ${extractedText.length} characters extracted`);
imagePaths.push(enhancedImagePath);
}
// Generate all formats
const formats = generateFormats(extractedText, req.file.originalname, mode);
// Comprehensive cleanup of all temporary files
const filesToCleanup = [req.file.path, ...imagePaths];
let cleanedFiles = 0;
filesToCleanup.forEach(filePath => {
if (filePath && fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
cleanedFiles++;
addConsoleLog(`🗑️ Cleaned up: ${path.basename(filePath)}`);
} catch (error) {
addConsoleLog(`⚠️ Cleanup warning for ${path.basename(filePath)}: ${error.message}`);
}
}
});
addConsoleLog(`✅ Cleanup complete: ${cleanedFiles} files removed`);
// Final progress update
const finalProgress = progressTracking.get(sessionId);
if (finalProgress) {
updateProgress(finalProgress.total, finalProgress.total, '✅ Processing complete!');
}
// Return success response
res.json({
success: true,
sessionId: sessionId,
data: {
fileName: req.file.originalname,
fileSize: req.file.size,
processingMode: mode,
extractedText: extractedText,
formats: formats,
metadata: {
characterCount: extractedText.length,
wordCount: extractedText.split(/\s+/).filter(word => word.length > 0).length,
lineCount: extractedText.split('\n').length,
processedAt: new Date().toISOString()
}
}
});
// Clean up progress tracking and console logs after a delay
setTimeout(() => {
progressTracking.delete(sessionId);
consoleLogs.delete(sessionId);
}, 30000); // Clean up after 30 seconds
} catch (error) {
console.error('OCR processing error:', error);
// Comprehensive cleanup on error
const filesToCleanup = [];
if (req.file && req.file.path) filesToCleanup.push(req.file.path);
if (imagePaths && imagePaths.length > 0) filesToCleanup.push(...imagePaths);
let cleanedFiles = 0;
filesToCleanup.forEach(filePath => {
if (filePath && fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
cleanedFiles++;
console.log(`🗑️ Error cleanup: ${path.basename(filePath)}`);
} catch (cleanupError) {
console.warn(`⚠️ Error cleanup warning for ${path.basename(filePath)}:`, cleanupError.message);
}
}
});
if (cleanedFiles > 0) {
console.log(`✅ Error cleanup complete: ${cleanedFiles} files removed`);
}
res.status(500).json({
success: false,
error: error.message || 'Internal server error during OCR processing'
});
}
});
// Root endpoint
app.get('/', (req, res) => {
res.json({
success: true,
message: '🌙 Luna OCR Backend API',
version: '1.0.0',
endpoints: {
health: '/api/health',
ocr: '/api/ocr (POST)'
},
status: 'Running',
port: PORT,
timestamp: new Date().toISOString()
});
});
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({
success: true,
message: 'Luna OCR Backend is running!',
hasApiKey: !!GEMINI_API_KEY,
requiresUserApiKey: !GEMINI_API_KEY,
timestamp: new Date().toISOString()
});
});
// Manual cleanup endpoint
app.post('/api/cleanup', (req, res) => {
try {
console.log('🧹 Manual cleanup requested...');
cleanupTempFiles();
res.json({
success: true,
message: 'Cleanup completed successfully',
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Manual cleanup error:', error);
res.status(500).json({
success: false,
error: 'Cleanup failed: ' + error.message
});
}
});
// Error handling middleware
app.use((error, req, res, next) => {
if (error instanceof multer.MulterError) {
if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
success: false,
error: 'File too large. Maximum size is 10MB.'
});
}
}
console.error('Unhandled error:', error);
res.status(500).json({
success: false,
error: 'Internal server error'
});
});
// Graceful shutdown cleanup
process.on('SIGINT', () => {
console.log('\n🛑 Received SIGINT, cleaning up before shutdown...');
cleanupTempFiles();
console.log('� LuRna OCR Backend shutting down gracefully');
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\n🛑 Received SIGTERM, cleaning up before shutdown...');
cleanupTempFiles();
console.log('👋 Luna OCR Backend shutting down gracefully');
process.exit(0);
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 Luna OCR Backend running on port ${PORT}`);
console.log(`📡 Health check: http://localhost:${PORT}/api/health`);
console.log(`🔍 OCR endpoint: http://localhost:${PORT}/api/ocr`);
console.log(`🧹 Cleanup endpoint: http://localhost:${PORT}/api/cleanup`);
console.log(`⏰ Automatic cleanup runs every 15 minutes`);
});
module.exports = app;