diff --git a/package-lock.json b/package-lock.json
index 9f57e8c..e0104af 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,9 @@
"openid-client": "^6.4.2",
"opentype.js": "^1.3.4",
"passport": "^0.7.0",
+ "pdf-lib": "^1.17.1",
+ "pdf-merger-js": "^5.1.2",
+ "pdf-parse": "^2.4.5",
"sequelize": "^6.37.8",
"sharp": "^0.34.1"
}
@@ -3123,6 +3126,208 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
+ "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
+ "license": "MIT",
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-x64": "0.1.80",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.80",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.80",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.80"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
+ "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
+ "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
+ "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
+ "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
+ "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
+ "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
+ "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
+ "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
+ "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
+ "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@pdf-lib/standard-fonts": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
+ "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.6"
+ }
+ },
+ "node_modules/@pdf-lib/upng": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
+ "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.10"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -7781,6 +7986,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7930,6 +8141,81 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
+ "node_modules/pdf-lib": {
+ "version": "1.17.1",
+ "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
+ "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@pdf-lib/standard-fonts": "^1.0.0",
+ "@pdf-lib/upng": "^1.0.1",
+ "pako": "^1.0.11",
+ "tslib": "^1.11.1"
+ }
+ },
+ "node_modules/pdf-lib/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
+ "node_modules/pdf-merger-js": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/pdf-merger-js/-/pdf-merger-js-5.1.2.tgz",
+ "integrity": "sha512-RCBjLQILZ8UA4keO/Ip2/gjUuxigMMoK7mO5eJg6zjlnyymboFnRTgzKwOs/FiU9ornS2m72Qr95oARX1C24fw==",
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^11.1.0",
+ "pdf-lib": "^1.17.1"
+ },
+ "bin": {
+ "pdf-merge": "cli.js"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/pdf-merger-js/node_modules/commander": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
+ "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/pdf-parse": {
+ "version": "2.4.5",
+ "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
+ "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@napi-rs/canvas": "0.1.80",
+ "pdfjs-dist": "5.4.296"
+ },
+ "bin": {
+ "pdf-parse": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": ">=20.16.0 <21 || >=22.3.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/mehmet-kozan"
+ }
+ },
+ "node_modules/pdfjs-dist": {
+ "version": "5.4.296",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
+ "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.16.0 || >=22.3.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.80"
+ }
+ },
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
diff --git a/package.json b/package.json
index 784e0e6..1f0d6fa 100755
--- a/package.json
+++ b/package.json
@@ -34,6 +34,9 @@
"openid-client": "^6.4.2",
"opentype.js": "^1.3.4",
"passport": "^0.7.0",
+ "pdf-lib": "^1.17.1",
+ "pdf-merger-js": "^5.1.2",
+ "pdf-parse": "^2.4.5",
"sequelize": "^6.37.8",
"sharp": "^0.34.1"
}
diff --git a/public/dashboard.html b/public/dashboard.html
index 132ae18..4b17f88 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -185,14 +185,15 @@
Утилиты
- 2
- пароли, Markdown и другие
+ 3
+ пароли, PDF, Markdown
-
+
Генератор паролей
Криптографически стойкая генерация
+
PDF инструменты
Объединение, сжатие, конвертация
Markdown Viewer
Просмотр и экспорт .md файлов
diff --git a/public/pdf.html b/public/pdf.html
new file mode 100644
index 0000000..03a0bbb
--- /dev/null
+++ b/public/pdf.html
@@ -0,0 +1,1639 @@
+
+
+
+
+
+ WA Dev Tools — PDF инструменты
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/shared.js b/public/shared.js
index a8e7bfc..c1892c3 100644
--- a/public/shared.js
+++ b/public/shared.js
@@ -26,7 +26,7 @@ const WA_CATEGORIES = [
{ id: 'images', title: 'Изображения', description: 'Сжатие, редактирование и генерация', icon: '', tools: ['compress', 'placeholder', 'svgeditor', 'editor', 'video'] },
{ id: 'code', title: 'Код', description: 'Форматирование, очистка и конвертация', icon: '', tools: ['formatter', 'sanitizer', 'converter'] },
{ id: 'web', title: 'Веб', description: 'HTTP-клиент, парсер и анализ', icon: '', tools: ['parser', 'httpclient', 'redirects'] },
- { id: 'utils', title: 'Утилиты', description: 'Пароли, Markdown и другие', icon: '', tools: ['password', 'md'] },
+ { id: 'utils', title: 'Утилиты', description: 'Пароли, Markdown и другие', icon: '', tools: ['password', 'md', 'pdf'] },
];
/* Sidebar tools definition */
@@ -48,6 +48,7 @@ const WA_TOOLS = [
{ id: 'redirects', path: '/redirects', title: 'Redirects', category: 'web', icon: '' },
// Утилиты
{ id: 'password', path: '/password', title: 'Пароли', category: 'utils', icon: '' },
+ { id: 'pdf', path: '/pdf', title: 'PDF', category: 'utils', icon: '' },
{ id: 'md', path: '/md', title: 'Markdown', category: 'utils', icon: '' },
// Admin (only visible in sidebar for admins — hidden by default, shown via JS)
{ id: 'admin', path: '/admin', title: 'Админ', category: null, adminOnly: true, icon: '' },
diff --git a/routes/pdf.js b/routes/pdf.js
new file mode 100644
index 0000000..36f76e5
--- /dev/null
+++ b/routes/pdf.js
@@ -0,0 +1,505 @@
+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;
diff --git a/server.js b/server.js
index 7e10333..2c71c26 100755
--- a/server.js
+++ b/server.js
@@ -87,6 +87,7 @@ app.use(require('./routes/httpclient'));
app.use(require('./routes/redirects'));
app.use(require('./routes/svgeditor'));
app.use('/video', require('./routes/video'));
+app.use('/pdf', require('./routes/pdf'));
app.use(require('./routes/pages'));
// Global error handler