diff --git a/lib/image.js b/lib/image.js
new file mode 100644
index 0000000..67c8397
--- /dev/null
+++ b/lib/image.js
@@ -0,0 +1,39 @@
+/**
+ * Открытие загруженных картинок через sharp с поддержкой HEIC.
+ * HEIC (фото с iPhone) — HEIF с кодеком HEVC. Сборка libvips в sharp декодирует только
+ * AV1-HEIF (AVIF), поэтому HEIC разбираем через libheif-js (WASM) и отдаём sharp сырые пиксели.
+ */
+
+const fs = require('fs');
+const sharp = require('sharp');
+const heicDecode = require('heic-decode');
+
+const HEVC_BRANDS = ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs'];
+
+// file — объект multer ({ path, originalname, mimetype })
+function isHeic(file) {
+ let brand = '';
+ try {
+ const fd = fs.openSync(file.path, 'r');
+ const head = Buffer.alloc(12);
+ fs.readSync(fd, head, 0, 12, 0);
+ fs.closeSync(fd);
+ if (head.toString('latin1', 4, 8) === 'ftyp') brand = head.toString('latin1', 8, 12);
+ } catch {}
+ if (HEVC_BRANDS.includes(brand)) return true;
+ if (brand === 'avif' || brand === 'avis') return false;
+ return /\.(heic|heif)$/i.test(file.originalname) || /^image\/hei[cf]/.test(file.mimetype);
+}
+
+async function openImage(file) {
+ if (!isHeic(file)) return sharp(file.path);
+ const { width, height, data } = await heicDecode({ buffer: await fs.promises.readFile(file.path) });
+ const image = sharp(Buffer.from(data.buffer, data.byteOffset, data.byteLength), { raw: { width, height, channels: 4 } });
+ // libheif всегда отдаёт RGBA; у фото альфа сплошь 255 — без неё не будет лишнего PNG/альфа-канала
+ // (пересобираем буфер: metadata() описывает вход, и removeAlpha() в конвейере его hasAlpha не сбросит)
+ for (let i = 3; i < data.length; i += 4) if (data[i] !== 255) return image;
+ const rgb = await image.removeAlpha().raw().toBuffer();
+ return sharp(rgb, { raw: { width, height, channels: 3 } });
+}
+
+module.exports = { isHeic, openImage };
diff --git a/package-lock.json b/package-lock.json
index b51c405..9521c0a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@adminjs/sequelize": "^4.1.1",
"@adminjs/sql": "^2.2.6",
"@mozilla/readability": "^0.6.0",
+ "@pdf-lib/fontkit": "^1.1.1",
"adminjs": "^7.8.17",
"archiver": "^7.0.1",
"bcrypt": "^6.0.0",
@@ -24,6 +25,7 @@
"express-rate-limit": "^8.3.0",
"express-session": "^1.18.1",
"geoip-lite": "^1.4.10",
+ "heic-decode": "^2.1.0",
"helmet": "^8.1.0",
"iconv-lite": "^0.7.2",
"jsdom": "^28.1.0",
@@ -3368,6 +3370,15 @@
"node": ">= 8"
}
},
+ "node_modules/@pdf-lib/fontkit": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
+ "integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.6"
+ }
+ },
"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",
@@ -7096,6 +7107,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/heic-decode": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
+ "integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
+ "license": "ISC",
+ "dependencies": {
+ "libheif-js": "^1.19.8"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
@@ -7784,6 +7807,15 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/libheif-js": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.2.tgz",
+ "integrity": "sha512-qvHIXtggEsw1lCNCWBYKloL2Z36DJBm0R9ThGiH2JnhKYdeZFLPFkP30Lw4yMskxxhx0bKg1gLrBHX1D2w2pSw==",
+ "license": "LGPL-3.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
diff --git a/package.json b/package.json
index 11e45c1..38fc9a0 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"express-rate-limit": "^8.3.0",
"express-session": "^1.18.1",
"geoip-lite": "^1.4.10",
+ "heic-decode": "^2.1.0",
"helmet": "^8.1.0",
"iconv-lite": "^0.7.2",
"jsdom": "^28.1.0",
diff --git a/public/compress.html b/public/compress.html
index 3c58759..298091f 100644
--- a/public/compress.html
+++ b/public/compress.html
@@ -26,14 +26,14 @@
.quality-val { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-top: 3px; text-align: center; }
/* ── Summary bar ── */
- .summary-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--surface-700); border: 1px solid var(--surface-600); border-radius: 12px; margin-top: 20px; }
+ .summary-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 12px; margin-top: 20px; }
.summary-bar.hidden { display: none; }
.summary-stat { font-size: 12px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); }
.summary-stat strong { color: var(--text-primary); }
.summary-savings { font-size: 18px; font-weight: 800; font-family: 'JetBrains Mono', monospace; }
/* ── File list ── */
- .file-list { margin-top: 12px; border: 1px solid var(--surface-600); border-radius: 12px; overflow: hidden; }
+ .file-list { margin-top: 12px; background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 12px; overflow: hidden; }
.file-list.hidden { display: none; }
.file-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--surface-600); transition: background .15s; }
@@ -138,7 +138,7 @@
Перетащите файлы сюда или выберите
+
Текст водяного знака (необязательно)
@@ -932,20 +1098,8 @@
-
-
Загрузить изображения
-
-
-
Перетащите изображения или нажмите
-
PNG, JPG, JPEG, WebP
-
-
-
-
+
Сборка PDF из картинок — в отдельном конструкторе: порядок, поворот, повтор страниц, формат листа.
+
@@ -1061,8 +1215,6 @@
convertSub: 'toimg',
imgFormat: 'png',
imgDpi: '150',
- // images for fromImages
- imgFiles: [],
// compress
compressQuality: 'screen',
// pagenums
@@ -1115,6 +1267,7 @@
`;
}
+ window.pdfIcon = pdfIcon; // нужен inline-onerror превью файлов
// ─── Render file list ─────────────────────────────────────────────────────
function renderFileList() {
@@ -1230,53 +1383,16 @@
if (fileInputMore.files.length) handleFiles(Array.from(fileInputMore.files));
});
- // ─── Images drop zone (fromImages) ────────────────────────────────────────
- const imgDropZone = document.getElementById('imgDropZone');
- const imgFileInput = document.getElementById('imgFileInput');
- const imgFileList = document.getElementById('imgFileList');
-
- imgDropZone.addEventListener('click', () => imgFileInput.click());
- imgDropZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') imgFileInput.click(); });
- imgDropZone.addEventListener('dragover', e => { e.preventDefault(); imgDropZone.classList.add('drag-over'); });
- imgDropZone.addEventListener('dragleave', () => imgDropZone.classList.remove('drag-over'));
- imgDropZone.addEventListener('drop', e => {
- e.preventDefault();
- imgDropZone.classList.remove('drag-over');
- state.imgFiles = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
- renderImgFileList();
- });
- imgFileInput.addEventListener('change', () => {
- state.imgFiles = Array.from(imgFileInput.files);
- renderImgFileList();
- });
-
- function renderImgFileList() {
- imgFileList.innerHTML = '';
- state.imgFiles.forEach((f, idx) => {
- const card = document.createElement('div');
- card.className = 'file-card';
- card.innerHTML = `
-
${idx + 1}
-
-
-
${escHtml(f.name)}
-
${formatSize(f.size)}
-
`;
- imgFileList.appendChild(card);
- });
- }
-
// ─── Upload files ─────────────────────────────────────────────────────────
async function handleFiles(files) {
clearError();
const pdfFiles = files.filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
if (pdfFiles.length === 0) {
- showError('Выберите PDF-файлы.');
+ // Одни картинки — сразу в конструктор «Картинки → PDF»
+ const images = files.filter(isImageFile);
+ if (images.length) { openImgStudio(images); return; }
+ showError('Выберите PDF-файлы или изображения.');
return;
}
@@ -1366,7 +1482,7 @@
Перетащите PDF-файлы или нажмите для выбора
-
PDF — можно несколько файлов сразу
`;
+
PDF — можно несколько файлов сразу · картинки соберём в PDF
`;
}
// ─── Operation tabs ───────────────────────────────────────────────────────
@@ -1506,8 +1622,8 @@
showError('Введите пароль для защиты файла.');
return;
}
- if (op === 'convert' && state.convertSub === 'fromimg' && state.imgFiles.length === 0) {
- showError('Добавьте изображения для конвертации в PDF.');
+ if (op === 'convert' && state.convertSub === 'fromimg') {
+ openImgStudio([]);
return;
}
@@ -1573,16 +1689,8 @@
body = JSON.stringify({ fileId, quality: state.compressQuality });
break;
case 'convert':
- if (state.convertSub === 'toimg') {
- url = '/pdf/toImages';
- body = JSON.stringify({ fileId, format: state.imgFormat, dpi: parseInt(state.imgDpi, 10) });
- } else {
- url = '/pdf/fromImages';
- isMultipart = true;
- const fd = new FormData();
- state.imgFiles.forEach(f => fd.append('files', f));
- body = fd;
- }
+ url = '/pdf/toImages';
+ body = JSON.stringify({ fileId, format: state.imgFormat, dpi: parseInt(state.imgDpi, 10) });
break;
case 'security':
if (state.securitySub === 'protect') {
@@ -1693,7 +1801,6 @@
function resetToUpload() {
state.files = [];
state.selectedFileId = null;
- state.imgFiles = [];
stepConfig.style.display = 'none';
stepResult.style.display = 'none';
@@ -1710,6 +1817,374 @@
// ─── Security sub-op default ──────────────────────────────────────────────
state.securitySub = 'protect';
+ // ─── Картинки → PDF (конструктор) ─────────────────────────────────────────
+ // ip.files — уникальные исходники; ip.items — страницы будущего PDF (повторы
+ // ссылаются на один и тот же файл и на сервер уходят один раз).
+ const IP_MAX_FILES = 50;
+ const IP_MAX_SIZE = 50 * 1024 * 1024;
+ const IP_QUALITY_HINTS = {
+ original: 'Картинки без пересжатия — максимальное качество, самый большой файл.',
+ high: 'До 3000 px по длинной стороне — для печати.',
+ medium: 'До 2000 px — хорошо для экрана и почты.',
+ low: 'До 1400 px, сильное сжатие — самый лёгкий файл.',
+ };
+ const ip = { files: [], items: [], seq: 0, prevStep: null, dragUid: null,
+ pageSize: 'fit', orient: 'auto', margin: '0', quality: 'original' };
+ const ipEl = id => document.getElementById(id);
+ const stepImages = ipEl('stepImages');
+ const ipGrid = ipEl('ipGrid');
+ const ipInput = ipEl('ipInput');
+ const ipDrop = ipEl('ipDrop');
+ const ipCollator = new Intl.Collator('ru', { numeric: true, sensitivity: 'base' });
+
+ function isImageFile(f) {
+ return (f.type.startsWith('image/') && f.type !== 'image/svg+xml')
+ || /\.(jpe?g|png|webp|avif|heic|heif|tiff?|gif|bmp)$/i.test(f.name);
+ }
+
+ function openImgStudio(files) {
+ clearError();
+ if (stepImages.style.display === 'none') {
+ ip.prevStep = stepConfig.style.display !== 'none' ? stepConfig : stepUpload;
+ stepUpload.style.display = 'none';
+ stepConfig.style.display = 'none';
+ stepImages.style.display = 'block';
+ }
+ if (files && files.length) ipAddFiles(files);
+ ipRender();
+ stepImages.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+
+ function closeImgStudio() {
+ stepImages.style.display = 'none';
+ (ip.prevStep || stepUpload).style.display = 'block';
+ if (ip.prevStep !== stepConfig) restoreDropZone();
+ fileInput.value = '';
+ clearError();
+ }
+
+ function ipAddFiles(list) {
+ const arr = Array.from(list);
+ const bad = arr.filter(f => !isImageFile(f));
+ const big = arr.filter(f => isImageFile(f) && f.size > IP_MAX_SIZE);
+ let ok = arr.filter(f => isImageFile(f) && f.size <= IP_MAX_SIZE);
+ const room = IP_MAX_FILES - ip.files.length;
+ const over = Math.max(0, ok.length - room);
+ if (over) ok = ok.slice(0, Math.max(0, room));
+ ok.forEach(file => {
+ const f = { id: ++ip.seq, file, url: null, thumb: 'native' };
+ ip.files.push(f);
+ if (ipNeedsServerThumb(file)) ipQueueThumb(f);
+ else f.url = URL.createObjectURL(file);
+ ip.items.push({ uid: ++ip.seq, fid: f.id, rot: 0 });
+ });
+ const msgs = [];
+ if (bad.length) msgs.push(bad.length + ' — не изображение');
+ if (big.length) msgs.push(big.length + ' — больше 50 МБ');
+ if (over) msgs.push(over + ' — сверх лимита в ' + IP_MAX_FILES + ' файлов');
+ if (msgs.length) showError('Пропущено: ' + msgs.join(', '));
+ ipResetResult();
+ }
+
+ function ipFile(fid) { return ip.files.find(f => f.id === fid); }
+
+ function ipDropUnusedFiles() {
+ ip.files = ip.files.filter(f => {
+ const used = ip.items.some(it => it.fid === f.id);
+ if (!used && f.url) URL.revokeObjectURL(f.url);
+ return used;
+ });
+ }
+
+ function ipRender() {
+ ipEl('ipWork').style.display = ip.items.length ? 'block' : 'none';
+ ipEl('ipCount').textContent = ip.items.length
+ ? ip.items.length + ' стр. · ' + ip.files.length + ' файл(ов) · ' + formatSize(ip.files.reduce((s, f) => s + f.file.size, 0))
+ : '';
+ ipEl('ipBuild').textContent = 'Собрать PDF' + (ip.items.length ? ' · ' + ip.items.length + ' стр.' : '');
+ ipGrid.innerHTML = '';
+ ip.items.forEach((it, idx) => {
+ const f = ipFile(it.fid);
+ const card = document.createElement('div');
+ card.className = 'ip-card';
+ card.draggable = true;
+ card.dataset.uid = it.uid;
+ card.innerHTML = `
+
${ipThumbHtml(f, it.rot)}
+
${idx + 1}
+ ${it.rot ? `
${it.rot}°` : ''}
+
${escHtml(f.file.name)}
+
+
+
+
+
+
+
+
`;
+ ipGrid.appendChild(card);
+ });
+ }
+
+ // Превью: HEIC/TIFF браузер (кроме Safari) не рисует — их миниатюры делает сервер.
+ // Для остальных сначала пробуем показать сам файл, при ошибке — тоже просим сервер.
+ const ipThumbQueue = [];
+ let ipThumbActive = 0;
+
+ function ipNeedsServerThumb(file) {
+ return /\.(heic|heif|tiff?)$/i.test(file.name) || /^image\/(hei[cf]|tiff)/.test(file.type);
+ }
+
+ function ipQueueThumb(f) {
+ f.thumb = 'loading';
+ ipThumbQueue.push(f);
+ ipPumpThumbs();
+ }
+
+ function ipPumpThumbs() {
+ while (ipThumbActive < 3 && ipThumbQueue.length) {
+ const f = ipThumbQueue.shift();
+ if (!ip.files.includes(f)) continue;
+ ipThumbActive++;
+ const fd = new FormData();
+ fd.append('image', f.file, f.file.name);
+ fetch('/pdf/thumb', { method: 'POST', body: fd })
+ .then(r => r.ok ? r.blob() : Promise.reject(new Error(r.status)))
+ .then(blob => {
+ if (f.url) URL.revokeObjectURL(f.url);
+ f.url = URL.createObjectURL(blob);
+ f.thumb = 'server';
+ })
+ .catch(() => { f.thumb = 'none'; })
+ .finally(() => { ipThumbActive--; ipUpdateThumbs(f); ipPumpThumbs(); });
+ }
+ }
+
+ function ipThumbHtml(f, rot) {
+ if (f.thumb === 'loading') {
+ return '
';
+ }
+ if (f.thumb === 'none' || !f.url) {
+ const ext = (f.file.name.split('.').pop() || 'IMG').toUpperCase().slice(0, 5);
+ return `
+
+ ${escHtml(ext)}
без превью`;
+ }
+ return `

