wa-dev-tools/routes/pdf.js
treamz 378c1a075e Add PDF tools: merge, split, rotate, delete, watermark, compress, convert, protect
- 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
2026-03-22 01:10:37 +03:00

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;