1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
|
// ============================================================================
// 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<number> {
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<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}`);
}
}
}
/**
* 이미지 품질을 개선합니다
*/
export async function enhanceImageQuality(base64: string): Promise<string> {
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<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 {
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<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;
}
}
// 기존 함수들
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;
}
}
|