const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); const crypto = require('crypto'); const { createLimiter, isAdmin } = require('../lib/limits'); const archiver = require('archiver'); const log = require('../lib/logger'); const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage'); const owner = require('../lib/owner'); const { openImage } = require('../lib/image'); const router = express.Router(); const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB const MAX_FILES = 10; const MAX_IMAGES = 50; const pdfFileFilter = (req, file, cb) => { if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) cb(null, true); // for fromImages. SVG не берём: librsvg в sharp ходит по внешним ссылкам (SSRF/чтение файлов) else if (file.mimetype === 'image/svg+xml' || /\.svgz?$/i.test(file.originalname)) cb(new Error('SVG не поддерживается')); else if (file.mimetype.startsWith('image/') || /\.(jpe?g|png|webp|avif|heic|heif|tiff?|gif|bmp)$/i.test(file.originalname)) cb(null, true); else cb(new Error('Только PDF и изображения')); }; const upload = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: pdfFileFilter }); // Админ — без лимита размера и количества, как в /compress и /video const uploadAdmin = multer({ dest: UPLOADS_DIR, fileFilter: pdfFileFilter }); function uploadFiles(field, maxCount) { return (req, res, next) => isAdmin(req) ? uploadAdmin.array(field)(req, res, next) : upload.array(field, maxCount)(req, res, next); } const limiter = createLimiter({ windowMs: 60000, max: 30 }); // Превью для конструктора «Картинки → PDF»: HEIC/TIFF браузер не рисует — отдаём JPEG ≤ 320 px. // Стоит до общего лимитера: на 50 картинок — 50 запросов, а общий лимит — 30/мин. const thumbLimiter = createLimiter({ windowMs: 60000, max: 300 }); router.post('/thumb', thumbLimiter, (req, res, next) => (isAdmin(req) ? uploadAdmin : upload).single('image')(req, res, next), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'Изображение не загружено' }); try { const image = await openImage(req.file); const buf = await image.autoOrient() .resize({ width: 320, height: 320, fit: 'inside', withoutEnlargement: true }) .flatten({ background: '#ffffff' }) .jpeg({ quality: 75 }) .toBuffer(); res.type('jpeg').set('Cache-Control', 'no-store').send(buf); } catch (err) { log.warn('PDF thumb error', { error: err.message }); res.status(415).json({ error: 'Не удалось прочитать изображение' }); } finally { try { fs.unlinkSync(req.file.path); } catch {} } }); // Rate-limit ALL pdf routes (several spawn Ghostscript / do heavy pdf-lib work) router.use(limiter); // Ghostscript-обёртка: без неё каждый вызов падал наружу сырым «spawn gs ENOENT». const GS_MISSING = 'GS_MISSING'; function runGhostscript(args, timeout) { return new Promise((resolve, reject) => { const proc = spawn('gs', args, { timeout }); let stderr = ''; if (proc.stderr) proc.stderr.on('data', d => { stderr += d.toString(); }); proc.on('close', code => { if (code === 0) return resolve(); reject(new Error('Ghostscript exited with code ' + code + (stderr ? ': ' + stderr.slice(-300) : ''))); }); proc.on('error', err => { if (err && err.code === 'ENOENT') return reject(new Error(GS_MISSING)); reject(err); }); }); } // Ответ, когда Ghostscript не установлен на сервере function gsUnavailable(res, where) { log.error('Ghostscript not found (spawn gs ENOENT)', { where }); return res.status(503).json({ error: 'Операция недоступна: на сервере не установлен Ghostscript' }); } // Fix multer filename encoding (Latin-1 → UTF-8 for Cyrillic) function fixFilename(str) { try { const buf = Buffer.from(str, 'latin1'); const decoded = buf.toString('utf8'); if (/[а-яёА-ЯЁ]/.test(decoded)) return decoded; } catch {} return str; } // 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: fixFilename(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, req, displayName) { const name = `result_${Date.now()}_${crypto.randomBytes(6).toString('hex')}${ext}`; const outPath = path.join(RESULTS_DIR, name); fs.writeFileSync(outPath, buffer); owner.claim(name, req && req.session && req.session.user && req.session.user.id, displayName); 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', uploadFiles('files', MAX_FILES), async (req, res) => { if (!req.files || !req.files.length) return res.status(400).json({ error: 'Файлы не загружены' }); const results = []; for (const file of req.files) { let pages = 0; try { if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) { const buf = fs.readFileSync(file.path); // Use pdf-lib for page count (more reliable than pdf-parse) const { PDFDocument } = await import('pdf-lib'); const doc = await PDFDocument.load(buf, { ignoreEncryption: true }); pages = doc.getPageCount(); } } catch (e) { log.warn('PDF page count failed', { file: file.originalname, error: e.message }); } 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 }); }); // Preview — render PDF page as PNG thumbnail via Ghostscript router.get('/preview/:fileId', (req, res) => renderPreview(req, res, 1)); router.get('/preview/:fileId/:page', (req, res) => renderPreview(req, res, parseInt(req.params.page) || 1)); async function renderPreview(req, res, pageNum) { const f = getFile(req.params.fileId); if (!f) return res.status(404).json({ error: 'Файл не найден' }); const cacheKey = `${f.id}_p${pageNum}`; const cachePath = path.join(RESULTS_DIR, `preview_${cacheKey}.png`); // Return cached if exists if (fs.existsSync(cachePath)) { return res.type('png').sendFile(cachePath); } try { await runGhostscript([ '-sDEVICE=png16m', '-r72', '-dNOPAUSE', '-dBATCH', '-dQUIET', `-dFirstPage=${pageNum}`, `-dLastPage=${pageNum}`, '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', `-sOutputFile=${cachePath}`, f.path, ], 15000); // Cleanup after 30 min setTimeout(() => { try { fs.unlinkSync(cachePath); } catch {} }, 30 * 60 * 1000); res.type('png').sendFile(cachePath); } catch (err) { if (err.message === GS_MISSING) return gsUnavailable(res, 'preview'); log.error('PDF preview error', { error: err.message }); res.status(500).json({ error: 'Не удалось создать превью' }); } } // Thumbnails — return preview URLs for all pages router.get('/thumbnails/:fileId', async (req, res) => { const f = getFile(req.params.fileId); if (!f) return res.status(404).json({ error: 'Файл не найден' }); const urls = []; for (let i = 1; i <= Math.min(f.pages, 50); i++) { urls.push(`/pdf/preview/${f.id}/${i}`); } res.json({ pages: f.pages, thumbnails: urls }); }); // 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', req); 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', req); 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', req); 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', req); 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', req); res.json({ downloadUrl: url, size: result.length }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Watermark const WM_DEFAULTS = { text: 'КОНФИДЕНЦИАЛЬНО', fontSize: 36, opacity: 0.3, color: '#ff0000' }; const WM_FONT_PATH = path.join(__dirname, '..', 'public', 'vendor', 'fonts', 'manrope-600.ttf'); // Кириллица: StandardFonts.Helvetica кодирует только WinAnsi, поэтому встраиваем TTF. // Если fontkit/шрифт недоступны — откатываемся на Helvetica с транслитерацией. const WM_TRANSLIT = { а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'e',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p', р:'r',с:'s',т:'t',у:'u',ф:'f',х:'h',ц:'c',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya', }; function translitWatermark(str) { return str.replace(/[а-яёА-ЯЁ]/g, ch => { const lower = ch.toLowerCase(); const mapped = WM_TRANSLIT[lower] !== undefined ? WM_TRANSLIT[lower] : ch; return ch === lower ? mapped : mapped.toUpperCase(); }); } async function embedWatermarkFont(doc, StandardFonts) { try { const fontkit = (await import('@pdf-lib/fontkit')).default; doc.registerFontkit(fontkit); const font = await doc.embedFont(fs.readFileSync(WM_FONT_PATH), { subset: true }); return { font, unicode: true }; } catch (err) { log.warn('Watermark: TTF unavailable, falling back to Helvetica', { error: err.message }); return { font: await doc.embedFont(StandardFonts.Helvetica), unicode: false }; } } router.post('/watermark', express.json(), async (req, res) => { const { fileId } = req.body; const f = getFile(fileId); if (!f) return res.status(404).json({ error: 'Файл не найден' }); // Все параметры кроме файла опциональны — пустые/битые значения заменяются дефолтами. const rawText = typeof req.body.text === 'string' ? req.body.text.trim() : ''; const text = rawText ? rawText.slice(0, 200) : WM_DEFAULTS.text; const fontSize = Math.min(200, Math.max(6, Number(req.body.fontSize) || WM_DEFAULTS.fontSize)); const opacity = Math.min(1, Math.max(0.05, Number(req.body.opacity) || WM_DEFAULTS.opacity)); const color = /^#[0-9a-fA-F]{6}$/.test(String(req.body.color || '')) ? String(req.body.color) : WM_DEFAULTS.color; try { const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib'); const buf = fs.readFileSync(f.path); const doc = await PDFDocument.load(buf); const { font, unicode } = await embedWatermarkFont(doc, StandardFonts); const drawnText = unicode ? text : translitWatermark(text); 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 diagonal = Math.sqrt(width * width + height * height) * 0.9; let size = fontSize; let textWidth = font.widthOfTextAtSize(drawnText, size); if (textWidth > diagonal) { size = Math.max(6, size * (diagonal / textWidth)); textWidth = font.widthOfTextAtSize(drawnText, size); } // Центрируем повёрнутую на -45° надпись относительно середины страницы const rad = Math.PI / 4; page.drawText(drawnText, { x: width / 2 - (textWidth / 2) * Math.cos(rad), y: height / 2 + (textWidth / 2) * Math.sin(rad), size, font, color: rgb(r, g, b), opacity, rotate: { type: 'degrees', angle: -45 }, }); } const result = await doc.save(); const url = saveResult(Buffer.from(result), '.pdf', req); res.json({ downloadUrl: url, size: result.length }); } catch (err) { log.error('PDF watermark error', { error: err.message }); res.status(500).json({ error: 'Не удалось добавить водяной знак' }); } }); // 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', req); 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()}_${crypto.randomBytes(6).toString('hex')}.pdf`; const outPath = path.join(RESULTS_DIR, outName); const settings = { screen: '/screen', ebook: '/ebook', printer: '/printer' }; try { await runGhostscript([ '-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4', `-dPDFSETTINGS=${settings[quality] || '/ebook'}`, '-dNOPAUSE', '-dBATCH', '-dQUIET', `-sOutputFile=${outPath}`, f.path, ], 120000); const stat = fs.statSync(outPath); owner.claim(outName, req.session && req.session.user && req.session.user.id); 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 {} if (err.message === GS_MISSING) return gsUnavailable(res, 'compress'); log.error('PDF compress error', { error: err.message }); res.status(500).json({ error: 'Не удалось сжать PDF' }); } }); // 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', req); 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' } = req.body; // Clamp DPI to avoid Ghostscript rendering gigantic bitmaps (memory/disk DoS) const dpi = Math.min(300, Math.max(36, parseInt(req.body.dpi) || 150)); const f = getFile(fileId); if (!f) return res.status(404).json({ error: 'Файл не найден' }); const tmpDir = path.join(RESULTS_DIR, `img_${Date.now()}`); fs.mkdirSync(tmpDir, { recursive: true }); const device = format === 'jpg' ? 'jpeg' : 'png16m'; try { await runGhostscript([ `-sDEVICE=${device}`, `-r${dpi}`, '-dNOPAUSE', '-dBATCH', '-dQUIET', `-sOutputFile=${tmpDir}/page_%03d.${format === 'jpg' ? 'jpg' : 'png'}`, f.path, ], 120000); // ZIP the images const zipName = `pages_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.zip`; const zipPath = path.join(RESULTS_DIR, zipName); const output = fs.createWriteStream(zipPath); const archive = archiver('zip', { zlib: { level: 6 } }); const zipDone = new Promise((resolve, reject) => { output.on('close', resolve); output.on('error', reject); archive.on('error', reject); }); archive.pipe(output); archive.directory(tmpDir, false); await archive.finalize(); await zipDone; // 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); owner.claim(zipName, req.session && req.session.user && req.session.user.id); 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 {} if (err.message === GS_MISSING) return gsUnavailable(res, 'toImages'); log.error('PDF toImages error', { error: err.message }); res.status(500).json({ error: 'Не удалось преобразовать PDF в изображения' }); } }); // Images to PDF const MM = 72 / 25.4; const PAGE_SIZES = { a4: [595.28, 841.89], a3: [841.89, 1190.55], a5: [419.53, 595.28], letter: [612, 792] }; const IMG_QUALITY = { original: { maxSide: 0, jpeg: 92 }, high: { maxSide: 3000, jpeg: 88 }, medium: { maxSide: 2000, jpeg: 80 }, low: { maxSide: 1400, jpeg: 68 }, }; const MAX_PDF_PAGES = 500; // Готовит картинку к вставке в PDF: EXIF-ориентация, поворот, ужатие. Нетронутые // JPEG/PNG в режиме «оригинал» вставляются байт-в-байт — без потери качества. async function prepareImageForPdf(file, rotate, quality) { const q = IMG_QUALITY[quality] || IMG_QUALITY.original; const image = await openImage(file); const meta = await image.metadata(); const untouched = !rotate && !q.maxSide && (!meta.orientation || meta.orientation === 1); if (untouched && (meta.format === 'jpeg' || meta.format === 'png')) { return { bytes: await fs.promises.readFile(file.path), kind: meta.format === 'png' ? 'png' : 'jpg' }; } image.autoOrient(); if (rotate) image.rotate(rotate); if (q.maxSide) image.resize({ width: q.maxSide, height: q.maxSide, fit: 'inside', withoutEnlargement: true }); if (meta.hasAlpha) return { bytes: await image.png({ compressionLevel: 9 }).toBuffer(), kind: 'png' }; return { bytes: await image.jpeg({ quality: q.jpeg, mozjpeg: true }).toBuffer(), kind: 'jpg' }; } function safePdfName(name) { const base = String(name || '').replace(/\.pdf$/i, '').replace(/[\\/:*?"<>|\x00-\x1f]+/g, ' ').trim().slice(0, 120); return (base || 'images') + '.pdf'; } // multipart: images[] — уникальные файлы; options (JSON): // pages: [{ f: индекс файла, r: 0|90|180|270 }] — порядок страниц, повторы допустимы // pageSize: fit|a4|a3|a5|letter, orientation: auto|portrait|landscape, // margin: мм (0–50), quality: original|high|medium|low, filename router.post('/fromImages', uploadFiles('images', MAX_IMAGES), async (req, res) => { const files = req.files || []; const cleanup = () => files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} }); if (!files.length) return res.status(400).json({ error: 'Изображения не загружены' }); let opts = {}; try { opts = JSON.parse(req.body.options || '{}') || {}; } catch { cleanup(); return res.status(400).json({ error: 'Некорректные параметры' }); } const pages = Array.isArray(opts.pages) && opts.pages.length ? opts.pages.map(p => ({ f: parseInt(p && p.f, 10), r: ((parseInt(p && p.r, 10) || 0) % 360 + 360) % 360 })) : files.map((_, f) => ({ f, r: 0 })); if (pages.some(p => !(p.f >= 0 && p.f < files.length) || p.r % 90)) { cleanup(); return res.status(400).json({ error: 'Некорректный список страниц' }); } if (pages.length > MAX_PDF_PAGES && !isAdmin(req)) { cleanup(); return res.status(400).json({ error: `Слишком много страниц. Максимум ${MAX_PDF_PAGES}.` }); } const pageSize = PAGE_SIZES[opts.pageSize] ? opts.pageSize : 'fit'; const orientation = ['portrait', 'landscape'].includes(opts.orientation) ? opts.orientation : 'auto'; const margin = Math.min(50, Math.max(0, parseFloat(opts.margin) || 0)) * MM; const quality = IMG_QUALITY[opts.quality] ? opts.quality : 'original'; const filename = safePdfName(opts.filename); try { const { PDFDocument } = await import('pdf-lib'); const doc = await PDFDocument.create(); doc.setTitle(filename.replace(/\.pdf$/, '')); doc.setProducer('wadevelop.ru'); doc.setCreator('WA Dev Tools — Картинки → PDF'); // Повторы одной картинки с тем же поворотом встраиваются в PDF один раз const embedded = new Map(); for (const p of pages) { const key = p.f + ':' + p.r; let img = embedded.get(key); if (!img) { const { bytes, kind } = await prepareImageForPdf(files[p.f], p.r, quality); img = kind === 'png' ? await doc.embedPng(bytes) : await doc.embedJpg(bytes); embedded.set(key, img); } let pw, ph, dw, dh; if (pageSize === 'fit') { // 1 px = 1 pt, как и раньше; огромные фото ограничиваем 5080 pt (≈180 см — предел удобного просмотра) const k = Math.min(1, 5080 / Math.max(img.width, img.height)); dw = img.width * k; dh = img.height * k; pw = dw + margin * 2; ph = dh + margin * 2; } else { const [a, b] = PAGE_SIZES[pageSize]; const landscape = orientation === 'landscape' || (orientation === 'auto' && img.width > img.height); pw = landscape ? b : a; ph = landscape ? a : b; const k = Math.min((pw - margin * 2) / img.width, (ph - margin * 2) / img.height); dw = img.width * k; dh = img.height * k; } const page = doc.addPage([pw, ph]); page.drawImage(img, { x: (pw - dw) / 2, y: (ph - dh) / 2, width: dw, height: dh }); } const result = Buffer.from(await doc.save()); const url = saveResult(result, '.pdf', req, filename); const msg = `[pdf] fromImages: ${files.length} files -> ${pages.length} pages size=${pageSize} q=${quality} (${(result.length / 1024).toFixed(0)}KB)`; log.info(msg); res.json({ downloadUrl: url, size: result.length, pages: pages.length, filename }); } catch (err) { log.error('PDF fromImages error', { error: err.message }); res.status(500).json({ error: 'Не удалось собрать PDF: ' + err.message }); } finally { cleanup(); } }); // Download router.get('/download/:filename', (req, res) => { const filename = path.basename(req.params.filename); // Имя результата предсказуемо по времени — отдаём только тому, кто его создал if (!owner.isOwner(filename, req.session && req.session.user && req.session.user.id)) { return res.status(404).json({ error: 'Файл не найден' }); } const filePath = path.join(RESULTS_DIR, filename); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' }); res.download(filePath, owner.displayName(filename) || filename); }); // 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: `Файл слишком большой. Максимум ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB.` }); const maxCount = req.path === '/fromImages' ? MAX_IMAGES : MAX_FILES; // multer.array(field, max) при превышении max бросает LIMIT_UNEXPECTED_FILE, а не LIMIT_FILE_COUNT if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') return res.status(400).json({ error: `Слишком много файлов. Максимум ${maxCount}.` }); return res.status(400).json({ error: err.message }); } if (err) return res.status(400).json({ error: err.message }); next(); }); module.exports = router;