KOOHAWN/AI_PDF_TOOLKIt
0
1const express = require('express');2const cors = require('cors');3const multer = require('multer');4const { Worker } = require('worker_threads');5const path = require('path');6const fs = require('fs');7const os = require('os');8const crypto = require('crypto');9const { execFile } = require('child_process');10const JSZip = require('jszip');11const sharp = require('sharp');12 13function loadLocalEnvFile() {14 const envPath = path.join(__dirname, '.env');15 if (!fs.existsSync(envPath)) return;16 17 const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/);18 for (const line of lines) {19 const trimmed = line.trim();20 if (!trimmed || trimmed.startsWith('#')) continue;21 const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);22 if (!match) continue;23 const key = match[1];24 let value = match[2].trim();25 if (26 (value.startsWith('"') && value.endsWith('"')) ||27 (value.startsWith("'") && value.endsWith("'"))28 ) {29 value = value.slice(1, -1);30 }31 if (!process.env[key]) process.env[key] = value;32 }33}34 35loadLocalEnvFile();36 37const app = express();38const PORT = process.env.PORT || 8080;39 40app.use(cors());41app.use(express.json());42 43// In-memory task queue to bypass Render's 30-second timeout44const tasks = new Map();45 46// Periodic cleanup of expired tasks (older than 10 minutes)47setInterval(() => {48 const now = Date.now();49 for (const [taskId, task] of tasks.entries()) {50 if (now - task.createdAt > 10 * 60 * 1000) {51 tasks.delete(taskId);52 }53 }54}, 60 * 1000);55 56// Configure Multer for secure temporary file uploads57const upload = multer({ dest: os.tmpdir() });58 59// Resolve Ghostscript and MuPDF path dynamically (embedded vs system-wide for Docker/Linux)60const isWindows = process.platform === 'win32';61const localGsPath = path.join(__dirname, 'bin', 'gs', 'bin', 'gswin64c.exe');62const localGsLibPath = path.join(__dirname, 'bin', 'gs', 'lib');63 64const gsPath = fs.existsSync(localGsPath) ? localGsPath : 'gs';65const gsLibPath = fs.existsSync(localGsLibPath) ? localGsLibPath : '';66 67console.log(`[API Server] Using Ghostscript path: ${gsPath}`);68if (gsLibPath) {69 console.log(`[API Server] Using Ghostscript library path: ${gsLibPath}`);70}71 72// Ensure temp upload directory exists73const tempDir = path.join(__dirname, 'temp_uploads');74if (!fs.existsSync(tempDir)) {75 fs.mkdirSync(tempDir);76}77const localUpload = multer({ dest: tempDir });78const imageUpload = multer({79 dest: tempDir,80 limits: {81 files: 100,82 fileSize: 50 * 1024 * 1024,83 },84 fileFilter: (req, file, cb) => {85 const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');86 const ext = path.extname(originalName).replace('.', '').toLowerCase();87 if (file.fieldname === 'htmlFile' && ['html', 'htm'].includes(ext)) {88 cb(null, true);89 return;90 }91 if (file.fieldname === 'files' && ['png', 'jpg', 'jpeg', 'webp'].includes(ext)) {92 cb(null, true);93 return;94 }95 cb(new Error('지원하지 않는 파일 형식입니다.'));96 },97});98 99// Serve static assets from Vite's build directory (dist)100app.use(express.static(path.join(__dirname, 'dist')));101 102// Route 0: Serve React Web App at Root103app.get('/', (req, res) => {104 const indexPath = path.join(__dirname, 'dist', 'index.html');105 if (fs.existsSync(indexPath)) {106 res.sendFile(indexPath);107 } else {108 res.send(`109 <div style="font-family: sans-serif; text-align: center; margin-top: 100px;">110 <h2>PDF & AI Toolkit API Service Running</h2>111 <p>Frontend static files (dist/) not built yet. Please build with <b>npm run build</b>.</p>112 <span style="background: #e6fffa; color: #00875a; padding: 4px 12px; border-radius: 99px;">Active</span>113 </div>114 `);115 }116});117 118// Route 1: Health check119app.get('/health', (req, res) => {120 res.json({ status: 'ok', platform: process.platform, arch: process.arch });121});122 123function runImageWorker(workerData) {124 return new Promise((resolve, reject) => {125 const workerPath = path.join(__dirname, 'workers', 'image.worker.cjs');126 const worker = new Worker(workerPath, { workerData });127 let settled = false;128 129 worker.on('message', (message) => {130 settled = true;131 resolve(message);132 });133 134 worker.on('error', (err) => {135 if (!settled) {136 settled = true;137 reject(err);138 }139 });140 141 worker.on('exit', (code) => {142 if (code !== 0 && !settled) {143 reject(new Error(`이미지 워커가 비정상적으로 종료되었습니다 (code: ${code}).`));144 }145 });146 });147}148 149const GIF_EXPORT_LIMITS = Object.freeze({150 maxFrames: 60,151 maxFrameBytes: 8 * 1024 * 1024,152 maxUploadBytes: 80 * 1024 * 1024,153 maxDurationMs: 8000,154 outputWidth: 860,155});156 157function removeGifTempDir(req) {158 if (!req.gifTempDir) return;159 try {160 fs.rmSync(req.gifTempDir, { recursive: true, force: true });161 } catch (_) {}162 req.gifTempDir = null;163}164 165const gifUpload = multer({166 storage: multer.diskStorage({167 destination(req, _file, callback) {168 try {169 if (!req.gifTempDir) {170 req.gifTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gif-export-'));171 }172 callback(null, req.gifTempDir);173 } catch (error) {174 callback(error);175 }176 },177 filename(_req, _file, callback) {178 callback(null, `frame-${crypto.randomUUID()}.png`);179 },180 }),181 limits: {182 fileSize: GIF_EXPORT_LIMITS.maxFrameBytes,183 files: GIF_EXPORT_LIMITS.maxFrames,184 fields: 2,185 fieldSize: 64 * 1024,186 },187 fileFilter(_req, file, callback) {188 callback(null, file.mimetype === 'image/png');189 },190}).array('frames', GIF_EXPORT_LIMITS.maxFrames);191 192function gifUploadMiddleware(req, res, next) {193 const contentLength = Number(req.headers['content-length'] || 0);194 if (Number.isFinite(contentLength) && contentLength > GIF_EXPORT_LIMITS.maxUploadBytes + (512 * 1024)) {195 return res.status(413).json({ success: false, error: 'GIF 프레임 전체 업로드는 80MB 이하여야 합니다.' });196 }197 198 gifUpload(req, res, (error) => {199 if (!error) return next();200 removeGifTempDir(req);201 const isLimitError = error instanceof multer.MulterError;202 return res.status(isLimitError ? 413 : 400).json({203 success: false,204 error: isLimitError205 ? 'GIF 프레임 업로드 제한을 초과했습니다.'206 : 'PNG 프레임 업로드를 처리할 수 없습니다.',207 });208 });209}210 211function runGifWorker(workerData) {212 return new Promise((resolve, reject) => {213 const workerPath = path.join(__dirname, 'workers', 'gif.worker.cjs');214 const worker = new Worker(workerPath, { workerData });215 let settled = false;216 217 worker.on('message', (message) => {218 if (settled) return;219 settled = true;220 if (message && message.success) resolve(message.outputPath);221 else reject(new Error(message?.error || 'GIF 인코딩에 실패했습니다.'));222 });223 224 worker.on('error', (error) => {225 if (settled) return;226 settled = true;227 reject(error);228 });229 230 worker.on('exit', (code) => {231 if (code !== 0 && !settled) {232 settled = true;233 reject(new Error(`GIF 워커가 비정상적으로 종료되었습니다 (code: ${code}).`));234 }235 });236 });237}238 239function parseGifExportOptions(value, frameCount) {240 let options;241 try {242 options = JSON.parse(value || '{}');243 } catch (_) {244 throw Object.assign(new Error('GIF 내보내기 옵션을 읽을 수 없습니다.'), { statusCode: 400 });245 }246 247 if (!options || typeof options !== 'object' || Array.isArray(options)) {248 throw Object.assign(new Error('GIF 내보내기 옵션 형식이 올바르지 않습니다.'), { statusCode: 400 });249 }250 251 const { durationMs, fps, delays, loopCount = 0 } = options;252 const validDuration = Number.isInteger(durationMs) && durationMs >= 100 && durationMs <= GIF_EXPORT_LIMITS.maxDurationMs;253 const validFps = Number.isInteger(fps) && fps >= 1 && fps <= 60;254 const validDelays = Array.isArray(delays)255 && delays.length === frameCount256 && delays.every(delay => Number.isInteger(delay) && delay > 0 && delay <= GIF_EXPORT_LIMITS.maxDurationMs)257 && delays.reduce((sum, delay) => sum + delay, 0) === durationMs;258 const validLoop = Number.isInteger(loopCount) && loopCount >= 0 && loopCount <= 65535;259 260 if (!validDuration || !validFps || !validDelays || !validLoop) {261 throw Object.assign(new Error('GIF 길이, FPS, delay 또는 반복 옵션이 유효하지 않습니다.'), { statusCode: 400 });262 }263 264 return { delays, loopCount };265}266 267async function validateGifPngFrames(files) {268 let expectedWidth = null;269 let expectedHeight = null;270 271 for (const file of files) {272 const signature = Buffer.alloc(8);273 const handle = await fs.promises.open(file.path, 'r');274 try {275 await handle.read(signature, 0, signature.length, 0);276 } finally {277 await handle.close();278 }279 280 if (!signature.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {281 throw Object.assign(new Error('PNG가 아닌 프레임이 포함되어 있습니다.'), { statusCode: 400 });282 }283 284 let metadata;285 try {286 metadata = await sharp(file.path, { animated: false, limitInputPixels: 100_000_000 }).metadata();287 } catch (_) {288 throw Object.assign(new Error('손상되었거나 너무 큰 PNG 프레임이 포함되어 있습니다.'), { statusCode: 400 });289 }290 if (metadata.format !== 'png' || !metadata.width || !metadata.height) {291 throw Object.assign(new Error('PNG 프레임 크기를 확인할 수 없습니다.'), { statusCode: 400 });292 }293 if (expectedWidth === null) {294 expectedWidth = metadata.width;295 expectedHeight = metadata.height;296 } else if (metadata.width !== expectedWidth || metadata.height !== expectedHeight) {297 throw Object.assign(new Error('모든 PNG 프레임의 크기는 같아야 합니다.'), { statusCode: 400 });298 }299 }300}301 302function sanitizeUploadName(name, fallback) {303 const parsed = path.parse(name || fallback || 'image');304 const safeBase = (parsed.name || 'image').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').trim() || 'image';305 const safeExt = (parsed.ext || '').toLowerCase().replace(/[^.a-z0-9]/g, '');306 return `${safeBase}${safeExt}`;307}308 309function uniquePathInDir(dir, fileName, usedNames) {310 const ext = path.extname(fileName);311 const base = path.basename(fileName, ext);312 let candidate = fileName;313 let index = 2;314 315 while (usedNames.has(candidate.toLowerCase()) || fs.existsSync(path.join(dir, candidate))) {316 candidate = `${base}_${index}${ext}`;317 index += 1;318 }319 320 usedNames.add(candidate.toLowerCase());321 return path.join(dir, candidate);322}323 324function collectUploadedImageFiles(req) {325 const files = req.files?.files || [];326 return Array.isArray(files) ? files : [];327}328 329function collectUploadedHtmlFile(req) {330 const files = req.files?.htmlFile || [];331 return Array.isArray(files) ? files[0] : null;332}333 334function isLocalRequest(req) {335 const remoteAddress = req.socket?.remoteAddress || req.ip || '';336 return [337 '::1',338 '127.0.0.1',339 '::ffff:127.0.0.1',340 ].includes(remoteAddress) || remoteAddress.endsWith(':127.0.0.1');341}342 343function imageUploadMiddleware(req, res, next) {344 imageUpload.fields([{ name: 'files', maxCount: 100 }, { name: 'htmlFile', maxCount: 1 }])(req, res, (err) => {345 if (!err) {346 next();347 return;348 }349 350 const errorMessage =351 err.code === 'LIMIT_FILE_SIZE'352 ? '단일 파일은 50MB 이하만 업로드할 수 있습니다.'353 : err.code === 'LIMIT_FILE_COUNT'354 ? '이미지는 최대 100개까지 업로드할 수 있습니다.'355 : err.message || '이미지 업로드 중 오류가 발생했습니다.';356 357 res.status(400).json({ success: false, error: errorMessage });358 });359}360 361function positiveInteger(value, fallback = 0) {362 const parsed = Number.parseInt(String(value ?? ''), 10);363 return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;364}365 366function clamp(value, min, max) {367 return Math.min(Math.max(value, min), max);368}369 370function expectedCutPositions(totalPixels, options) {371 const maxPixels = positiveInteger(options.maxPixels, 3000);372 const minLastChunkPixels = Math.max(0, positiveInteger(options.minLastChunkPixels, 300));373 const cuts = [];374 let cursor = maxPixels;375 376 while (cursor < totalPixels - minLastChunkPixels) {377 cuts.push(cursor);378 cursor += maxPixels;379 }380 381 return cuts;382}383 384function parseGeminiJson(text) {385 const cleaned = String(text || '')386 .trim()387 .replace(/^```(?:json)?/i, '')388 .replace(/```$/i, '')389 .trim();390 return JSON.parse(cleaned);391}392 393function normalizeGeminiCuts(rawCuts, totalPixels, options) {394 if (!Array.isArray(rawCuts)) return [];395 const minGap = Math.max(40, Math.min(positiveInteger(options.minLastChunkPixels, 300), positiveInteger(options.maxPixels, 3000) - 1));396 const cuts = Array.from(new Set(397 rawCuts398 .map(value => Number(value))399 .filter(value => Number.isFinite(value))400 .map(value => Math.round(value))401 .map(value => clamp(value, 1, totalPixels - 1))402 )).sort((a, b) => a - b);403 404 const filtered = [];405 let previous = 0;406 for (const cut of cuts) {407 if (cut - previous < minGap) continue;408 if (totalPixels - cut < minGap) continue;409 filtered.push(cut);410 previous = cut;411 }412 413 return filtered;414}415 416async function buildGeminiSplitPreview(inputPath, options) {417 const metadata = await sharp(inputPath, { animated: false }).rotate().metadata();418 const axis = options.axis === 'horizontal' ? 'horizontal' : 'vertical';419 const originalWidth = metadata.width || 1;420 const originalHeight = metadata.height || 1;421 422 const { data, info } = await sharp(inputPath, { animated: false })423 .rotate()424 .jpeg({ quality: 88, mozjpeg: true })425 .toBuffer({ resolveWithObject: true });426 427 const originalAxisPixels = axis === 'vertical' ? originalHeight : originalWidth;428 const previewAxisPixels = axis === 'vertical' ? info.height : info.width;429 430 return {431 data,432 mimeType: 'image/jpeg',433 axis,434 originalWidth,435 originalHeight,436 previewWidth: info.width,437 previewHeight: info.height,438 originalAxisPixels,439 previewAxisPixels,440 axisScale: previewAxisPixels / originalAxisPixels,441 isOriginalResolution: info.width === originalWidth && info.height === originalHeight,442 };443}444 445function sectionLengthsFromCuts(cuts, totalPixels) {446 const points = [0, ...cuts, totalPixels];447 const lengths = [];448 for (let index = 0; index < points.length - 1; index += 1) {449 lengths.push(points[index + 1] - points[index]);450 }451 return lengths;452}453 454function findGeminiCutViolations(cuts, totalPixels, options) {455 const maxPixels = positiveInteger(options.maxPixels, 3000);456 const minLastChunkPixels = Math.max(0, positiveInteger(options.minLastChunkPixels, 300));457 const lengths = sectionLengthsFromCuts(cuts, totalPixels);458 return lengths459 .map((length, index) => ({ index: index + 1, length }))460 .filter(item => item.length > maxPixels || item.length < minLastChunkPixels);461}462 463async function requestGeminiJson({ apiKey, model, prompt, imageBuffer, mimeType }) {464 let lastError = null;465 466 for (let attempt = 0; attempt < 3; attempt += 1) {467 const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(apiKey)}`, {468 method: 'POST',469 headers: { 'Content-Type': 'application/json' },470 body: JSON.stringify({471 contents: [472 {473 role: 'user',474 parts: [475 { text: prompt },476 {477 inline_data: {478 mime_type: mimeType,479 data: imageBuffer.toString('base64'),480 },481 },482 ],483 },484 ],485 generationConfig: {486 temperature: 0.1,487 responseMimeType: 'application/json',488 },489 }),490 });491 492 if (response.ok) {493 const payload = await response.json();494 const text = payload?.candidates?.[0]?.content?.parts?.map(part => part.text || '').join('\n') || '';495 return parseGeminiJson(text);496 }497 498 lastError = new Error(`Gemini API ?붿껌 ?ㅽ뙣: ${response.status} ${errorText.slice(0, 180)}`);499 if (![429, 500, 502, 503, 504].includes(response.status) || attempt === 2) {500 throw lastError;501 }502 503 await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));504 }505 506 throw lastError || new Error('Gemini API ?붿껌 ?ㅽ뙣');507}508 509async function buildGeminiCrop(inputPath, crop, axis) {510 const extract =511 axis === 'vertical'512 ? { left: 0, top: crop.start, width: crop.width, height: crop.length }513 : { left: crop.start, top: 0, width: crop.length, height: crop.height };514 515 const { data, info } = await sharp(inputPath, { animated: false })516 .rotate()517 .extract(extract)518 .jpeg({ quality: 90, mozjpeg: true })519 .toBuffer({ resolveWithObject: true });520 521 return {522 data,523 mimeType: 'image/jpeg',524 width: info.width,525 height: info.height,526 };527}528 529async function requestGeminiWindowedSplitCuts(inputPath, options, failedWholeImageError) {530 const apiKey = process.env.GEMINI_API_KEY;531 const model = process.env.GEMINI_IMAGE_SPLIT_MODEL || 'gemini-2.5-flash';532 const metadata = await sharp(inputPath, { animated: false }).rotate().metadata();533 const axis = options.axis === 'horizontal' ? 'horizontal' : 'vertical';534 const originalWidth = metadata.width || 1;535 const originalHeight = metadata.height || 1;536 const totalPixels = axis === 'vertical' ? originalHeight : originalWidth;537 const maxPixels = positiveInteger(options.maxPixels, 3000);538 const minLastChunkPixels = Math.max(0, positiveInteger(options.minLastChunkPixels, 300));539 const lookAheadPixels = Math.min(maxPixels, Math.max(900, Math.round(maxPixels * 0.55)));540 const cuts = [];541 const reasons = [];542 let cursor = 0;543 544 while (totalPixels - cursor > maxPixels) {545 const remaining = totalPixels - cursor;546 const cropLength = Math.min(remaining, maxPixels + lookAheadPixels);547 const crop = axis === 'vertical'548 ? { start: cursor, length: cropLength, width: originalWidth, height: cropLength }549 : { start: cursor, length: cropLength, width: cropLength, height: originalHeight };550 const cropImage = await buildGeminiCrop(inputPath, crop, axis);551 const maxLocalCut = Math.min(maxPixels, remaining - minLastChunkPixels);552 const minLocalCut = Math.max(minLastChunkPixels, 1);553 554 const prompt = [555 'You are splitting a long commerce/detail-page image into natural sections.',556 'This attached image is an ORIGINAL-RESOLUTION crop from the page, not a resized overview.',557 'The current output section starts at the top/left edge of this crop.',558 'Choose exactly ONE next cut position inside this crop.',559 'The cut must be a local crop coordinate, not a global page coordinate.',560 'The cut must be at a real semantic boundary under the hard maximum length.',561 'Never cut through a product photo, image card, heading, subheading, table, spec block, notice block, Q&A block, color chart, or a heading/subheading and the content directly below it.',562 'If the cleanest section boundary is slightly before the maximum, choose that boundary.',563 'If a visual section is longer than the maximum, choose the least harmful internal whitespace before the maximum.',564 `Hard local cut range: ${minLocalCut}px <= cut <= ${maxLocalCut}px.`,565 `Original full page size: ${originalWidth}x${originalHeight}px.`,566 `Crop global ${axis === 'vertical' ? 'Y' : 'X'} range: ${cursor} to ${cursor + cropLength}px.`,567 `Crop image size: ${cropImage.width}x${cropImage.height}px.`,568 `Maximum final section length: ${maxPixels}px.`,569 `Minimum final section length: ${minLastChunkPixels}px.`,570 'Return JSON only. No markdown.',571 '{"coordinateBasis":"crop","cut":number,"reason":"short Korean explanation"}',572 ].join('\n');573 574 const parsed = await requestGeminiJson({575 apiKey,576 model,577 prompt,578 imageBuffer: cropImage.data,579 mimeType: cropImage.mimeType,580 });581 582 const localCut = Math.round(Number(parsed.cut));583 if (!Number.isFinite(localCut) || localCut < minLocalCut || localCut > maxLocalCut) {584 throw new Error(`Gemini가 유효하지 않은 crop cut을 반환했습니다: ${parsed.cut}. 허용 범위 ${minLocalCut}-${maxLocalCut}px.`);585 }586 587 const globalCut = cursor + localCut;588 if (globalCut <= cursor || globalCut >= totalPixels) {589 throw new Error(`Gemini crop cut 변환이 유효하지 않습니다: ${globalCut}px.`);590 }591 592 cuts.push(globalCut);593 reasons.push(`${globalCut}px: ${parsed.reason || ''}`.trim());594 cursor = globalCut;595 }596 597 const violations = findGeminiCutViolations(cuts, totalPixels, options);598 if (violations.length > 0) {599 const summary = violations.map(item => `${item.index}번 섹션 ${item.length}px`).join(', ');600 throw new Error(`Gemini windowed split이 설정한 분할 기준을 지키지 못했습니다: ${summary}.`);601 }602 603 return {604 cuts,605 model,606 preview: {607 axis,608 originalWidth,609 originalHeight,610 previewWidth: originalWidth,611 previewHeight: originalHeight,612 originalAxisPixels: totalPixels,613 previewAxisPixels: totalPixels,614 axisScale: 1,615 isOriginalResolution: true,616 },617 reason: `전체 원본 이미지는 API가 거절하여 원본 해상도 crop 방식으로 판단했습니다. ${reasons.join(' / ')}`,618 coordinateBasis: 'original-windowed',619 rawOriginalCuts: cuts,620 wholeImageError: failedWholeImageError?.message || '',621 };622}623 624async function requestGeminiSplitCuts(inputPath, options) {625 const apiKey = process.env.GEMINI_API_KEY;626 if (!apiKey) {627 throw new Error('GEMINI_API_KEY가 설정되어 있지 않습니다.');628 }629 630 const model = process.env.GEMINI_IMAGE_SPLIT_MODEL || 'gemini-2.5-flash';631 const preview = await buildGeminiSplitPreview(inputPath, options);632 const totalPixels = preview.originalAxisPixels;633 const approximateOriginalCuts = expectedCutPositions(totalPixels, options);634 const maxPixels = positiveInteger(options.maxPixels, 3000);635 const searchWindow = positiveInteger(options.searchWindow, 300);636 const minLastChunkPixels = Math.max(0, positiveInteger(options.minLastChunkPixels, 300));637 const originalSearchWindow = Math.max(80, searchWindow);638 639 if (totalPixels <= maxPixels) {640 return { cuts: [], model, preview };641 }642 643 const prompt = [644 'You are helping split a long commerce/detail-page image into natural sections.',645 'The attached image preserves the ORIGINAL pixel dimensions. Read the page structure directly from this image.',646 'Your job is to return every cut position needed to split the page into natural sections.',647 'A section means a meaningful visual group: heading/subheading, body text, related product images, callouts, tables, warnings, Q&A blocks, specs, and footer blocks that belong together.',648 'Choose cut positions that preserve visual meaning while always respecting the maximum section length.',649 'Never cut through product photos, image cards, important text, tables, specification tables, color charts, product-card groups, section headings, or a heading/subheading and the content directly below it.',650 'A section heading and its following body are atomic. Do not cut immediately after or underneath headings such as product names, feature titles, table titles, Q&A titles, notice titles, spec titles, PEN INFO, or PEN COLOR.',651 'If a meaningful section is longer than the maximum length, split it at the cleanest internal whitespace between sub-blocks, not through an image or heading.',652 'Prefer real section boundaries, whitespace between cards, gutters between image blocks, repeated point markers, or quiet background bands.',653 `Hard limit: every final section MUST be ${maxPixels}px or shorter in the ORIGINAL image. Do not return cuts that would create a section taller/longer than ${maxPixels}px.`,654 `Within that hard limit, choose the most natural content boundaries. If a meaningful group is longer than ${maxPixels}px, split it at the cleanest internal whitespace or quiet band before reaching ${maxPixels}px.`,655 `Do not create ANY section shorter than ${minLastChunkPixels}px in the ORIGINAL image.`,656 'Prefer fewer, larger, meaningful sections only when every section still respects the hard maximum length.',657 'If you are unsure whether a boundary splits a section, choose the cleanest boundary before the hard maximum length instead of exceeding it.',658 'If a color chart, specification table, product image group, or ending brand/footer block would become a tiny fragment, keep it attached to the previous or next meaningful section.',659 'Before returning, mentally verify every resulting section length between consecutive cuts is within the hard limit and that no image card or heading group is sliced.',660 'Return JSON only. No markdown.',661 '',662 `Axis: ${preview.axis}.`,663 `Original image size: ${preview.originalWidth}x${preview.originalHeight}px.`,664 `Attached image size: ${preview.previewWidth}x${preview.previewHeight}px.`,665 `Attached image resolution basis: ${preview.isOriginalResolution ? 'original-resolution' : 'scaled'}.`,666 `Return cut positions in ORIGINAL pixels along the ${preview.axis === 'vertical' ? 'Y axis from top to bottom' : 'X axis from left to right'}.`,667 `Approximate ORIGINAL cut positions for rough planning only. Add, move, or remove cuts as needed, but every final section must be ${maxPixels}px or shorter: ${approximateOriginalCuts.join(', ') || 'none'}.`,668 `Do not optimize for staying within ${originalSearchWindow}px of the rough positions. Optimize for valid section boundaries under the hard maximum length.`,669 '',670 'JSON schema:',671 '{"coordinateBasis":"original","cuts":[number],"reason":"short Korean explanation"}',672 ].join('\n');673 674 const parsed = await requestGeminiJson({675 apiKey,676 model,677 prompt,678 imageBuffer: preview.data,679 mimeType: preview.mimeType,680 });681 682 const coordinateBasis = String(parsed.coordinateBasis || 'original').toLowerCase();683 const rawCuts = Array.isArray(parsed.cuts) && coordinateBasis.includes('preview')684 ? parsed.cuts.map(value => Number(value) / preview.axisScale)685 : parsed.cuts;686 const cuts = normalizeGeminiCuts(rawCuts, totalPixels, options);687 const violations = findGeminiCutViolations(cuts, totalPixels, options);688 689 if (violations.length > 0) {690 const summary = violations.map(item => `${item.index}번 섹션 ${item.length}px`).join(', ');691 throw new Error(`Gemini가 설정한 분할 기준을 지키지 못했습니다: ${summary}. 최대 ${maxPixels}px 이하로 다시 시도해 주세요.`);692 }693 694 return {695 cuts,696 model,697 preview,698 reason: parsed.reason || '',699 coordinateBasis: coordinateBasis.includes('preview') ? 'preview-converted-to-original' : 'original',700 rawOriginalCuts: rawCuts || [],701 };702}703 704async function applyGeminiSplitOptions(inputPaths, options) {705 if (options.strategy !== 'ai-flow') return options;706 707 const manualCutsByFile = {};708 const aiResults = [];709 710 for (const inputPath of inputPaths) {711 let result;712 try {713 result = await requestGeminiSplitCuts(inputPath, options);714 } catch (err) {715 if (!String(err?.message || '').includes('Unable to process input image')) {716 throw err;717 }718 result = await requestGeminiWindowedSplitCuts(inputPath, options, err);719 }720 manualCutsByFile[path.basename(inputPath)] = result.cuts;721 aiResults.push({722 fileName: path.basename(inputPath),723 cuts: result.cuts,724 reason: result.reason,725 model: result.model,726 coordinateBasis: result.coordinateBasis,727 wholeImageError: result.wholeImageError,728 });729 }730 731 return {732 ...options,733 strategy: 'ai-flow',734 manualCutsByFile,735 aiFlow: {736 provider: 'gemini',737 model: aiResults[0]?.model || process.env.GEMINI_IMAGE_SPLIT_MODEL || 'gemini-2.5-flash',738 results: aiResults,739 },740 };741}742 743// Route 1.4: Encode browser-rendered PNG frames as an animated GIF744app.post('/gif-export', gifUploadMiddleware, async (req, res) => {745 const files = Array.isArray(req.files) ? req.files : [];746 747 try {748 if (files.length < 2 || files.length > GIF_EXPORT_LIMITS.maxFrames) {749 return res.status(400).json({ success: false, error: 'GIF에는 2~60개의 PNG 프레임이 필요합니다.' });750 }751 752 const totalBytes = files.reduce((sum, file) => sum + file.size, 0);753 if (totalBytes > GIF_EXPORT_LIMITS.maxUploadBytes) {754 return res.status(413).json({ success: false, error: 'GIF 프레임 전체 업로드는 80MB 이하여야 합니다.' });755 }756 757 const options = parseGifExportOptions(req.body.options, files.length);758 await validateGifPngFrames(files);759 760 const outputPath = path.join(req.gifTempDir, 'motion.gif');761 await runGifWorker({762 framePaths: files.map(file => file.path),763 outputPath,764 delays: options.delays,765 width: GIF_EXPORT_LIMITS.outputWidth,766 loop: options.loopCount,767 colors: 256,768 dither: 0.75,769 effort: 7,770 });771 772 res.set({773 'Content-Type': 'image/gif',774 'Content-Disposition': 'attachment; filename="motion.gif"',775 'Cache-Control': 'no-store',776 });777 await new Promise((resolve, reject) => {778 res.sendFile(outputPath, error => (error ? reject(error) : resolve()));779 });780 } catch (error) {781 console.error('[API Server] GIF export failed:', error);782 if (!res.headersSent) {783 res.status(error.statusCode || 500).json({784 success: false,785 error: error.statusCode ? error.message : 'GIF를 인코딩하지 못했습니다.',786 });787 }788 } finally {789 removeGifTempDir(req);790 }791});792 793// Route 1.5: Image toolkit operations for web browser uploads794app.post('/image-process', imageUploadMiddleware, async (req, res) => {795 const uploadedFiles = collectUploadedImageFiles(req);796 const uploadedHtmlFile = collectUploadedHtmlFile(req);797 const operation = req.body.operation;798 const workDir = path.join(tempDir, `image_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`);799 const inputDir = path.join(workDir, 'input');800 const outputDir = path.join(workDir, 'output');801 802 try {803 if (!['resize', 'stitch', 'split', 'html'].includes(operation)) {804 return res.status(400).json({ success: false, error: '지원하지 않는 이미지 작업입니다.' });805 }806 807 if (operation !== 'html' && uploadedFiles.length === 0) {808 return res.status(400).json({ success: false, error: '이미지 파일을 추가해주세요.' });809 }810 811 if (operation === 'html' && !uploadedHtmlFile && !String(req.body.htmlText || '').trim()) {812 return res.status(400).json({ success: false, error: 'HTML 파일을 업로드하거나 HTML 코드를 입력해주세요.' });813 }814 815 let options = {};816 try {817 options = req.body.options ? JSON.parse(req.body.options) : {};818 } catch (err) {819 return res.status(400).json({ success: false, error: '이미지 처리 옵션을 읽을 수 없습니다.' });820 }821 822 fs.mkdirSync(inputDir, { recursive: true });823 fs.mkdirSync(outputDir, { recursive: true });824 825 const usedNames = new Set();826 const inputPaths = [];827 for (const uploaded of uploadedFiles) {828 const originalName = Buffer.from(uploaded.originalname, 'latin1').toString('utf8');829 const safeName = sanitizeUploadName(originalName, uploaded.filename);830 const targetPath = uniquePathInDir(inputDir, safeName, usedNames);831 fs.renameSync(uploaded.path, targetPath);832 inputPaths.push(targetPath);833 }834 835 let htmlFilePath = '';836 if (uploadedHtmlFile) {837 const originalName = Buffer.from(uploadedHtmlFile.originalname, 'latin1').toString('utf8');838 const safeName = sanitizeUploadName(originalName, uploadedHtmlFile.filename);839 htmlFilePath = uniquePathInDir(inputDir, safeName, usedNames);840 fs.renameSync(uploadedHtmlFile.path, htmlFilePath);841 }842 843 const allowLocalSource = process.env.ALLOW_LOCAL_IMAGE_URLS === 'true' || isLocalRequest(req);844 845 let processedOptions = options;846 if (operation === 'split' && options.strategy === 'ai-flow') {847 try {848 processedOptions = await applyGeminiSplitOptions(inputPaths, options);849 } catch (err) {850 return res.status(400).json({851 success: false,852 error: err.message || 'Gemini AI split failed.',853 });854 }855 }856 857 const result = await runImageWorker({858 operation,859 inputPaths,860 htmlText: req.body.htmlText || '',861 htmlFilePath,862 outputDir,863 options: processedOptions,864 allowLocalUrls: allowLocalSource,865 allowFileUrls: allowLocalSource,866 });867 868 if (!result.success) {869 return res.status(400).json({ success: false, error: result.error || '이미지 처리 중 오류가 발생했습니다.' });870 }871 872 const zip = new JSZip();873 let hasManifestFile = false;874 for (const file of result.files || []) {875 if (!file.path || !fs.existsSync(file.path)) continue;876 const zipPath = file.relativePath || file.fileName || path.basename(file.path);877 if (zipPath === 'manifest.json') hasManifestFile = true;878 zip.file(zipPath, fs.readFileSync(file.path));879 }880 if (!hasManifestFile) {881 zip.file('manifest.json', JSON.stringify(result.manifest || { operation, count: result.files?.length || 0 }, null, 2));882 }883 884 const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });885 const zipName =886 operation === 'resize'887 ? 'image_resize_results.zip'888 : operation === 'stitch'889 ? 'image_stitch_results.zip'890 : operation === 'split'891 ? 'image_split_results.zip'892 : 'image_results.zip';893 894 res.setHeader('Content-Type', 'application/zip');895 res.setHeader('X-File-Name', encodeURIComponent(zipName));896 res.setHeader('Content-Disposition', `attachment; filename="${zipName}"`);897 res.send(zipBuffer);898 } catch (err) {899 console.error('[API Server] Image process failed:', err);900 if (!res.headersSent) {901 res.status(500).json({ success: false, error: err.message || '이미지 처리 중 오류가 발생했습니다.' });902 }903 } finally {904 for (const uploaded of uploadedFiles) {905 try {906 if (uploaded.path && fs.existsSync(uploaded.path)) fs.unlinkSync(uploaded.path);907 } catch (_) {}908 }909 if (uploadedHtmlFile) {910 try {911 if (uploadedHtmlFile.path && fs.existsSync(uploadedHtmlFile.path)) fs.unlinkSync(uploadedHtmlFile.path);912 } catch (_) {}913 }914 try {915 fs.rmSync(workDir, { recursive: true, force: true });916 } catch (_) {}917 }918});919 920// Route 2: Outline PDF/AI files921app.post('/process-outline', localUpload.single('file'), (req, res) => {922 if (!req.file) {923 return res.status(400).json({ success: false, error: '업로드된 파일이 없습니다.' });924 }925 926 const file = req.file;927 const filePath = file.path;928 // Fix multer's latin1 encoding issue for Korean filenames929 const originalNameUtf8 = Buffer.from(file.originalname, 'latin1').toString('utf8');930 const cleanName = path.parse(originalNameUtf8).name || 'document';931 932 // Create output paths in temporary folder933 const printPdfPath = path.join(tempDir, `(인쇄용)${cleanName}_${Date.now()}.pdf`);934 935 // Check if Ghostscript exists (either local or system-wide)936 if (gsPath !== 'gs' && !fs.existsSync(gsPath)) {937 try { fs.unlinkSync(filePath); } catch (_) {}938 return res.status(500).json({ success: false, error: '변환 엔진(Ghostscript)이 준비되지 않았습니다.' });939 }940 941 // Execute Ghostscript outliner942 const args = [943 '-dNOPAUSE',944 '-dBATCH',945 '-sDEVICE=pdfwrite',946 '-dCompatibilityLevel=1.6',947 '-dPDFSETTINGS=/prepress',948 '-dNoOutputFonts=true',949 '-dUseCropBox',950 '-o', printPdfPath,951 ];952 953 if (gsLibPath) {954 args.unshift(`-I${gsLibPath}`);955 }956 957 args.push(filePath);958 959 execFile(gsPath, args, (err, stdout, stderr) => {960 // Delete raw uploaded file961 try { fs.unlinkSync(filePath); } catch (_) {}962 963 if (err) {964 console.error('[API Server] Outline failed:', err, stderr);965 return res.status(500).json({ success: false, error: `아웃라인 처리 실패: ${stderr || err.message}` });966 }967 968 // Read the outlined PDF as base64 and return969 try {970 const fileBuffer = fs.readFileSync(printPdfPath);971 const base64Data = fileBuffer.toString('base64');972 973 // Clean up output file974 try { fs.unlinkSync(printPdfPath); } catch (_) {}975 976 res.json({977 success: true,978 fileName: `(인쇄용)${cleanName}.pdf`,979 originalName: cleanName,980 fileData: base64Data981 });982 } catch (readErr) {983 console.error('[API Server] File read error:', readErr);984 res.status(500).json({ success: false, error: '출력 파일 리딩에 실패했습니다.' });985 }986 });987});988 989// Route 2.25: Preview Illustrator/PDF/SVG/EPS files990const ILLUSTRATOR_PREVIEW_MAX_BYTES = 100 * 1024 * 1024;991const illustratorPreviewUpload = multer({992 dest: tempDir,993 limits: { files: 1, fileSize: ILLUSTRATOR_PREVIEW_MAX_BYTES },994}).single('file');995 996function illustratorPreviewUploadMiddleware(req, res, next) {997 illustratorPreviewUpload(req, res, (error) => {998 if (!error) return next();999 if (req.file?.path) {1000 try { fs.unlinkSync(req.file.path); } catch (_) {}1001 }1002 const isLimitError = error instanceof multer.MulterError;1003 return res.status(isLimitError ? 413 : 400).json({1004 success: false,1005 error: isLimitError1006 ? 'AI/EPS 파일은 100MB 이하만 업로드할 수 있습니다.'1007 : 'AI/EPS 업로드를 처리할 수 없습니다.',1008 });1009 });1010}1011 1012app.post('/preview-illustrator', illustratorPreviewUploadMiddleware, (req, res) => {1013 if (!req.file) {1014 return res.status(400).json({ success: false, error: '업로드된 파일이 없습니다.' });1015 }1016 1017 const uploadedPath = req.file.path;1018 const originalNameUtf8 = Buffer.from(req.file.originalname, 'latin1').toString('utf8');1019 const ext = path.extname(originalNameUtf8).replace('.', '').toLowerCase();1020 const baseName = path.parse(originalNameUtf8).name || 'preview';1021 const allowed = new Set(['ai', 'eps', 'pdf', 'svg']);1022 1023 if (!allowed.has(ext)) {1024 try { fs.unlinkSync(uploadedPath); } catch (_) {}1025 return res.status(400).json({ success: false, error: 'AI, EPS, SVG, PDF 파일만 미리보기할 수 있습니다.' });1026 }1027 1028 if (ext === 'svg') {1029 try {1030 const svgData = fs.readFileSync(uploadedPath).toString('base64');1031 try { fs.unlinkSync(uploadedPath); } catch (_) {}1032 return res.json({1033 success: true,1034 mode: 'image',1035 fileName: baseName + '.svg',1036 mimeType: 'image/svg+xml',1037 fileData: svgData1038 });1039 } catch (err) {1040 try { fs.unlinkSync(uploadedPath); } catch (_) {}1041 return res.status(500).json({ success: false, error: 'SVG 파일을 읽지 못했습니다.' });1042 }1043 }1044 1045 const outputPath = path.join(tempDir, 'illustrator_preview_' + Date.now() + '_' + Math.random().toString(36).slice(2) + '.pdf');1046 const args = [1047 '-dSAFER',1048 '-dBATCH',1049 '-dNOPAUSE',1050 '-sDEVICE=pdfwrite',1051 '-dCompatibilityLevel=1.6',1052 '-dPDFSETTINGS=/prepress',1053 '-sOutputFile=' + outputPath,1054 ];1055 if (gsLibPath) args.unshift('-I' + gsLibPath);1056 args.push(uploadedPath);1057 1058 execFile(gsPath, args, (err, stdout, stderr) => {1059 try { fs.unlinkSync(uploadedPath); } catch (_) {}1060 1061 if (err) {1062 console.error('[API Server] Illustrator preview failed:', err, stderr);1063 try { fs.unlinkSync(outputPath); } catch (_) {}1064 const ghostscriptUnavailable = err.code === 'ENOENT';1065 return res.status(500).json({1066 success: false,1067 error: ghostscriptUnavailable1068 ? '변환 엔진(Ghostscript)을 찾을 수 없습니다. 서버에 Ghostscript를 설치하거나 내장 실행 파일을 확인해 주세요.'1069 : 'PDF 호환 저장된 AI 파일이 아니거나 EPS 변환에 실패했습니다.'1070 });1071 }1072 1073 try {1074 const outputStats = fs.statSync(outputPath);1075 if (outputStats.size <= 0 || outputStats.size > ILLUSTRATOR_PREVIEW_MAX_BYTES) {1076 try { fs.unlinkSync(outputPath); } catch (_) {}1077 return res.status(413).json({ success: false, error: '변환된 AI/EPS PDF는 100MB 이하여야 합니다.' });1078 }1079 const fileData = fs.readFileSync(outputPath).toString('base64');1080 try { fs.unlinkSync(outputPath); } catch (_) {}1081 return res.json({1082 success: true,1083 mode: 'pdf',1084 fileName: baseName + '.pdf',1085 mimeType: 'application/pdf',1086 fileData1087 });1088 } catch (readErr) {1089 console.error('[API Server] Illustrator preview read failed:', readErr);1090 try { fs.unlinkSync(outputPath); } catch (_) {}1091 return res.status(500).json({ success: false, error: '변환된 미리보기 파일을 읽지 못했습니다.' });1092 }1093 });1094});1095 1096// Route 2.5: Fast Render PDF for Instant Visual Compare (No AI analysis)1097app.post('/quick-render', localUpload.fields([{ name: 'fileA', maxCount: 1 }, { name: 'fileB', maxCount: 1 }]), async (req, res) => {1098 if (!req.files || !req.files['fileA'] || !req.files['fileB']) {1099 return res.status(400).json({ success: false, error: '렌더링할 파일 2개가 모두 필요합니다.' });1100 }1101 1102 const fileAPath = req.files['fileA'][0].path;1103 const fileBPath = req.files['fileB'][0].path;1104 1105 const tempSubDir = path.join(tempDir, `quick_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`);1106 try {1107 fs.mkdirSync(tempSubDir, { recursive: true });1108 } catch (err) {1109 try { fs.unlinkSync(fileAPath); } catch (_) {}1110 try { fs.unlinkSync(fileBPath); } catch (_) {}1111 return res.status(500).json({ success: false, error: '임시 디렉토리 생성에 실패했습니다.' });1112 }1113 1114 const renderPDF = (filePath, outPattern) => {1115 return new Promise((resolve, reject) => {1116 const args = [1117 '-dSAFER', '-dBATCH', '-dNOPAUSE',1118 '-sDEVICE=png16m', `-r150`,1119 '-dUseCropBox',1120 `-sOutputFile=${outPattern}`,1121 ];1122 if (gsLibPath) {1123 args.unshift(`-I${gsLibPath}`);1124 }1125 args.push(filePath);1126 1127 execFile(gsPath, args, (err, stdout, stderr) => {1128 if (err) reject(new Error(stderr || err.message));1129 else resolve();1130 });1131 });1132 };1133 1134 try {1135 // Render A and B in parallel using Ghostscript1136 await Promise.all([1137 renderPDF(fileAPath, path.join(tempSubDir, 'a%d.png')),1138 renderPDF(fileBPath, path.join(tempSubDir, 'b%d.png'))1139 ]);1140 1141 // Read the directory to find rendered images1142 const files = fs.readdirSync(tempSubDir);1143 const pagesA = [];1144 const pagesB = [];1145 1146 // Filter and sort pages1147 const aImages = files.filter(f => f.startsWith('a') && f.endsWith('.png'))1148 .sort((x, y) => parseInt(x.slice(1)) - parseInt(y.slice(1)));1149 const bImages = files.filter(f => f.startsWith('b') && f.endsWith('.png'))1150 .sort((x, y) => parseInt(x.slice(1)) - parseInt(y.slice(1)));1151 1152 for (const f of aImages) {1153 const pageNum = parseInt(f.slice(1, -4), 10);1154 const imgPath = path.join(tempSubDir, f);1155 const base64 = fs.readFileSync(imgPath).toString('base64');1156 pagesA.push({ page: pageNum, img: `data:image/png;base64,${base64}` });1157 }1158 1159 for (const f of bImages) {1160 const pageNum = parseInt(f.slice(1, -4), 10);1161 const imgPath = path.join(tempSubDir, f);1162 const base64 = fs.readFileSync(imgPath).toString('base64');1163 pagesB.push({ page: pageNum, img: `data:image/png;base64,${base64}` });1164 }1165 1166 res.json({1167 success: true,1168 pagesA,1169 pagesB1170 });1171 1172 } catch (err) {1173 console.error('[API Server] Quick render failed:', err);1174 res.status(500).json({ success: false, error: `초고속 렌더링 실패: ${err.message}` });1175 } finally {1176 // Cleanup temporary files1177 try { fs.unlinkSync(fileAPath); } catch (_) {}1178 try { fs.unlinkSync(fileBPath); } catch (_) {}1179 try {1180 fs.rmSync(tempSubDir, { recursive: true, force: true });1181 } catch (_) {}1182 }1183});1184 1185// Route 3: Initiate PDF Comparison (Asynchronous Task)1186app.post('/compare-pdfs', localUpload.fields([{ name: 'fileA', maxCount: 1 }, { name: 'fileB', maxCount: 1 }]), (req, res) => {1187 if (!req.files || !req.files['fileA'] || !req.files['fileB']) {1188 return res.status(400).json({ success: false, error: '비교할 파일 2개가 모두 필요합니다.' });1189 }1190 1191 const fileAPath = req.files['fileA'][0].path;1192 const fileBPath = req.files['fileB'][0].path;1193 const sensitivity = req.body.sensitivity || 'standard';1194 1195 const taskId = Date.now().toString() + '_' + Math.random().toString(36).substring(2, 11);1196 console.log(`[API Server] Created comparison task: ${taskId}`);1197 1198 tasks.set(taskId, {1199 status: 'running',1200 result: null,