CoolFace
Apppublic

KOOHAWN/AI_PDF_TOOLKIt

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
server.cjs1289 linesDownload Raw Back to root
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,

Showing the first 1,200 of 1289 lines. Download the file for the rest.