- 15 API endpoints for PDF operations (pdf-lib, pdf-parse, Ghostscript) - Frontend with 8 operation tabs - Merge multiple PDFs, split by page ranges - Rotate/delete/reorder pages - Add watermark text, page numbers - Compress via Ghostscript (screen/ebook/printer quality) - PDF to PNG/JPG, Images to PDF - Password protection, text extraction - Added to sidebar, dashboard (Utils category), DB
506 lines
17 KiB
JavaScript
506 lines
17 KiB
JavaScript
const express = require('express');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { spawn } = require('child_process');
|
|
const rateLimit = require('express-rate-limit');
|
|
const archiver = require('archiver');
|
|
const log = require('../lib/logger');
|
|
|
|
const router = express.Router();
|
|
|
|
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
|
|
const DOWNLOADS_DIR = path.join(__dirname, '..', 'downloads');
|
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
|
|
const upload = multer({
|
|
dest: UPLOADS_DIR,
|
|
limits: { fileSize: MAX_FILE_SIZE },
|
|
fileFilter: (req, file, cb) => {
|
|
if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) cb(null, true);
|
|
else if (file.mimetype.startsWith('image/')) cb(null, true); // for fromImages
|
|
else cb(new Error('Только PDF и изображения'));
|
|
},
|
|
});
|
|
|
|
const limiter = rateLimit({ windowMs: 60000, max: 30, message: { error: 'Слишком много запросов' } });
|
|
|
|
// File storage for uploaded PDFs
|
|
const pdfFiles = new Map();
|
|
|
|
function registerFile(multerFile, pageCount) {
|
|
const id = `pdf_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const entry = {
|
|
id,
|
|
path: multerFile.path,
|
|
name: multerFile.originalname,
|
|
size: multerFile.size,
|
|
pages: pageCount || 0,
|
|
};
|
|
pdfFiles.set(id, entry);
|
|
setTimeout(() => {
|
|
try { fs.existsSync(entry.path) && fs.unlinkSync(entry.path); } catch {}
|
|
pdfFiles.delete(id);
|
|
}, 30 * 60 * 1000);
|
|
return entry;
|
|
}
|
|
|
|
function getFile(id) {
|
|
const f = pdfFiles.get(id);
|
|
if (!f || !fs.existsSync(f.path)) return null;
|
|
return f;
|
|
}
|
|
|
|
function saveResult(buffer, ext) {
|
|
const name = `result_${Date.now()}_${Math.random().toString(36).slice(2, 6)}${ext}`;
|
|
const outPath = path.join(DOWNLOADS_DIR, name);
|
|
fs.writeFileSync(outPath, buffer);
|
|
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
|
|
return `/pdf/download/${name}`;
|
|
}
|
|
|
|
// Page
|
|
router.get('/', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '..', 'public', 'pdf.html'));
|
|
});
|
|
|
|
// Upload
|
|
router.post('/upload', limiter, upload.array('files', 10), async (req, res) => {
|
|
if (!req.files || !req.files.length) return res.status(400).json({ error: 'Файлы не загружены' });
|
|
|
|
const pdfParse = require('pdf-parse');
|
|
const results = [];
|
|
|
|
for (const file of req.files) {
|
|
let pages = 0;
|
|
try {
|
|
if (file.mimetype === 'application/pdf') {
|
|
const buf = fs.readFileSync(file.path);
|
|
const data = await pdfParse(buf);
|
|
pages = data.numpages || 0;
|
|
}
|
|
} catch {}
|
|
const entry = registerFile(file, pages);
|
|
results.push({ id: entry.id, name: entry.name, size: entry.size, pages });
|
|
}
|
|
|
|
log.info(`PDF upload: ${results.length} files`);
|
|
res.json({ files: results });
|
|
});
|
|
|
|
// Info
|
|
router.get('/info/:fileId', async (req, res) => {
|
|
const f = getFile(req.params.fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
|
|
try {
|
|
const pdfParse = require('pdf-parse');
|
|
const buf = fs.readFileSync(f.path);
|
|
const data = await pdfParse(buf);
|
|
res.json({
|
|
pages: data.numpages,
|
|
title: data.info?.Title || '',
|
|
author: data.info?.Author || '',
|
|
creator: data.info?.Creator || '',
|
|
size: f.size,
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Merge
|
|
router.post('/merge', express.json(), async (req, res) => {
|
|
const { fileIds } = req.body;
|
|
if (!fileIds || fileIds.length < 2) return res.status(400).json({ error: 'Нужно минимум 2 файла' });
|
|
|
|
try {
|
|
const { PDFDocument } = await import('pdf-lib');
|
|
const merged = await PDFDocument.create();
|
|
|
|
for (const id of fileIds) {
|
|
const f = getFile(id);
|
|
if (!f) return res.status(404).json({ error: `Файл ${id} не найден` });
|
|
const buf = fs.readFileSync(f.path);
|
|
const doc = await PDFDocument.load(buf);
|
|
const pages = await merged.copyPages(doc, doc.getPageIndices());
|
|
pages.forEach(p => merged.addPage(p));
|
|
}
|
|
|
|
const result = await merged.save();
|
|
const url = saveResult(Buffer.from(result), '.pdf');
|
|
res.json({ downloadUrl: url, size: result.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Split
|
|
router.post('/split', express.json(), async (req, res) => {
|
|
const { fileId, ranges } = req.body;
|
|
const f = getFile(fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
|
|
try {
|
|
const { PDFDocument } = await import('pdf-lib');
|
|
const buf = fs.readFileSync(f.path);
|
|
const srcDoc = await PDFDocument.load(buf);
|
|
const totalPages = srcDoc.getPageCount();
|
|
|
|
// Parse ranges: "1-3,5,7-9"
|
|
const pageNums = [];
|
|
for (const part of ranges.split(',')) {
|
|
const trimmed = part.trim();
|
|
if (trimmed.includes('-')) {
|
|
const [start, end] = trimmed.split('-').map(Number);
|
|
for (let i = start; i <= Math.min(end, totalPages); i++) pageNums.push(i);
|
|
} else {
|
|
const n = parseInt(trimmed);
|
|
if (n >= 1 && n <= totalPages) pageNums.push(n);
|
|
}
|
|
}
|
|
|
|
if (pageNums.length === 0) return res.status(400).json({ error: 'Нет валидных страниц' });
|
|
|
|
// Single output PDF with selected pages
|
|
const newDoc = await PDFDocument.create();
|
|
const indices = pageNums.map(n => n - 1); // 0-based
|
|
const copiedPages = await newDoc.copyPages(srcDoc, indices);
|
|
copiedPages.forEach(p => newDoc.addPage(p));
|
|
|
|
const result = await newDoc.save();
|
|
const url = saveResult(Buffer.from(result), '.pdf');
|
|
res.json({ downloadUrl: url, size: result.length, pages: pageNums.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Rotate
|
|
router.post('/rotate', express.json(), async (req, res) => {
|
|
const { fileId, pages, angle } = req.body;
|
|
const f = getFile(fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
|
|
try {
|
|
const { PDFDocument, degrees } = await import('pdf-lib');
|
|
const buf = fs.readFileSync(f.path);
|
|
const doc = await PDFDocument.load(buf);
|
|
const total = doc.getPageCount();
|
|
|
|
const targetPages = pages === 'all'
|
|
? Array.from({ length: total }, (_, i) => i)
|
|
: pages.split(',').map(n => parseInt(n.trim()) - 1).filter(i => i >= 0 && i < total);
|
|
|
|
for (const idx of targetPages) {
|
|
const page = doc.getPage(idx);
|
|
const current = page.getRotation().angle;
|
|
page.setRotation(degrees(current + (angle || 90)));
|
|
}
|
|
|
|
const result = await doc.save();
|
|
const url = saveResult(Buffer.from(result), '.pdf');
|
|
res.json({ downloadUrl: url, size: result.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete pages
|
|
router.post('/delete', express.json(), async (req, res) => {
|
|
const { fileId, pages } = req.body;
|
|
const f = getFile(fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
|
|
try {
|
|
const { PDFDocument } = await import('pdf-lib');
|
|
const buf = fs.readFileSync(f.path);
|
|
const srcDoc = await PDFDocument.load(buf);
|
|
const total = srcDoc.getPageCount();
|
|
|
|
const deleteSet = new Set(pages.split(',').map(n => parseInt(n.trim()) - 1));
|
|
const keepIndices = Array.from({ length: total }, (_, i) => i).filter(i => !deleteSet.has(i));
|
|
|
|
if (keepIndices.length === 0) return res.status(400).json({ error: 'Нельзя удалить все страницы' });
|
|
|
|
const newDoc = await PDFDocument.create();
|
|
const copied = await newDoc.copyPages(srcDoc, keepIndices);
|
|
copied.forEach(p => newDoc.addPage(p));
|
|
|
|
const result = await newDoc.save();
|
|
const url = saveResult(Buffer.from(result), '.pdf');
|
|
res.json({ downloadUrl: url, size: result.length, pages: keepIndices.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Reorder
|
|
router.post('/reorder', express.json(), async (req, res) => {
|
|
const { fileId, order } = req.body;
|
|
const f = getFile(fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
|
|
try {
|
|
const { PDFDocument } = await import('pdf-lib');
|
|
const buf = fs.readFileSync(f.path);
|
|
const srcDoc = await PDFDocument.load(buf);
|
|
const indices = order.map(n => n - 1); // 1-based to 0-based
|
|
|
|
const newDoc = await PDFDocument.create();
|
|
const copied = await newDoc.copyPages(srcDoc, indices);
|
|
copied.forEach(p => newDoc.addPage(p));
|
|
|
|
const result = await newDoc.save();
|
|
const url = saveResult(Buffer.from(result), '.pdf');
|
|
res.json({ downloadUrl: url, size: result.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Watermark
|
|
router.post('/watermark', express.json(), async (req, res) => {
|
|
const { fileId, text, fontSize = 48, opacity = 0.3, color = '#888888' } = req.body;
|
|
const f = getFile(fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
|
|
try {
|
|
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
|
|
const buf = fs.readFileSync(f.path);
|
|
const doc = await PDFDocument.load(buf);
|
|
const font = await doc.embedFont(StandardFonts.Helvetica);
|
|
|
|
const r = parseInt(color.slice(1, 3), 16) / 255;
|
|
const g = parseInt(color.slice(3, 5), 16) / 255;
|
|
const b = parseInt(color.slice(5, 7), 16) / 255;
|
|
|
|
for (let i = 0; i < doc.getPageCount(); i++) {
|
|
const page = doc.getPage(i);
|
|
const { width, height } = page.getSize();
|
|
const textWidth = font.widthOfTextAtSize(text, fontSize);
|
|
|
|
page.drawText(text, {
|
|
x: (width - textWidth) / 2,
|
|
y: height / 2,
|
|
size: fontSize,
|
|
font,
|
|
color: rgb(r, g, b),
|
|
opacity,
|
|
rotate: { type: 'degrees', angle: -45 },
|
|
});
|
|
}
|
|
|
|
const result = await doc.save();
|
|
const url = saveResult(Buffer.from(result), '.pdf');
|
|
res.json({ downloadUrl: url, size: result.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Page numbers
|
|
router.post('/pagenumbers', express.json(), async (req, res) => {
|
|
const { fileId, position = 'bottom-center', startFrom = 1 } = req.body;
|
|
const f = getFile(fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
|
|
try {
|
|
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
|
|
const buf = fs.readFileSync(f.path);
|
|
const doc = await PDFDocument.load(buf);
|
|
const font = await doc.embedFont(StandardFonts.Helvetica);
|
|
const total = doc.getPageCount();
|
|
|
|
for (let i = 0; i < total; i++) {
|
|
const page = doc.getPage(i);
|
|
const { width } = page.getSize();
|
|
const numText = String(i + startFrom);
|
|
const textWidth = font.widthOfTextAtSize(numText, 10);
|
|
|
|
let x = (width - textWidth) / 2; // center
|
|
let y = 30;
|
|
|
|
if (position.includes('left')) x = 40;
|
|
if (position.includes('right')) x = width - 40 - textWidth;
|
|
if (position.includes('top')) y = page.getSize().height - 30;
|
|
|
|
page.drawText(numText, { x, y, size: 10, font, color: rgb(0.4, 0.4, 0.4) });
|
|
}
|
|
|
|
const result = await doc.save();
|
|
const url = saveResult(Buffer.from(result), '.pdf');
|
|
res.json({ downloadUrl: url, size: result.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Compress (Ghostscript)
|
|
router.post('/compress', express.json(), async (req, res) => {
|
|
const { fileId, quality = 'ebook' } = req.body;
|
|
const f = getFile(fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
|
|
const outName = `compressed_${Date.now()}.pdf`;
|
|
const outPath = path.join(DOWNLOADS_DIR, outName);
|
|
const settings = { screen: '/screen', ebook: '/ebook', printer: '/printer' };
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const proc = spawn('gs', [
|
|
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4',
|
|
`-dPDFSETTINGS=${settings[quality] || '/ebook'}`,
|
|
'-dNOPAUSE', '-dBATCH', '-dQUIET',
|
|
`-sOutputFile=${outPath}`, f.path,
|
|
], { timeout: 120000 });
|
|
proc.on('close', code => code === 0 ? resolve() : reject(new Error('Ghostscript error')));
|
|
proc.on('error', reject);
|
|
});
|
|
|
|
const stat = fs.statSync(outPath);
|
|
const savings = f.size > 0 ? Math.round((1 - stat.size / f.size) * 100) : 0;
|
|
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
|
|
|
|
res.json({ downloadUrl: `/pdf/download/${outName}`, size: stat.size, savings });
|
|
} catch (err) {
|
|
try { fs.unlinkSync(outPath); } catch {}
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Protect (add password)
|
|
router.post('/protect', express.json(), async (req, res) => {
|
|
const { fileId, password } = req.body;
|
|
const f = getFile(fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
if (!password) return res.status(400).json({ error: 'Пароль обязателен' });
|
|
|
|
try {
|
|
const { PDFDocument } = await import('pdf-lib');
|
|
const buf = fs.readFileSync(f.path);
|
|
const doc = await PDFDocument.load(buf);
|
|
doc.encrypt({ userPassword: password, ownerPassword: password });
|
|
|
|
const result = await doc.save();
|
|
const url = saveResult(Buffer.from(result), '.pdf');
|
|
res.json({ downloadUrl: url, size: result.length });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Extract text
|
|
router.post('/extract-text', express.json(), async (req, res) => {
|
|
const { fileId } = req.body;
|
|
const f = getFile(fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
|
|
try {
|
|
const pdfParse = require('pdf-parse');
|
|
const buf = fs.readFileSync(f.path);
|
|
const data = await pdfParse(buf);
|
|
res.json({
|
|
text: data.text,
|
|
pages: data.numpages,
|
|
metadata: data.info || {},
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// PDF to Images (Ghostscript)
|
|
router.post('/toImages', express.json(), async (req, res) => {
|
|
const { fileId, format = 'png', dpi = 150 } = req.body;
|
|
const f = getFile(fileId);
|
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
|
|
|
const tmpDir = path.join(DOWNLOADS_DIR, `img_${Date.now()}`);
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
const device = format === 'jpg' ? 'jpeg' : 'png16m';
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const proc = spawn('gs', [
|
|
`-sDEVICE=${device}`, `-r${dpi}`,
|
|
'-dNOPAUSE', '-dBATCH', '-dQUIET',
|
|
`-sOutputFile=${tmpDir}/page_%03d.${format === 'jpg' ? 'jpg' : 'png'}`,
|
|
f.path,
|
|
], { timeout: 120000 });
|
|
proc.on('close', code => code === 0 ? resolve() : reject(new Error('Ghostscript error')));
|
|
proc.on('error', reject);
|
|
});
|
|
|
|
// ZIP the images
|
|
const zipName = `pages_${Date.now()}.zip`;
|
|
const zipPath = path.join(DOWNLOADS_DIR, zipName);
|
|
const output = fs.createWriteStream(zipPath);
|
|
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
archive.pipe(output);
|
|
archive.directory(tmpDir, false);
|
|
await archive.finalize();
|
|
await new Promise(r => output.on('close', r));
|
|
|
|
// Cleanup temp dir
|
|
fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f)));
|
|
fs.rmdirSync(tmpDir);
|
|
|
|
setTimeout(() => { try { fs.unlinkSync(zipPath); } catch {} }, 30 * 60 * 1000);
|
|
const stat = fs.statSync(zipPath);
|
|
res.json({ downloadUrl: `/pdf/download/${zipName}`, size: stat.size });
|
|
} catch (err) {
|
|
try { fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f))); fs.rmdirSync(tmpDir); } catch {}
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Images to PDF
|
|
router.post('/fromImages', upload.array('images', 50), async (req, res) => {
|
|
if (!req.files || !req.files.length) return res.status(400).json({ error: 'Изображения не загружены' });
|
|
|
|
try {
|
|
const { PDFDocument } = await import('pdf-lib');
|
|
const doc = await PDFDocument.create();
|
|
|
|
for (const file of req.files) {
|
|
const imgBytes = fs.readFileSync(file.path);
|
|
let img;
|
|
if (file.mimetype === 'image/png') img = await doc.embedPng(imgBytes);
|
|
else img = await doc.embedJpg(imgBytes);
|
|
|
|
const page = doc.addPage([img.width, img.height]);
|
|
page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
|
|
|
|
try { fs.unlinkSync(file.path); } catch {}
|
|
}
|
|
|
|
const result = await doc.save();
|
|
const url = saveResult(Buffer.from(result), '.pdf');
|
|
res.json({ downloadUrl: url, size: result.length, pages: req.files.length });
|
|
} catch (err) {
|
|
req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Download
|
|
router.get('/download/:filename', (req, res) => {
|
|
const filename = path.basename(req.params.filename);
|
|
const filePath = path.join(DOWNLOADS_DIR, filename);
|
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
|
res.download(filePath);
|
|
});
|
|
|
|
// Multer error handler
|
|
router.use((err, req, res, next) => {
|
|
if (err instanceof multer.MulterError) {
|
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: 'Файл слишком большой. Максимум 50MB.' });
|
|
return res.status(400).json({ error: err.message });
|
|
}
|
|
if (err) return res.status(400).json({ error: err.message });
|
|
next();
|
|
});
|
|
|
|
module.exports = router;
|