HEIC: - lib/image.js — openImage(): HEIC (HEVC) через heic-decode (libheif WASM), т.к. sharp декодирует только AV1-HEIF; непрозрачная альфа отбрасывается - compress: общий encodeImage(); формат «оригинала» по содержимому (metadata), а не по MIME; HEIC/BMP в режиме «Оригинал» → JPEG с расширением .jpg Картинки → PDF (/pdf): - /pdf/fromImages переписан: любые форматы вкл. HEIC, порядок/повторы/поворот страниц, размер листа (по картинке/A4/A3/A5/Letter), ориентация, поля, качество, имя файла; повторы встраиваются один раз; нетронутые JPEG/PNG — байт-в-байт - /pdf/thumb — серверные миниатюры для HEIC/TIFF (свой мягкий лимитер) - UI-конструктор: сетка с превью, drag&drop, сортировка, массовый поворот, прогресс загрузки - старая вкладка была нерабочей (поле files вместо images) и требовала загрузки PDF - SVG не принимается (librsvg + внешние ссылки) - фикс: потерянная обёртка panel-watermark ломала вложенность stepConfig - фикс: pdfIcon недоступен из inline onerror UI: белый фон (--surface-800) у зон загрузки, неактивных табов, опций и карточек (compress, pdf, video, converter, formatter, sanitizer, parser)
766 lines
31 KiB
JavaScript
766 lines
31 KiB
JavaScript
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;
|