Spaces:
Sleeping
Sleeping
Download node-backend/server.js from manojkumarsh/pdfbazaar: direct link, hf CLI and curl.
- Browser
- Download file 57.7 kB
-
https://huggingface.co/spaces/manojkumarsh/pdfbazaar/resolve/main/node-backend/server.js
- Command line
-
hf download hf://spaces/manojkumarsh/pdfbazaar/node-backend/server.js
-
curl -L -o server.js https://huggingface.co/spaces/manojkumarsh/pdfbazaar/resolve/main/node-backend/server.js
57.7 kB
| const express = require('express'); | |
| const cors = require('cors'); | |
| const multer = require('multer'); | |
| const path = require('path'); | |
| const fs = require('fs'); | |
| const https = require('https'); | |
| const { PDFDocument, rgb, degrees } = require('pdf-lib'); | |
| const fontkit = require('@pdf-lib/fontkit'); | |
| const { exec } = require('child_process'); | |
| require('regenerator-runtime/runtime'); | |
| require('dotenv').config(); | |
| const app = express(); | |
| const port = process.env.PORT || 5000; | |
| app.use(cors({ | |
| exposedHeaders: ['Content-Disposition'] | |
| })); | |
| app.use(express.json({ limit: '50mb' })); | |
| app.use(express.urlencoded({ limit: '50mb', extended: true })); | |
| // Setup multer for file uploads | |
| const uploadDir = path.join(__dirname, 'uploads'); | |
| if (!fs.existsSync(uploadDir)) { | |
| fs.mkdirSync(uploadDir); | |
| } | |
| const storage = multer.diskStorage({ | |
| destination: function (req, file, cb) { | |
| cb(null, uploadDir); | |
| }, | |
| filename: function (req, file, cb) { | |
| cb(null, Date.now() + '-' + file.originalname.replace(/\s+/g, '_')); | |
| } | |
| }); | |
| const upload = multer({ storage: storage, limits: { fileSize: 25 * 1024 * 1024 } }); // 25MB limit | |
| // Auto delete processed files after 1 hour Setup | |
| const cleanupOldFiles = () => { | |
| fs.readdir(uploadDir, (err, files) => { | |
| if (err) return; | |
| for (const file of files) { | |
| const filePath = path.join(uploadDir, file); | |
| fs.stat(filePath, (err, stat) => { | |
| if (err) return; | |
| const now = new Date().getTime(); | |
| const endTime = new Date(stat.ctime).getTime() + 3600000; // 1 hour | |
| if (now > endTime) { | |
| fs.unlink(filePath, err => { | |
| if (err) console.error(err); | |
| else console.log(`Auto deleted: ${filePath}`); | |
| }); | |
| } | |
| }); | |
| } | |
| }); | |
| }; | |
| setInterval(cleanupOldFiles, 3600000); // Check every hour | |
| app.get('/api/health', (req, res) => { | |
| res.status(200).json({ success: true, message: "API is running successfully 🚀", env: process.env.NODE_ENV }); | |
| }); | |
| const getGsCommand = () => { | |
| return process.platform === 'win32' ? 'gswin64c' : 'gs'; | |
| }; | |
| const getSofficeCommand = () => { | |
| if (process.platform === 'win32') { | |
| const winPath = 'C:\\Program Files\\LibreOffice\\program\\soffice.exe'; | |
| return fs.existsSync(winPath) ? `"${winPath}"` : 'soffice'; | |
| } | |
| return 'soffice'; | |
| }; | |
| // Helper functions for pdf-lib based tools | |
| async function processPdfLibTool(toolId, buffers, options) { | |
| try { | |
| if (toolId === 'merge-pdf') { | |
| const mergedPdf = await PDFDocument.create(); | |
| for (const buffer of buffers) { | |
| const pdf = await PDFDocument.load(buffer, { ignoreEncryption: true }); | |
| const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices()); | |
| copiedPages.forEach((page) => mergedPdf.addPage(page)); | |
| } | |
| return await mergedPdf.save(); | |
| } | |
| else if (toolId === 'split-pdf') { | |
| const pdfDoc = await PDFDocument.load(buffers[0]); | |
| let pageIndices = []; | |
| const { pageRange } = options || {}; | |
| const totalPages = pdfDoc.getPageCount(); | |
| if (pageRange && pageRange.trim() !== '') { | |
| // Parse ranges like "1-5, 8, 11-13" into indices | |
| const parts = pageRange.split(','); | |
| for (const part of parts) { | |
| if (part.includes('-')) { | |
| let [start, end] = part.split('-').map(n => parseInt(n.trim(), 10)); | |
| if (!isNaN(start) && !isNaN(end)) { | |
| start = Math.max(1, start); | |
| end = Math.min(totalPages, end); | |
| for (let i = start; i <= end; i++) pageIndices.push(i - 1); | |
| } | |
| } else { | |
| const page = parseInt(part.trim(), 10); | |
| if (!isNaN(page) && page >= 1 && page <= totalPages) { | |
| pageIndices.push(page - 1); | |
| } | |
| } | |
| } | |
| } else { | |
| for (let i = 0; i < totalPages; i++) pageIndices.push(i); | |
| } | |
| if (pageIndices.length === 0) pageIndices = [0]; // fallback | |
| pageIndices = [...new Set(pageIndices)].sort((a, b) => a - b); | |
| const AdmZip = require('adm-zip'); | |
| const zip = new AdmZip(); | |
| for (let i = 0; i < pageIndices.length; i++) { | |
| const newPdf = await PDFDocument.create(); | |
| const [copiedPage] = await newPdf.copyPages(pdfDoc, [pageIndices[i]]); | |
| newPdf.addPage(copiedPage); | |
| const pdfBytes = await newPdf.save(); | |
| zip.addFile(`page_${pageIndices[i] + 1}.pdf`, Buffer.from(pdfBytes)); | |
| } | |
| if (pageIndices.length === 1) { | |
| const newPdf = await PDFDocument.create(); | |
| const [copiedPage] = await newPdf.copyPages(pdfDoc, [pageIndices[0]]); | |
| newPdf.addPage(copiedPage); | |
| return await newPdf.save(); | |
| } | |
| return { data: zip.toBuffer(), ext: 'zip' }; | |
| } | |
| else if (toolId === 'add-watermark') { | |
| const pdfDoc = await PDFDocument.load(buffers[0], { ignoreEncryption: true }); | |
| const pages = pdfDoc.getPages(); | |
| const { watermarkText } = options || {}; | |
| const textToDraw = watermarkText || 'PDFbazaar.com Watermark'; | |
| for (const page of pages) { | |
| const { width, height } = page.getSize(); | |
| page.drawText(textToDraw, { | |
| x: width / 2 - (textToDraw.length * 10), | |
| y: height / 2, | |
| size: 40, | |
| color: rgb(0.95, 0.1, 0.1), | |
| opacity: 0.5, | |
| rotate: degrees(45) | |
| }); | |
| } | |
| return await pdfDoc.save(); | |
| } | |
| else if (toolId === 'add-page-numbers') { | |
| const pdfDoc = await PDFDocument.load(buffers[0], { ignoreEncryption: true }); | |
| const pages = pdfDoc.getPages(); | |
| for (let i = 0; i < pages.length; i++) { | |
| const { width, height } = pages[i].getSize(); | |
| pages[i].drawText(`Page ${i + 1} of ${pages.length}`, { | |
| x: width / 2 - 20, | |
| y: 20, | |
| size: 10, | |
| color: rgb(0.2, 0.2, 0.2), | |
| }); | |
| } | |
| return await pdfDoc.save(); | |
| } | |
| else if (toolId === 'rotate-pdf') { | |
| const pdfDoc = await PDFDocument.load(buffers[0], { ignoreEncryption: true }); | |
| const pages = pdfDoc.getPages(); | |
| const { rotateAngle } = options || {}; | |
| const angle = parseInt(rotateAngle, 10) || 90; | |
| for (const page of pages) { | |
| page.setRotation(degrees(angle)); | |
| } | |
| return await pdfDoc.save(); | |
| } | |
| else if (toolId === 'delete-pdf-pages') { | |
| const pdfDoc = await PDFDocument.load(buffers[0], { ignoreEncryption: true }); | |
| const { pageRange } = options || {}; | |
| if (pageRange) { | |
| // Parse values like "1, 3, 5-7" | |
| const totalPages = pdfDoc.getPageCount(); | |
| const pagesToDelete = new Set(); | |
| const parts = pageRange.split(','); | |
| for (const part of parts) { | |
| if (part.includes('-')) { | |
| let [start, end] = part.split('-').map(n => parseInt(n.trim(), 10)); | |
| if (!isNaN(start) && !isNaN(end)) { | |
| for (let i = Math.max(1, start); i <= Math.min(totalPages, end); i++) pagesToDelete.add(i - 1); | |
| } | |
| } else { | |
| const pageNum = parseInt(part.trim(), 10); | |
| if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) { | |
| pagesToDelete.add(pageNum - 1); | |
| } | |
| } | |
| } | |
| const sortedIndexesToDelete = [...pagesToDelete].sort((a, b) => b - a); // Reverse order for deletion | |
| for (const idx of sortedIndexesToDelete) { | |
| if (pdfDoc.getPageCount() > 1) { | |
| pdfDoc.removePage(idx); | |
| } | |
| } | |
| } else { | |
| // Default: remove last page if no range Provided | |
| if (pdfDoc.getPageCount() > 1) { | |
| pdfDoc.removePage(pdfDoc.getPageCount() - 1); | |
| } | |
| } | |
| return await pdfDoc.save(); | |
| } | |
| else if (toolId === 'jpg-to-pdf') { | |
| const pdfDoc = await PDFDocument.create(); | |
| for (const buffer of buffers) { | |
| let image; | |
| try { | |
| image = await pdfDoc.embedJpg(buffer); | |
| } catch { | |
| image = await pdfDoc.embedPng(buffer); | |
| } | |
| const page = pdfDoc.addPage([image.width, image.height]); | |
| page.drawImage(image, { x: 0, y: 0, width: image.width, height: image.height }); | |
| } | |
| return await pdfDoc.save(); | |
| } | |
| else if (toolId === 'pdf-to-word') { | |
| const pdfParse = require('pdf-parse'); | |
| const { Document, Packer, Paragraph, TextRun } = require('docx'); | |
| const data = await pdfParse(buffers[0]); | |
| const doc = new Document({ | |
| sections: [{ | |
| properties: {}, | |
| children: data.text.split('\n').map(line => new Paragraph({ | |
| children: [new TextRun(line)], | |
| })), | |
| }], | |
| }); | |
| const buffer = await Packer.toBuffer(doc); | |
| return { data: buffer, ext: 'docx' }; | |
| } | |
| else if (toolId === 'pdf-to-txt') { | |
| const pdfParse = require('pdf-parse'); | |
| const data = await pdfParse(buffers[0]); | |
| return { data: Buffer.from(data.text), ext: 'txt' }; | |
| } | |
| else if (toolId === 'crop-pdf') { | |
| const pdfDoc = await PDFDocument.load(buffers[0]); | |
| const pages = pdfDoc.getPages(); | |
| for (const page of pages) { | |
| const { width, height } = page.getSize(); | |
| // Basic crop: reduce margins by 10% | |
| page.setCropBox(width * 0.1, height * 0.1, width * 0.8, height * 0.8); | |
| } | |
| return await pdfDoc.save(); | |
| } | |
| else if (toolId === 'resize-pdf') { | |
| const pdfDoc = await PDFDocument.load(buffers[0]); | |
| const pages = pdfDoc.getPages(); | |
| for (const page of pages) { | |
| // Resize to A4 (595.28 x 841.89 points) | |
| page.setSize(595.28, 841.89); | |
| } | |
| return await pdfDoc.save(); | |
| } | |
| return buffers[0]; // fallback | |
| } catch (e) { | |
| throw new Error('PDF Processing failed: ' + e.message); | |
| } | |
| } | |
| // Processing Route | |
| app.post('/api/process/:toolId', upload.array('files'), async (req, res) => { | |
| try { | |
| const { toolId } = req.params; | |
| const files = req.files || []; | |
| const options = req.body; | |
| if (toolId === 'html-to-pdf') { | |
| const { url } = options; | |
| if (!url) return res.status(400).json({ error: 'No URL provided.' }); | |
| const htmlProcessedPath = path.join(uploadDir, `processed-${Date.now()}.pdf`); | |
| const puppeteer = require('puppeteer'); | |
| const browser = await puppeteer.launch({ | |
| headless: 'new', | |
| args: ['--no-sandbox', '--disable-setuid-sandbox'] | |
| }); | |
| const page = await browser.newPage(); | |
| try { | |
| await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); | |
| await page.pdf({ path: htmlProcessedPath, format: 'A4' }); | |
| } finally { | |
| await browser.close(); | |
| } | |
| const token = path.basename(htmlProcessedPath); | |
| const fileSize = fs.statSync(htmlProcessedPath).size; | |
| return res.json({ token, filename: 'pdfbazaar-html-result.pdf', size: fileSize }); | |
| } | |
| if (!files || files.length === 0) { | |
| return res.status(400).json({ error: 'No files uploaded.' }); | |
| } | |
| if (files.some(f => f.size === 0)) { | |
| files.forEach(f => { if (fs.existsSync(f.path)) fs.unlinkSync(f.path); }); | |
| return res.status(400).json({ error: 'Uploaded file is empty (0.0 KB). Please select a valid file containing data.' }); | |
| } | |
| console.log(`Processing tool [${toolId}] with ${files.length} files.`); | |
| // Determine tool category based on toolId | |
| const isPdfLibTool = ['merge-pdf', 'split-pdf', 'add-watermark', 'rotate-pdf', 'delete-pdf-pages', 'jpg-to-pdf', 'add-page-numbers', 'crop-pdf', 'resize-pdf'].includes(toolId); | |
| let ext = 'pdf'; | |
| let processedFilePath = path.join(uploadDir, `processed-${Date.now()}.${ext}`); | |
| if (toolId === 'pdf-editor') { | |
| const { action, options } = req.body; | |
| let opts = {}; | |
| try { opts = JSON.parse(options || '{}'); } catch (e) { } | |
| const inputFile = files[0].path; | |
| ext = 'pdf'; | |
| processedFilePath = path.join(uploadDir, `processed-${Date.now()}.${ext}`); | |
| if (action === 'ocr') { | |
| const lang = opts.lang || 'eng'; | |
| // tesseract needs the output prefix without .pdf | |
| const outPrefix = processedFilePath.replace('.pdf', ''); | |
| const cmd = `tesseract "${inputFile}" "${outPrefix}" -l ${lang} pdf`; | |
| console.log(`[pdf-editor OCR] Running: ${cmd}`); | |
| await new Promise((resolve, reject) => { | |
| exec(cmd, { timeout: 120000 }, (err) => { | |
| if (err && !fs.existsSync(processedFilePath)) reject(err); | |
| else resolve(); | |
| }); | |
| }); | |
| } else if (action === 'delete-page') { | |
| // opts.pages: '1,3-5' (keep pages) | |
| const pages = opts.pages || '1-N'; // mutool semantics for keep pages | |
| const cmd = `mutool merge -o "${processedFilePath}" "${inputFile}" ${pages}`; | |
| console.log(`[pdf-editor delete] Running: ${cmd}`); | |
| await new Promise((resolve, reject) => { | |
| exec(cmd, { timeout: 60000 }, (err) => { | |
| if (err) reject(err); | |
| else resolve(); | |
| }); | |
| }); | |
| } else if (action === 'rotate-page') { | |
| // Actually mutool clean/merge does not natively do simple rotation of specific pages from CLI alone | |
| // in all versions without a script, but we try a basic approach or fallback to clean | |
| const cmd = `mutool clean "${inputFile}" "${processedFilePath}"`; | |
| console.log(`[pdf-editor rotate-mock] Running: ${cmd}`); | |
| await new Promise((resolve, reject) => { | |
| exec(cmd, { timeout: 60000 }, (err) => { | |
| if (err) reject(err); | |
| else resolve(); | |
| }); | |
| }); | |
| } else if (action === 'edit-text' || action === 'replace-image') { | |
| // edits via clean decompression | |
| const cmd = `mutool clean -d "${inputFile}" "${processedFilePath}"`; | |
| console.log(`[pdf-editor edits] Running: ${cmd}`); | |
| await new Promise((resolve, reject) => { | |
| exec(cmd, { timeout: 60000 }, (err) => { | |
| if (err) reject(err); | |
| else resolve(); | |
| }); | |
| }); | |
| } else { | |
| fs.copyFileSync(inputFile, processedFilePath); | |
| } | |
| } else if (isPdfLibTool) { | |
| const buffers = files.map(file => fs.readFileSync(file.path)); | |
| let resultBytes; | |
| try { | |
| resultBytes = await processPdfLibTool(toolId, buffers, options); | |
| if (resultBytes && resultBytes.ext && resultBytes.data) { | |
| ext = resultBytes.ext; | |
| resultBytes = resultBytes.data; | |
| } | |
| } catch (err) { | |
| return res.status(500).json({ error: 'Failed to manipulate PDF. The file may be corrupted or highly encrypted.', details: err.message }); | |
| } | |
| processedFilePath = path.join(uploadDir, `processed-${Date.now()}.${ext}`); | |
| fs.writeFileSync(processedFilePath, resultBytes); | |
| } else if (toolId === 'compress-pdf') { | |
| const { compressLevel, customDpi, targetSizeMB } = options || {}; | |
| const inputFile = files[0].path; | |
| const originalSize = fs.statSync(inputFile).size; | |
| const isLargeFile = originalSize > 15 * 1024 * 1024; // >15MB = large | |
| const gsCmd = getGsCommand(); | |
| const runGhostscript = (srcFile, outFile, quality, dpi) => { | |
| const cmd = `${gsCmd} -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dPDFSETTINGS=${quality} -dNOPAUSE -dQUIET -dBATCH -dNumRenderingThreads=4 -dNOGC -dColorImageDownsampleType=/Bicubic -dGrayImageDownsampleType=/Bicubic -dMonoImageDownsampleType=/Subsample -dColorImageResolution=${dpi} -dGrayImageResolution=${dpi} -dMonoImageResolution=${Math.max(dpi, 150)} -sOutputFile="${outFile}" "${srcFile}"`; | |
| return new Promise((resolve, reject) => exec(cmd, { timeout: 120000 }, (err) => { | |
| if (err && !fs.existsSync(outFile)) reject(err); | |
| else resolve(); | |
| })); | |
| }; | |
| let usedFastResult = false; | |
| const targetBytes = targetSizeMB && parseFloat(targetSizeMB) > 0 | |
| ? Math.round(parseFloat(targetSizeMB) * 1048576) | |
| : null; | |
| // ── Layer 1: Fast pure-JS (only for small files <15MB, no target or target is easily met) ── | |
| if (!isLargeFile && compressLevel !== 'extreme') { | |
| try { | |
| const inputBuffer = fs.readFileSync(inputFile); | |
| const { PDFDocument } = require('pdf-lib'); | |
| const pdfDoc = await PDFDocument.load(inputBuffer, { ignoreEncryption: true }); | |
| const fastBytes = await pdfDoc.save({ useObjectStreams: true, addDefaultPage: false }); | |
| const fastReduced = fastBytes.length < originalSize * 0.92; | |
| const fastHitsTarget = targetBytes && fastBytes.length <= targetBytes; | |
| if ((fastReduced && !targetSizeMB) || fastHitsTarget) { | |
| fs.writeFileSync(processedFilePath, fastBytes); | |
| usedFastResult = true; | |
| console.log(`[compress-pdf] pdf-lib fast pass: ${originalSize} → ${fastBytes.length} bytes`); | |
| } | |
| } catch (e) { /* fall through to GS */ } | |
| } | |
| // ── Layer 2: Ghostscript ── | |
| if (!usedFastResult) { | |
| if (targetBytes) { | |
| // ── Iterative mode: try progressively lower DPI until target is met ── | |
| // Steps: try quality levels from best to worst until we hit the target | |
| const attempts = [ | |
| { quality: '/ebook', dpi: 150 }, | |
| { quality: '/screen', dpi: 96 }, | |
| { quality: '/screen', dpi: 60 }, | |
| ]; | |
| // If user explicitly set a custom DPI, start from that DPI | |
| const userDpi = customDpi && customDpi !== 'auto' ? parseInt(customDpi) : null; | |
| if (userDpi) { | |
| // Insert user DPI as the first attempt with /screen quality | |
| attempts.unshift({ quality: '/screen', dpi: userDpi }); | |
| } | |
| let bestPath = null; | |
| let bestSize = Infinity; | |
| let hitTarget = false; | |
| for (const attempt of attempts) { | |
| const tmpPath = path.join(uploadDir, `compress-attempt-${Date.now()}-${attempt.dpi}.pdf`); | |
| try { | |
| await runGhostscript(inputFile, tmpPath, attempt.quality, attempt.dpi); | |
| if (!fs.existsSync(tmpPath)) continue; | |
| const resultSize = fs.statSync(tmpPath).size; | |
| console.log(`[compress-pdf] Attempt DPI=${attempt.dpi}: ${resultSize} bytes (target=${targetBytes})`); | |
| if (resultSize < bestSize) { | |
| // Clean up previous best (if any) | |
| if (bestPath && fs.existsSync(bestPath)) fs.unlinkSync(bestPath); | |
| bestSize = resultSize; | |
| bestPath = tmpPath; | |
| } else { | |
| fs.unlinkSync(tmpPath); // not better, discard | |
| } | |
| if (resultSize <= targetBytes) { | |
| hitTarget = true; | |
| break; // Target met — stop iterating | |
| } | |
| } catch (e) { | |
| console.warn(`[compress-pdf] Attempt DPI=${attempt.dpi} failed:`, e.message); | |
| if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); | |
| } | |
| } | |
| if (bestPath && fs.existsSync(bestPath)) { | |
| fs.renameSync(bestPath, processedFilePath); | |
| if (!hitTarget) { | |
| console.log(`[compress-pdf] Target ${targetBytes} bytes not achievable; best result: ${bestSize} bytes`); | |
| } else { | |
| console.log(`[compress-pdf] Target met! Final size: ${bestSize} bytes`); | |
| } | |
| } else { | |
| // All attempts failed — fallback to basic /screen pass | |
| await runGhostscript(inputFile, processedFilePath, '/screen', 72); | |
| } | |
| } else { | |
| // ── Non-target mode: Single pass with appropriate quality ── | |
| let gsQuality = '/screen'; | |
| let dpi = customDpi && customDpi !== 'auto' ? parseInt(customDpi) : 96; | |
| if (compressLevel === 'less') { gsQuality = '/ebook'; dpi = Math.max(dpi, 150); } | |
| else if (compressLevel === 'extreme') { gsQuality = '/screen'; dpi = Math.min(dpi, 72); } | |
| await runGhostscript(inputFile, processedFilePath, gsQuality, dpi); | |
| } | |
| } | |
| } else if (toolId === 'grayscale-pdf') { | |
| const inputFile = files[0].path; | |
| const gsCmd = getGsCommand(); | |
| const command = `${gsCmd} -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dNOPAUSE -dQUIET -dBATCH -sColorConversionStrategy=Gray -dProcessColorModel=/DeviceGray -sOutputFile="${processedFilePath}" "${inputFile}"`; | |
| await new Promise((resolve) => exec(command, resolve)); | |
| } else if (toolId === 'pdf-to-jpg') { | |
| const inputFile = files[0].path; | |
| const gsCmd = getGsCommand(); | |
| const outPathPattern = path.join(uploadDir, 'page-%d.jpg'); | |
| const command = `${gsCmd} -sDEVICE=jpeg -r150 -dNOPAUSE -dQUIET -dBATCH -sOutputFile="${outPathPattern}" "${inputFile}"`; | |
| await new Promise((resolve) => { | |
| exec(command, (err) => { | |
| if (err) return resolve(); | |
| const AdmZip = require('adm-zip'); | |
| const zip = new AdmZip(); | |
| let i = 1; | |
| while (true) { | |
| const pagePath = outPathPattern.replace('%d', i); | |
| if (fs.existsSync(pagePath)) { | |
| zip.addLocalFile(pagePath); | |
| fs.unlinkSync(pagePath); | |
| i++; | |
| } else break; | |
| } | |
| if (i > 1) { | |
| ext = 'zip'; | |
| processedFilePath = path.join(uploadDir, `processed-${Date.now()}.zip`); | |
| zip.writeZip(processedFilePath); | |
| } | |
| resolve(); | |
| }); | |
| }); | |
| } else if (['protect-pdf', 'unlock-pdf'].includes(toolId)) { | |
| const { password } = options || {}; | |
| const inputFile = files[0].path; | |
| const pyScript = path.join(__dirname, 'python_scripts', 'pdf_security.py'); | |
| const action = toolId === 'protect-pdf' ? 'protect' : 'unlock'; | |
| const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'; | |
| // Execute python pypdf script to remove DRM correctly instead of ghostscript | |
| const command = `${pythonCmd} "${pyScript}" ${action} "${inputFile}" "${processedFilePath}" --password "${(password || '').replace(/"/g, '\\"')}"`; | |
| console.log(`[${toolId}] Running: ${command}`); | |
| await new Promise((resolve, reject) => { | |
| exec(command, { timeout: 60000 }, (error, stdout, stderr) => { | |
| if (stdout && stdout.includes('ERROR:')) { | |
| console.error(`[${toolId}] Error:`, stdout); | |
| fs.copyFileSync(inputFile, processedFilePath); // Fallback copy unedited if we failed and hope user downloads original | |
| return reject(new Error(stdout.split('ERROR:')[1].trim())); | |
| } else if (error) { | |
| return reject(new Error(`Failed to ${action} PDF: Wrong password or unsupported format.`)); | |
| } | |
| resolve(); | |
| }); | |
| }); | |
| } else if (['word-to-pdf', 'excel-to-pdf', 'ppt-to-pdf'].includes(toolId)) { | |
| const inputFile = files[0].path; | |
| if (toolId === 'word-to-pdf') { | |
| // ── Pre-process: strip floating text boxes from docx ── | |
| // Word docs like Aadhaar cards have text boxes overlaid on top of images. | |
| // LibreOffice dumps these floating text boxes onto extra pages as overflow. | |
| // The numbers ARE already in the image itself, so stripping textboxes is safe. | |
| let convertInputFile = inputFile; | |
| try { | |
| const AdmZipDocx = require('adm-zip'); | |
| const zipDocx = new AdmZipDocx(inputFile); | |
| const docEntry = zipDocx.getEntry('word/document.xml'); | |
| if (docEntry) { | |
| let docXml = zipDocx.readAsText(docEntry); | |
| const originalLen = docXml.length; | |
| // Remove mc:AlternateContent blocks containing text boxes or drawings | |
| docXml = docXml.replace(/<mc:AlternateContent[\s\S]*?<\/mc:AlternateContent>/g, (match) => { | |
| if (match.includes('v:textbox') || match.includes('txbxContent') || match.includes('wps:txbx') || match.includes('wps:wsp')) { | |
| return ''; | |
| } | |
| return match; | |
| }); | |
| // Remove old-style VML shapes and text boxes | |
| docXml = docXml.replace(/<v:shape[\s\S]*?<\/v:shape>/g, ''); | |
| docXml = docXml.replace(/<v:rect[\s\S]*?<\/v:rect>/g, ''); | |
| docXml = docXml.replace(/<v:textbox[\s\S]*?<\/v:textbox>/g, ''); | |
| // Remove modern Word processing shapes (wps) | |
| docXml = docXml.replace(/<wps:wsp[\s\S]*?<\/wps:wsp>/g, ''); | |
| docXml = docXml.replace(/<wps:txbx[\s\S]*?<\/wps:txbx>/g, ''); | |
| if (docXml.length !== originalLen) { | |
| console.log(`[word-to-pdf] Stripped ${originalLen - docXml.length} chars of textbox content from docx`); | |
| zipDocx.updateFile('word/document.xml', Buffer.from(docXml, 'utf8')); | |
| const modifiedPath = inputFile + '.notxbx.docx'; | |
| zipDocx.writeZip(modifiedPath); | |
| convertInputFile = modifiedPath; | |
| } else { | |
| console.log('[word-to-pdf] No text boxes found in docx'); | |
| } | |
| } | |
| } catch (txbxErr) { | |
| console.log('[word-to-pdf] Text box stripping skipped:', txbxErr.message); | |
| convertInputFile = inputFile; // fallback to original | |
| } | |
| try { | |
| await new Promise((resolve, reject) => { | |
| const sofficePath = getSofficeCommand(); | |
| const cmd = `${sofficePath} --headless --norestore --nofirststartwizard --convert-to pdf --outdir "${uploadDir}" "${convertInputFile}"`; | |
| console.log(`[word-to-pdf] Running: ${cmd}`); | |
| exec(cmd, { timeout: 90000 }, (err, stdout, stderr) => { | |
| // LibreOffice names output after the INPUT file it converted | |
| const outName = path.parse(convertInputFile).name + '.pdf'; | |
| const loPath = path.join(uploadDir, outName); | |
| console.log(`[word-to-pdf] soffice err=${err?.message}, loPath=${loPath}, exists=${fs.existsSync(loPath)}`); | |
| if (!err && fs.existsSync(loPath)) { | |
| fs.renameSync(loPath, processedFilePath); | |
| // Clean up temp modified docx if created | |
| if (convertInputFile !== inputFile && fs.existsSync(convertInputFile)) { | |
| try { fs.unlinkSync(convertInputFile); } catch (e) { } | |
| } | |
| resolve(); | |
| } else { | |
| console.log('[word-to-pdf] soffice failed, stdout:', stdout, 'stderr:', stderr); | |
| reject(new Error(`LibreOffice Error: ${stderr || stdout || err?.message}`)); | |
| } | |
| }); | |
| }); | |
| // Remove blank/overflow trailing pages (LibreOffice puts floating textbox content on extra pages) | |
| if (fs.existsSync(processedFilePath)) { | |
| try { | |
| const pdfBytes = fs.readFileSync(processedFilePath); | |
| const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); | |
| const pageCount = pdfDoc.getPageCount(); | |
| if (pageCount > 1) { | |
| // Measure byte-size of each page individually | |
| const pageSizes = []; | |
| for (let i = 0; i < pageCount; i++) { | |
| try { | |
| const testDoc = await PDFDocument.create(); | |
| const [cp] = await testDoc.copyPages(pdfDoc, [i]); | |
| testDoc.addPage(cp); | |
| const testBytes = await testDoc.save(); | |
| pageSizes.push(testBytes.length); | |
| } catch (e) { | |
| pageSizes.push(999999); // assume real content on error | |
| } | |
| } | |
| // Page 1 (index 0) is the reference — real Aadhaar card / real content | |
| const page1Size = pageSizes[0]; | |
| console.log(`[word-to-pdf] Page sizes (bytes): ${pageSizes.join(', ')}`); | |
| // Remove trailing pages that are < 30% of page1 size. | |
| // These are "overflow" pages from floating text boxes in Word. | |
| // Example: Aadhaar page1 = 300KB, overflow page2 = 10KB → remove page2. | |
| let pagesToRemove = []; | |
| for (let i = pageCount - 1; i >= 1; i--) { | |
| const ratio = pageSizes[i] / page1Size; | |
| console.log(`[word-to-pdf] Page ${i + 1} ratio vs page1: ${(ratio * 100).toFixed(1)}%`); | |
| if (ratio < 0.30) { | |
| pagesToRemove.push(i); | |
| } else { | |
| break; // Stop — this page has real content | |
| } | |
| } | |
| if (pagesToRemove.length > 0) { | |
| for (const idx of pagesToRemove) { | |
| pdfDoc.removePage(idx); | |
| } | |
| const cleaned = await pdfDoc.save(); | |
| fs.writeFileSync(processedFilePath, cleaned); | |
| console.log(`[word-to-pdf] Removed ${pagesToRemove.length} overflow trailing page(s). Final pages: ${pageCount - pagesToRemove.length}`); | |
| } else { | |
| console.log(`[word-to-pdf] All ${pageCount} pages have real content — keeping all.`); | |
| } | |
| } | |
| } catch (cleanErr) { | |
| console.log('Page cleanup skipped:', cleanErr.message); | |
| } | |
| } | |
| } catch (err) { | |
| return res.status(500).json({ | |
| error: 'LibreOffice Error', | |
| details: err.message | |
| }); | |
| } | |
| } else { | |
| // For Excel and PPT | |
| const sofficePath = getSofficeCommand(); | |
| const command = `${sofficePath} --headless --norestore --nofirststartwizard --convert-to pdf --outdir "${uploadDir}" "${inputFile}"`; | |
| console.log(`[excel/ppt-to-pdf] Running: ${command}`); | |
| await new Promise((resolve, reject) => { | |
| exec(command, { timeout: 90000 }, (error, stdout, stderr) => { | |
| const outName = path.parse(inputFile).name + '.pdf'; | |
| const loPath = path.join(uploadDir, outName); | |
| console.log(`[excel/ppt-to-pdf] soffice error=${error?.message}, loPath=${loPath}, exists=${fs.existsSync(loPath)}`); | |
| if (!error && fs.existsSync(loPath)) { | |
| fs.renameSync(loPath, processedFilePath); | |
| resolve(); | |
| } else { | |
| console.log('[excel/ppt-to-pdf] soffice failed, stdout:', stdout, 'stderr:', stderr); | |
| let cleanErr = (stderr || stdout || error?.message || 'File not generated').replace(/Warning: failed to launch javaldx - java may not function correctly/gi, '').trim(); | |
| reject(new Error(`LibreOffice Error: ${cleanErr}`)); | |
| } | |
| }); | |
| }); | |
| } | |
| } else if (['pdf-to-excel', 'pdf-to-word', 'pdf-to-ppt'].includes(toolId)) { | |
| const inputFile = files[0].path; | |
| const sofficePath = getSofficeCommand(); | |
| const convMap = { | |
| 'pdf-to-excel': { format: 'xlsx', ext: 'xlsx' }, | |
| 'pdf-to-word': { format: 'docx', ext: 'docx' }, | |
| 'pdf-to-ppt': { format: 'pptx', ext: 'pptx' }, | |
| }; | |
| const { format, ext: outExt } = convMap[toolId]; | |
| ext = outExt; | |
| processedFilePath = path.join(uploadDir, `processed-${Date.now()}.${ext}`); | |
| // ── Try LibreOffice first (Docker/system with soffice) ── | |
| let libreOfficeDone = false; | |
| try { | |
| await new Promise((resolve, reject) => { | |
| let infilterObj = ''; | |
| if (toolId === 'pdf-to-word') infilterObj = '--infilter="writer_pdf_import"'; | |
| if (toolId === 'pdf-to-ppt') infilterObj = '--infilter="impress_pdf_import"'; | |
| const cmd = `${sofficePath} --headless --norestore --nofirststartwizard ${infilterObj} --convert-to ${format} --outdir "${uploadDir}" "${inputFile}"`; | |
| console.log(`[${toolId}] Trying soffice: ${cmd}`); | |
| exec(cmd, { timeout: 120000 }, (error, stdout, stderr) => { | |
| const baseName = path.parse(inputFile).name; | |
| const loOutPath = path.join(uploadDir, `${baseName}.${format}`); | |
| if (!error && fs.existsSync(loOutPath)) { | |
| fs.renameSync(loOutPath, processedFilePath); | |
| libreOfficeDone = true; | |
| resolve(); | |
| } else { | |
| reject(new Error(error?.message || 'soffice not available')); | |
| } | |
| }); | |
| }); | |
| } catch (sofficErr) { | |
| console.log(`[${toolId}] soffice not available: ${sofficErr.message}. Using JS fallback.`); | |
| } | |
| // ── JS Fallback: pdf-parse + xlsx (no external tools needed) ── | |
| if (!libreOfficeDone) { | |
| if (toolId === 'pdf-to-ppt') { | |
| throw new Error(`PDF to PPT requires LibreOffice on the server. Please contact support or install it.`); | |
| } | |
| if (toolId === 'pdf-to-excel') { | |
| const XLSX = require('xlsx'); | |
| let rows = []; | |
| // Try to extract text from PDF | |
| try { | |
| const pdfParse = require('pdf-parse'); | |
| const pdfBuffer = fs.readFileSync(inputFile); | |
| const pdfData = await pdfParse(pdfBuffer, { max: 0 }); | |
| rows = pdfData.text | |
| .split('\n') | |
| .map(line => line.trim()) | |
| .filter(line => line.length > 0) | |
| .map(line => line.split(/\t| +/).map(cell => cell.trim())); | |
| console.log(`[pdf-to-excel] Extracted ${rows.length} rows from PDF text`); | |
| } catch (parseErr) { | |
| console.log(`[pdf-to-excel] pdf-parse failed (likely image/encrypted PDF): ${parseErr.message}`); | |
| // For image-based/encrypted PDFs, create a notice sheet | |
| rows = [ | |
| ['PDF to Excel Conversion Notice'], | |
| [''], | |
| ['This PDF appears to be image-based or encrypted.'], | |
| ['Text extraction requires a text-based PDF.'], | |
| ['Tip: Use a PDF with selectable text for best results.'], | |
| ]; | |
| } | |
| if (rows.length === 0) { | |
| rows = [['No text content found in this PDF.']]; | |
| } | |
| const wb = XLSX.utils.book_new(); | |
| const ws = XLSX.utils.aoa_to_sheet(rows); | |
| XLSX.utils.book_append_sheet(wb, ws, 'PDF Data'); | |
| const xlsxBuffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); | |
| fs.writeFileSync(processedFilePath, xlsxBuffer); | |
| console.log(`[pdf-to-excel] Done. Rows: ${rows.length}`); | |
| } else if (toolId === 'pdf-to-word') { | |
| const AdmZip = require('adm-zip'); | |
| let textContent = ''; | |
| try { | |
| const pdfParse = require('pdf-parse'); | |
| const pdfBuffer = fs.readFileSync(inputFile); | |
| const pdfData = await pdfParse(pdfBuffer, { max: 0 }); | |
| textContent = pdfData.text; | |
| console.log(`[pdf-to-word] Extracted ${textContent.length} chars`); | |
| } catch (parseErr) { | |
| console.log(`[pdf-to-word] pdf-parse failed: ${parseErr.message}`); | |
| textContent = 'This PDF appears to be image-based or encrypted.\nText extraction requires a text-based PDF.\nTip: Use a PDF with selectable text for best results.'; | |
| } | |
| // Build a minimal valid DOCX using Office Open XML | |
| const zip = new AdmZip(); | |
| const paragraphs = textContent | |
| .split('\n') | |
| .map(line => `<w:p><w:r><w:t xml:space="preserve">${line.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</w:t></w:r></w:p>`) | |
| .join(''); | |
| const docXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <w:document xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" | |
| xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> | |
| <w:body>${paragraphs}<w:sectPr/></w:body> | |
| </w:document>`; | |
| const relsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> | |
| <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/> | |
| </Relationships>`; | |
| const wordRelsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`; | |
| const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> | |
| <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> | |
| <Default Extension="xml" ContentType="application/xml"/> | |
| <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/> | |
| </Types>`; | |
| zip.addFile('[Content_Types].xml', Buffer.from(contentTypes)); | |
| zip.addFile('_rels/.rels', Buffer.from(relsXml)); | |
| zip.addFile('word/document.xml', Buffer.from(docXml)); | |
| zip.addFile('word/_rels/document.xml.rels', Buffer.from(wordRelsXml)); | |
| fs.writeFileSync(processedFilePath, zip.toBuffer()); | |
| console.log(`[pdf-to-word] JS fallback done.`); | |
| } else { | |
| // pdf-to-ppt: no reliable JS fallback, return error | |
| throw new Error('PDF to PPT requires LibreOffice on the server. Please contact support.'); | |
| } | |
| } | |
| } else { | |
| processedFilePath = files[0].path; | |
| } | |
| // Return JSON with download token — client will use GET /api/download/:token | |
| if (fs.existsSync(processedFilePath)) { | |
| const dlFileName = ext !== 'pdf' ? `pdfbazaar-${toolId}-result.${ext}` : `pdfbazaar-${toolId}-result.pdf`; | |
| const token = path.basename(processedFilePath); | |
| const fileSize = fs.statSync(processedFilePath).size; | |
| // Clean up uploaded input files (NOT the processed output) | |
| files.forEach(f => { if (f.path && f.path !== processedFilePath && fs.existsSync(f.path)) fs.unlinkSync(f.path); }); | |
| return res.json({ token, filename: dlFileName, size: fileSize }); | |
| } else { | |
| return res.status(500).json({ error: 'Processing failed, result file not found.' }); | |
| } | |
| } catch (error) { | |
| console.error('Processing route error:', error); | |
| res.status(500).json({ error: 'Server Error during processing.', details: error.message }); | |
| } | |
| }); | |
| // ── GET /api/download/:token — serve processed file with correct filename ── | |
| app.get('/api/download/:token', (req, res) => { | |
| const { token } = req.params; | |
| if (!token || token.includes('..') || token.includes('/') || token.includes('\\') || token.includes('\0')) { | |
| return res.status(400).json({ error: 'Invalid token' }); | |
| } | |
| const filePath = path.join(uploadDir, token); | |
| if (!fs.existsSync(filePath)) { | |
| return res.status(404).json({ error: 'File expired. Please re-process.' }); | |
| } | |
| const ext = path.extname(token).slice(1) || 'pdf'; | |
| const friendlyName = req.query.name ? decodeURIComponent(req.query.name) : `pdfbazaar-result.${ext}`; | |
| res.setHeader('Access-Control-Allow-Origin', '*'); | |
| res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); | |
| res.download(filePath, friendlyName, (err) => { | |
| if (err) console.error('Download serve error:', err); | |
| setTimeout(() => { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); }, 5000); | |
| }); | |
| }); | |
| // ───────────────────────────────────────────────────── | |
| // PDF Editor: Text Replace API (Backend Method) | |
| // ───────────────────────────────────────────────────── | |
| app.post('/api/pdf-editor/replace-text', upload.single('file'), async (req, res) => { | |
| try { | |
| if (!req.file) return res.status(400).json({ error: 'No PDF file uploaded.' }); | |
| let replacements = []; | |
| try { replacements = JSON.parse(req.body.replacements || '[]'); } catch (e) { } | |
| const pdfBuffer = fs.readFileSync(req.file.path); | |
| const pdfDoc = await PDFDocument.load(pdfBuffer, { ignoreEncryption: true }); | |
| // 🔹 Support All Languages via Google Noto Fonts | |
| pdfDoc.registerFontkit(fontkit); | |
| // Comprehensive Font Map for Indian Regional Languages & Styles | |
| const FONT_FAMILIES = [ | |
| { name: 'NotoSansDevanagari', regex: /[\u0900-\u097F]/ }, | |
| { name: 'NotoSansBengali', regex: /[\u0980-\u09FF]/ }, | |
| { name: 'NotoSansTamil', regex: /[\u0B80-\u0BFF]/ }, | |
| { name: 'NotoSansTelugu', regex: /[\u0C00-\u0C7F]/ }, | |
| { name: 'NotoSansKannada', regex: /[\u0C80-\u0CFF]/ }, | |
| { name: 'NotoSansMalayalam', regex: /[\u0D00-\u0D7F]/ }, | |
| { name: 'NotoSansGujarati', regex: /[\u0A80-\u0AFF]/ }, | |
| { name: 'NotoSansGurmukhi', regex: /[\u0A00-\u0A7F]/ }, | |
| { name: 'NotoSansOriya', regex: /[\u0B00-\u0B7F]/ }, | |
| { name: 'NotoSansArabic', regex: /[\u0600-\u06FF\u0750-\u077F]/ }, | |
| { name: 'NotoSerif', regex: /[A-Z][a-z]+ [A-Z][a-z]+/ }, // Names/Formal | |
| { name: 'NotoSans', regex: /.*/ } // Default | |
| ]; | |
| const embeddedFonts = {}; | |
| const pages = pdfDoc.getPages(); | |
| for (const rep of replacements) { | |
| const { | |
| pageIndex, x, y, width, height, newText, fontSize, | |
| pdfX, pdfY, pdfWidth, pdfHeight, hasMatch, | |
| fontWeight, fontStyle | |
| } = rep; | |
| if (pageIndex === undefined || newText === undefined) continue; | |
| const page = pages[pageIndex]; | |
| if (!page) continue; | |
| let finalX, finalY, finalW, finalH, finalSize; | |
| if (hasMatch) { | |
| finalX = pdfX; | |
| finalY = pdfY; | |
| finalW = pdfWidth; | |
| finalH = pdfHeight; | |
| finalSize = fontSize; | |
| } else { | |
| const { height: pdfPageH } = page.getSize(); | |
| finalX = x; | |
| finalY = pdfPageH - y - height; | |
| finalW = width; | |
| finalH = height; | |
| finalSize = fontSize || 12; | |
| } | |
| // 1. White rectangle to cover original | |
| page.drawRectangle({ | |
| x: finalX - 0.5, | |
| y: finalY - 1, | |
| width: finalW + 1, | |
| height: finalH + 2, | |
| color: rgb(1, 1, 1), | |
| }); | |
| // 2. Draw new text | |
| if (newText.trim()) { | |
| // 🎨 Parse Color (e.g. "rgb(0, 0, 0)") | |
| let textColor = rgb(0, 0, 0); | |
| if (rep.fontColor) { | |
| const match = rep.fontColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); | |
| if (match) { | |
| textColor = rgb( | |
| parseInt(match[1]) / 255, | |
| parseInt(match[2]) / 255, | |
| parseInt(match[3]) / 255 | |
| ); | |
| } | |
| } | |
| // Determine style suffix | |
| let style = 'Regular'; | |
| const isBold = fontWeight === 'bold' || parseInt(fontWeight) >= 600; | |
| const isItalic = fontStyle === 'italic'; | |
| if (isBold && isItalic) style = 'BoldItalic'; | |
| else if (isBold) style = 'Bold'; | |
| else if (isItalic) style = 'Italic'; | |
| // Find family | |
| let family = 'NotoSans'; | |
| const originalFont = (rep.fontFamily || '').toLowerCase(); | |
| // If original font is likely Serif (Times, Georgia, etc) | |
| if (originalFont.includes('serif') || originalFont.includes('times') || originalFont.includes('georgia')) { | |
| family = 'NotoSerif'; | |
| } | |
| for (const fam of FONT_FAMILIES) { | |
| if (fam.regex.test(newText)) { | |
| // If it's a specific language, use that family | |
| if (fam.name !== 'NotoSans' && fam.name !== 'NotoSerif') { | |
| family = fam.name; | |
| } | |
| break; | |
| } | |
| } | |
| const fontKey = `${family}-${style}`; | |
| if (!embeddedFonts[fontKey]) { | |
| const fontFilename = `${fontKey}.ttf`; | |
| const localPath = path.join(__dirname, 'fonts', fontFilename); | |
| const systemPath = path.join('/usr/share/fonts/truetype/noto', fontFilename); | |
| let finalFontPath = null; | |
| if (fs.existsSync(systemPath)) { | |
| finalFontPath = systemPath; | |
| } else if (fs.existsSync(localPath)) { | |
| finalFontPath = localPath; | |
| } else { | |
| // Download from Google Fonts GitHub | |
| console.log(`[Font] Downloading ${fontKey} for parity...`); | |
| if (!fs.existsSync(path.join(__dirname, 'fonts'))) fs.mkdirSync(path.join(__dirname, 'fonts')); | |
| let downloadUrl = `https://raw.githubusercontent.com/googlefonts/noto-fonts/main/hinted/ttf/${family}/${fontFilename}`; | |
| try { | |
| await new Promise((resolve, reject) => { | |
| const file = fs.createWriteStream(localPath); | |
| function fetchUrl(url) { | |
| https.get(url, (res) => { | |
| if (res.statusCode === 301 || res.statusCode === 302) return fetchUrl(res.headers.location); | |
| if (res.statusCode !== 200) return reject(new Error(`Status: ${res.statusCode}`)); | |
| res.pipe(file); | |
| file.on('finish', () => { file.close(); resolve(); }); | |
| }).on('error', reject); | |
| } | |
| fetchUrl(downloadUrl); | |
| }); | |
| finalFontPath = localPath; | |
| } catch (e) { | |
| console.warn(`[Font] Download failed for ${fontKey}: ${e.message}.`); | |
| if (style !== 'Regular') { | |
| finalFontPath = path.join(__dirname, 'fonts', `${family}-Regular.ttf`); | |
| } | |
| } | |
| } | |
| if (finalFontPath && fs.existsSync(finalFontPath)) { | |
| try { | |
| const fontBytes = fs.readFileSync(finalFontPath); | |
| if (fontBytes.length > 0) { | |
| embeddedFonts[fontKey] = await pdfDoc.embedFont(fontBytes); | |
| } else { | |
| throw new Error('Empty font file'); | |
| } | |
| } catch (embedErr) { | |
| console.error(`[Font] Failed to embed ${fontKey}:`, embedErr.message); | |
| const { StandardFonts } = require('pdf-lib'); | |
| embeddedFonts[fontKey] = await pdfDoc.embedFont( | |
| family === 'NotoSerif' ? StandardFonts.TimesRoman : StandardFonts.Helvetica | |
| ); | |
| } | |
| } else { | |
| const { StandardFonts } = require('pdf-lib'); | |
| embeddedFonts[fontKey] = await pdfDoc.embedFont( | |
| family === 'NotoSerif' ? StandardFonts.TimesRoman : StandardFonts.Helvetica | |
| ); | |
| } | |
| } | |
| try { | |
| const activeCustomFont = embeddedFonts[fontKey]; | |
| page.drawText(newText, { | |
| x: finalX, | |
| y: finalY, | |
| size: finalSize, | |
| font: activeCustomFont, | |
| color: textColor, | |
| }); | |
| } catch (drawErr) { | |
| console.error(`[PDF Editor] Draw error on page ${pageIndex}:`, drawErr.message); | |
| } | |
| } | |
| } | |
| console.log(`[PDF Editor] Applied ${replacements.length} edits. Saving...`); | |
| const pdfBytes = await pdfDoc.save(); | |
| if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); | |
| res.contentType('application/pdf'); | |
| res.send(Buffer.from(pdfBytes)); | |
| } catch (err) { | |
| console.error('[replace-text Error]:', err.message); | |
| res.status(500).json({ error: 'Text replacement failed.', details: err.message }); | |
| } | |
| }); | |
| // Serve static files from the frontend build | |
| const distPath = path.resolve(__dirname, 'dist'); | |
| const indexPath = path.join(distPath, 'index.html'); | |
| console.log(`[Static] Checking for dist at: ${distPath}`); | |
| if (fs.existsSync(distPath)) { | |
| console.log('[Static] Dist directory found. Serving static files.'); | |
| app.use(express.static(distPath)); | |
| // Catch-all to serve index.html for SPA routing | |
| app.get('*', (req, res, next) => { | |
| // Skip API routes | |
| if (req.path.startsWith('/api')) return next(); | |
| // Log the request to help debug 404s | |
| console.log(`[SPA] Routing ${req.originalUrl} to index.html`); | |
| res.sendFile(indexPath, (err) => { | |
| if (err) { | |
| console.error(`[Error] Failed to send index.html: ${err.message}`); | |
| // Only send 404 if index.html is actually missing | |
| if (!res.headersSent) { | |
| res.status(404).send('Application not found. Please try again later.'); | |
| } | |
| } | |
| }); | |
| }); | |
| } else { | |
| console.warn('[Warning] Dist directory NOT found. Frontend will not be served.'); | |
| } | |
| // Health check endpoint | |
| app.get('/api/health', (req, res) => { | |
| res.json({ | |
| status: 'ok', | |
| timestamp: new Date().toISOString(), | |
| environment: process.env.NODE_ENV || 'production', | |
| port: port | |
| }); | |
| }); | |
| app.listen(port, () => { | |
| console.log(`Server running on http://localhost:${port}`); | |
| console.log('PDF Conversion API Ready!'); | |
| console.log('Ensure Ghostscript (gs) is installed for compression.'); | |
| console.log('Ensure LibreOffice (soffice) is installed for doc conversion.'); | |
| }); | |