wa-dev-tools/routes/pdf.js
treamz c65b3f5006 HEIC в /compress, конструктор «Картинки → PDF», белый фон зон загрузки и табов
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)
2026-09-22 20:37:48 +03:00

766 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;