`;
+ }
+
+ function ipUpdateThumbs(f) {
+ ipGrid.querySelectorAll('.ip-thumb[data-fid="' + f.id + '"]').forEach(el => {
+ el.innerHTML = ipThumbHtml(f, parseInt(el.dataset.rot, 10) || 0);
+ });
+ }
+
+ // Браузер не смог показать файл сам → серверная миниатюра; не смог и её → плашка
+ ipGrid.addEventListener('error', e => {
+ if (e.target.tagName !== 'IMG') return;
+ const f = ipFile(parseInt(e.target.closest('.ip-thumb').dataset.fid, 10));
+ if (!f) return;
+ if (f.thumb === 'native') {
+ if (f.url) URL.revokeObjectURL(f.url);
+ f.url = null;
+ ipQueueThumb(f);
+ } else {
+ f.thumb = 'none';
+ }
+ ipUpdateThumbs(f);
+ }, true);
+
+ function ipResetResult() {
+ ipEl('ipResult').style.display = 'none';
+ ipEl('ipProgress').style.display = 'none';
+ ipEl('ipBuild').style.display = '';
+ }
+
+ function ipChanged() { ipResetResult(); ipRender(); }
+
+ // Действия на карточке
+ ipGrid.addEventListener('click', e => {
+ const btn = e.target.closest('button[data-a]');
+ if (!btn) return;
+ const uid = parseInt(btn.closest('.ip-card').dataset.uid, 10);
+ const i = ip.items.findIndex(it => it.uid === uid);
+ if (i < 0) return;
+ const it = ip.items[i];
+ switch (btn.dataset.a) {
+ case 'left': if (i > 0) ip.items.splice(i - 1, 0, ip.items.splice(i, 1)[0]); break;
+ case 'right': if (i < ip.items.length - 1) ip.items.splice(i + 1, 0, ip.items.splice(i, 1)[0]); break;
+ case 'rotl': it.rot = (it.rot + 270) % 360; break;
+ case 'rotr': it.rot = (it.rot + 90) % 360; break;
+ case 'dup': ip.items.splice(i + 1, 0, { uid: ++ip.seq, fid: it.fid, rot: it.rot }); break;
+ case 'del': ip.items.splice(i, 1); ipDropUnusedFiles(); break;
+ }
+ ipChanged();
+ });
+
+ // Перетаскивание карточек (порядок) и файлов с диска (добавление)
+ function ipClearDropMarks() {
+ ipGrid.querySelectorAll('.drop-before,.drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
+ }
+ ipGrid.addEventListener('dragstart', e => {
+ const card = e.target.closest('.ip-card');
+ if (!card) return;
+ ip.dragUid = parseInt(card.dataset.uid, 10);
+ card.classList.add('dragging');
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', String(ip.dragUid));
+ });
+ ipGrid.addEventListener('dragend', () => {
+ ip.dragUid = null;
+ ipClearDropMarks();
+ ipGrid.querySelectorAll('.dragging').forEach(c => c.classList.remove('dragging'));
+ });
+ stepImages.addEventListener('dragover', e => {
+ e.preventDefault();
+ if (ip.dragUid == null) { ipDrop.classList.add('drag-over'); return; }
+ const card = e.target.closest('.ip-card');
+ ipClearDropMarks();
+ if (!card || parseInt(card.dataset.uid, 10) === ip.dragUid) return;
+ const r = card.getBoundingClientRect();
+ card.classList.add(e.clientX < r.left + r.width / 2 ? 'drop-before' : 'drop-after');
+ });
+ stepImages.addEventListener('dragleave', e => {
+ if (!stepImages.contains(e.relatedTarget)) { ipDrop.classList.remove('drag-over'); ipClearDropMarks(); }
+ });
+ stepImages.addEventListener('drop', e => {
+ e.preventDefault();
+ ipDrop.classList.remove('drag-over');
+ if (ip.dragUid == null) {
+ if (e.dataTransfer.files.length) { ipAddFiles(e.dataTransfer.files); ipRender(); }
+ return;
+ }
+ const target = ipGrid.querySelector('.drop-before,.drop-after');
+ ipClearDropMarks();
+ if (!target) return;
+ const from = ip.items.findIndex(it => it.uid === ip.dragUid);
+ const moved = ip.items.splice(from, 1)[0];
+ let to = ip.items.findIndex(it => it.uid === parseInt(target.dataset.uid, 10));
+ if (target.classList.contains('drop-after')) to++;
+ ip.items.splice(to, 0, moved);
+ ip.dragUid = null;
+ ipChanged();
+ });
+
+ ipDrop.addEventListener('click', () => ipInput.click());
+ ipDrop.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ipInput.click(); } });
+ ipInput.addEventListener('change', () => {
+ if (ipInput.files.length) { ipAddFiles(ipInput.files); ipRender(); }
+ ipInput.value = '';
+ });
+
+ // Сортировка и массовые действия
+ ipEl('ipSort').addEventListener('change', e => {
+ const [key, dir] = e.target.value.split('-');
+ e.target.value = '';
+ if (!key) return;
+ const sign = dir === 'desc' ? -1 : 1;
+ const val = it => ipFile(it.fid).file;
+ ip.items.sort((a, b) => {
+ const fa = val(a), fb = val(b);
+ let c = 0;
+ if (key === 'name') c = ipCollator.compare(fa.name, fb.name);
+ else if (key === 'date') c = fa.lastModified - fb.lastModified;
+ else c = fa.size - fb.size;
+ return c * sign;
+ });
+ ipChanged();
+ });
+ stepImages.querySelectorAll('[data-bulk]').forEach(btn => btn.addEventListener('click', () => {
+ const a = btn.dataset.bulk;
+ if (a === 'reverse') ip.items.reverse();
+ else if (a === 'rotl') ip.items.forEach(it => { it.rot = (it.rot + 270) % 360; });
+ else if (a === 'rotr') ip.items.forEach(it => { it.rot = (it.rot + 90) % 360; });
+ else if (a === 'clear') { ip.items = []; ipDropUnusedFiles(); clearError(); }
+ ipChanged();
+ }));
+
+ // Настройки страницы
+ function ipInitGroup(id, key, onChange) {
+ const el = ipEl(id);
+ el.addEventListener('click', e => {
+ const btn = e.target.closest('.btn-option');
+ if (!btn || btn.disabled) return;
+ el.querySelectorAll('.btn-option').forEach(b => b.classList.remove('selected'));
+ btn.classList.add('selected');
+ ip[key] = btn.dataset.value;
+ if (onChange) onChange();
+ ipResetResult();
+ });
+ }
+ function ipSyncOrient() {
+ // Для «По картинке» ориентация задаётся самим изображением
+ ipEl('ipOrient').querySelectorAll('.btn-option').forEach(b => { b.disabled = ip.pageSize === 'fit'; });
+ }
+ function ipSyncQualityHint() { ipEl('ipQualityHint').textContent = IP_QUALITY_HINTS[ip.quality]; }
+ ipInitGroup('ipPageSize', 'pageSize', ipSyncOrient);
+ ipInitGroup('ipOrient', 'orient');
+ ipInitGroup('ipMargin', 'margin');
+ ipInitGroup('ipQuality', 'quality', ipSyncQualityHint);
+ ipSyncOrient();
+ ipSyncQualityHint();
+
+ // Сборка
+ ipEl('ipBuild').addEventListener('click', () => {
+ clearError();
+ if (!ip.items.length) { showError('Добавьте изображения.'); return; }
+ const index = new Map(ip.files.map((f, i) => [f.id, i]));
+ const fd = new FormData();
+ ip.files.forEach(f => fd.append('images', f.file, f.file.name));
+ fd.append('options', JSON.stringify({
+ pages: ip.items.map(it => ({ f: index.get(it.fid), r: it.rot })),
+ pageSize: ip.pageSize,
+ orientation: ip.orient,
+ margin: parseFloat(ip.margin) || 0,
+ quality: ip.quality,
+ filename: ipEl('ipName').value.trim() || 'images',
+ }));
+
+ const setP = (pct, text) => {
+ ipEl('ipFill').style.width = pct + '%';
+ ipEl('ipPct').textContent = pct + '%';
+ ipEl('ipStatus').textContent = text;
+ };
+ ipEl('ipBuild').style.display = 'none';
+ ipEl('ipResult').style.display = 'none';
+ ipEl('ipProgress').style.display = 'block';
+ setP(0, 'Загрузка изображений…');
+
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', '/pdf/fromImages');
+ xhr.upload.onprogress = e => {
+ if (!e.lengthComputable) return;
+ const pct = Math.round(e.loaded / e.total * 80);
+ setP(pct, pct >= 80 ? 'Сборка PDF на сервере…' : 'Загрузка изображений…');
+ };
+ xhr.upload.onload = () => setP(80, 'Сборка PDF на сервере…');
+ const fail = msg => { ipResetResult(); showError(msg); };
+ xhr.onerror = () => fail('Сетевая ошибка. Проверьте соединение и попробуйте снова.');
+ xhr.onload = () => {
+ let data = {};
+ try { data = JSON.parse(xhr.responseText); } catch {}
+ if (xhr.status !== 200 || !data.downloadUrl) return fail(data.error || 'Ошибка сервера ' + xhr.status);
+ setP(100, 'Готово!');
+ ipEl('ipProgress').style.display = 'none';
+ ipEl('ipResSize').textContent = formatSize(data.size);
+ ipEl('ipResPages').textContent = data.pages;
+ const a = ipEl('ipDownload');
+ a.href = data.downloadUrl;
+ a.setAttribute('download', data.filename || 'images.pdf');
+ ipEl('ipResult').style.display = 'block';
+ ipEl('ipResult').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ };
+ xhr.send(fd);
+ });
+ ipEl('ipAgain').addEventListener('click', ipResetResult);
+
+ ipEl('btnImgMode').addEventListener('click', () => openImgStudio([]));
+ ipEl('btnImgModeConvert').addEventListener('click', () => openImgStudio([]));
+ ipEl('ipBack').addEventListener('click', closeImgStudio);
+
// ─── Page Grid Thumbnails ─────────────────────────────────────────────────
function renderPageGrid(gridId, hintId, fileId, pages, mode) {
const grid = document.getElementById(gridId);
diff --git a/public/sanitizer.html b/public/sanitizer.html
index 666e6aa..c97ea0b 100644
--- a/public/sanitizer.html
+++ b/public/sanitizer.html
@@ -12,8 +12,9 @@
diff --git a/public/video.html b/public/video.html
index 5329bf7..64a897a 100644
--- a/public/video.html
+++ b/public/video.html
@@ -18,7 +18,7 @@
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s;
- background: var(--surface-700);
+ background: var(--surface-800);
}
.drop-zone:hover,
.drop-zone.drag-over {
@@ -49,7 +49,7 @@
/* Video info card */
.info-card {
- background: var(--surface-700);
+ background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 12px;
padding: 20px;
@@ -96,7 +96,7 @@
.mode-tabs {
display: flex;
gap: 4px;
- background: var(--surface-700);
+ background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 10px;
padding: 4px;
@@ -157,7 +157,7 @@
padding: 8px 18px;
border-radius: 8px;
border: 1px solid var(--surface-600);
- background: var(--surface-700);
+ background: var(--surface-800);
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
@@ -187,7 +187,7 @@
padding: 12px 16px;
border-radius: 10px;
border: 1px solid var(--surface-600);
- background: var(--surface-700);
+ background: var(--surface-800);
cursor: pointer;
transition: all .2s;
}
@@ -250,7 +250,7 @@
/* Progress */
.progress-wrap {
- background: var(--surface-700);
+ background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 12px;
padding: 24px;
@@ -283,7 +283,7 @@
/* Result card */
.result-card {
- background: var(--surface-700);
+ background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 12px;
padding: 24px;
diff --git a/routes/compress.js b/routes/compress.js
index c5cbeff..2e3d012 100644
--- a/routes/compress.js
+++ b/routes/compress.js
@@ -10,6 +10,7 @@ const crypto = require('crypto');
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();
@@ -96,9 +97,34 @@ function transliterate(str) {
.replace(/[^\w.\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
}
-function getOutputExtension(format, originalName) {
- const extMap = { webp: '.webp', jpeg: '.jpg', png: '.png', avif: '.avif', tiff: '.tiff', gif: '.gif' };
- return extMap[format] || path.extname(originalName);
+// Формат источника по содержимому (metadata sharp), а не по MIME от браузера
+function sourceFormatOf(meta) {
+ if (['jpeg', 'png', 'webp', 'tiff', 'gif'].includes(meta.format)) return meta.format;
+ if (meta.format === 'heif' && meta.compression === 'av1') return 'avif';
+ return null; // HEIC (raw после декодера), BMP и пр.
+}
+const FORMAT_INFO = {
+ webp: { ext: '.webp', mimetype: 'image/webp' },
+ jpeg: { ext: '.jpg', mimetype: 'image/jpeg' },
+ png: { ext: '.png', mimetype: 'image/png' },
+ avif: { ext: '.avif', mimetype: 'image/avif' },
+ tiff: { ext: '.tiff', mimetype: 'image/tiff' },
+ gif: { ext: '.gif', mimetype: 'image/gif' },
+};
+
+// 'original' сохраняет формат источника; то, что не пересжать в себя же (HEIC, BMP), уходит в JPEG
+async function encodeImage(image, format, meta, quality) {
+ const sourceFormat = sourceFormatOf(meta);
+ const target = format !== 'original' ? format : (sourceFormat || 'jpeg');
+ let buffer;
+ if (target === 'webp') buffer = await image.webp({ quality }).toBuffer();
+ else if (target === 'jpeg') buffer = await image.jpeg({ quality }).toBuffer();
+ else if (target === 'png') buffer = await image.png({ compressionLevel: 9 }).toBuffer();
+ else if (target === 'avif') buffer = await image.avif({ quality }).toBuffer();
+ else if (target === 'tiff') buffer = await image.tiff({ quality }).toBuffer();
+ else buffer = await image.gif().toBuffer();
+ const keepName = format === 'original' && !!sourceFormat;
+ return { buffer, mimetype: FORMAT_INFO[target].mimetype, ext: keepName ? null : FORMAT_INFO[target].ext };
}
function changeExtension(filename, newExt) {
@@ -152,7 +178,7 @@ router.post('/', limiter, uploadArray, async (req, res) => {
for (const file of req.files) {
const inputPath = file.path;
const originalSize = file.size;
- const image = sharp(inputPath);
+ const image = await openImage(file);
const metadata = await image.metadata();
if (resize > 0 && metadata.width && metadata.height && Math.max(metadata.width, metadata.height) > resize) {
@@ -170,27 +196,10 @@ router.post('/', limiter, uploadArray, async (req, res) => {
log.logToFile(msg);
}
- let buffer;
- if (format === 'webp') buffer = await image.webp({ quality }).toBuffer();
- else if (format === 'jpeg') buffer = await image.jpeg({ quality }).toBuffer();
- else if (format === 'png') buffer = await image.png({ compressionLevel: 9 }).toBuffer();
- else if (format === 'avif') buffer = await image.avif({ quality }).toBuffer();
- else if (format === 'tiff') buffer = await image.tiff({ quality }).toBuffer();
- else if (format === 'gif') buffer = await image.gif().toBuffer();
- else {
- // original — keep source format with compression
- if (file.mimetype === 'image/jpeg') buffer = await image.jpeg({ quality }).toBuffer();
- else if (file.mimetype === 'image/png') buffer = await image.png({ compressionLevel: 9 }).toBuffer();
- else if (file.mimetype === 'image/webp') buffer = await image.webp({ quality }).toBuffer();
- else if (file.mimetype === 'image/avif') buffer = await image.avif({ quality }).toBuffer();
- else if (file.mimetype === 'image/tiff') buffer = await image.tiff({ quality }).toBuffer();
- else if (file.mimetype === 'image/gif') buffer = await image.gif().toBuffer();
- else buffer = await image.jpeg({ quality }).toBuffer(); // fallback
- }
+ const { buffer, ext } = await encodeImage(image, format, metadata, quality);
const readableName = fixMulterFilename(file.originalname);
- const newExt = getOutputExtension(format, readableName);
- const rawName = format === 'original' ? readableName : changeExtension(readableName, newExt);
+ const rawName = ext ? changeExtension(readableName, ext) : readableName;
const outputName = transliterate(rawName);
archive.append(buffer, { name: outputName });
@@ -237,7 +246,7 @@ router.post('/single', limiter, uploadSingle, async (req, res) => {
const originalSize = req.file.size;
try {
- const image = sharp(inputPath);
+ const image = await openImage(req.file);
const metadata = await image.metadata();
if (resize > 0 && metadata.width && metadata.height && Math.max(metadata.width, metadata.height) > resize) {
@@ -252,27 +261,10 @@ router.post('/single', limiter, uploadSingle, async (req, res) => {
log.info(msg); log.logToFile(msg);
}
- let buffer;
- let outputMimetype;
- if (format === 'webp') { buffer = await image.webp({ quality }).toBuffer(); outputMimetype = 'image/webp'; }
- else if (format === 'jpeg') { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; }
- else if (format === 'png') { buffer = await image.png({ compressionLevel: 9 }).toBuffer(); outputMimetype = 'image/png'; }
- else if (format === 'avif') { buffer = await image.avif({ quality }).toBuffer(); outputMimetype = 'image/avif'; }
- else if (format === 'tiff') { buffer = await image.tiff({ quality }).toBuffer(); outputMimetype = 'image/tiff'; }
- else if (format === 'gif') { buffer = await image.gif().toBuffer(); outputMimetype = 'image/gif'; }
- else {
- if (req.file.mimetype === 'image/jpeg') { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; }
- else if (req.file.mimetype === 'image/png') { buffer = await image.png({ compressionLevel: 9 }).toBuffer(); outputMimetype = 'image/png'; }
- else if (req.file.mimetype === 'image/webp') { buffer = await image.webp({ quality }).toBuffer(); outputMimetype = 'image/webp'; }
- else if (req.file.mimetype === 'image/avif') { buffer = await image.avif({ quality }).toBuffer(); outputMimetype = 'image/avif'; }
- else if (req.file.mimetype === 'image/tiff') { buffer = await image.tiff({ quality }).toBuffer(); outputMimetype = 'image/tiff'; }
- else if (req.file.mimetype === 'image/gif') { buffer = await image.gif().toBuffer(); outputMimetype = 'image/gif'; }
- else { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; }
- }
+ const { buffer, mimetype: outputMimetype, ext } = await encodeImage(image, format, metadata, quality);
const readableName = fixMulterFilename(req.file.originalname);
- const newExt = getOutputExtension(format, readableName);
- const rawName = format === 'original' ? readableName : changeExtension(readableName, newExt);
+ const rawName = ext ? changeExtension(readableName, ext) : readableName;
const outputFilename = 'single_' + Date.now() + '_' + crypto.randomBytes(6).toString('hex') + '_' + transliterate(rawName);
const outputPath = require('path').join(RESULTS_DIR, outputFilename);
diff --git a/routes/pdf.js b/routes/pdf.js
index 7778b5b..e0b008a 100644
--- a/routes/pdf.js
+++ b/routes/pdf.js
@@ -9,6 +9,7 @@ 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();
@@ -18,7 +19,9 @@ const MAX_IMAGES = 50;
const pdfFileFilter = (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
+ // 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 и изображения'));
};
@@ -34,6 +37,27 @@ function uploadFiles(field, maxCount) {
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);
@@ -98,11 +122,11 @@ function getFile(id) {
return f;
}
-function saveResult(buffer, ext, req) {
+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);
+ 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}`;
}
@@ -604,31 +628,112 @@ router.post('/toImages', express.json(), async (req, res) => {
});
// 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) => {
- if (!req.files || !req.files.length) return res.status(400).json({ error: 'Изображения не загружены' });
+ 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');
- 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);
+ // Повторы одной картинки с тем же поворотом встраиваются в 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);
+ }
- const page = doc.addPage([img.width, img.height]);
- page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
+ 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;
+ }
- try { fs.unlinkSync(file.path); } catch {}
+ const page = doc.addPage([pw, ph]);
+ page.drawImage(img, { x: (pw - dw) / 2, y: (ph - dh) / 2, width: dw, height: dh });
}
- const result = await doc.save();
- const url = saveResult(Buffer.from(result), '.pdf', req);
- res.json({ downloadUrl: url, size: result.length, pages: req.files.length });
+ 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) {
- req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
- res.status(500).json({ error: err.message });
+ log.error('PDF fromImages error', { error: err.message });
+ res.status(500).json({ error: 'Не удалось собрать PDF: ' + err.message });
+ } finally {
+ cleanup();
}
});
@@ -641,14 +746,16 @@ router.get('/download/:filename', (req, res) => {
}
const filePath = path.join(RESULTS_DIR, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
- res.download(filePath);
+ 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.` });
- if (err.code === 'LIMIT_FILE_COUNT') return res.status(400).json({ error: `Слишком много файлов. Максимум ${MAX_FILES}.` });
+ 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 });