From c65b3f5006c110ec3272f463d82b70ee614b6834 Mon Sep 17 00:00:00 2001 From: treamz Date: Tue, 22 Sep 2026 20:37:48 +0300 Subject: [PATCH] =?UTF-8?q?HEIC=20=D0=B2=20/compress,=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D0=BE=D1=80=20=C2=AB=D0=9A?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BA=D0=B8=20=E2=86=92=20PDF?= =?UTF-8?q?=C2=BB,=20=D0=B1=D0=B5=D0=BB=D1=8B=D0=B9=20=D1=84=D0=BE=D0=BD?= =?UTF-8?q?=20=D0=B7=D0=BE=D0=BD=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B8=20=D1=82=D0=B0=D0=B1=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/image.js | 39 +++ package-lock.json | 32 +++ package.json | 1 + public/compress.html | 13 +- public/converter.html | 2 +- public/formatter.html | 2 +- public/parser.html | 4 +- public/pdf.html | 647 ++++++++++++++++++++++++++++++++++++------ public/sanitizer.html | 5 +- public/video.html | 14 +- routes/compress.js | 78 +++-- routes/pdf.js | 145 ++++++++-- 12 files changed, 816 insertions(+), 166 deletions(-) create mode 100644 lib/image.js 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 @@ Перетащите файлы сюда или выберите

Файлы загружаются мгновенно, по одному

- + @@ -369,8 +369,11 @@ function createRow(id, file) { img.src = e.target.result; img.className = 'file-thumb'; img.alt = ''; - const ph = document.getElementById('thumb-' + id); - if (ph) ph.replaceWith(img); + // HEIC браузеры (кроме Safari) не рисуют — оставляем заглушку вместо битой картинки + img.onload = () => { + const ph = document.getElementById('thumb-' + id); + if (ph) ph.replaceWith(img); + }; }; reader.readAsDataURL(file); diff --git a/public/converter.html b/public/converter.html index e580640..2ed87d0 100644 --- a/public/converter.html +++ b/public/converter.html @@ -12,7 +12,7 @@ diff --git a/public/formatter.html b/public/formatter.html index 2e5eb33..10bf5e0 100644 --- a/public/formatter.html +++ b/public/formatter.html @@ -11,7 +11,7 @@ diff --git a/public/parser.html b/public/parser.html index 7ce4fa7..bae80dd 100644 --- a/public/parser.html +++ b/public/parser.html @@ -32,8 +32,8 @@ .article-content th, .article-content td { border: 1px solid var(--surface-600); padding: .5em .8em; text-align: left; } .article-content th { background: var(--surface-700); font-weight: 600; } - .mode-btn { transition: all .2s; } - .mode-btn.active { color: #0054e6; background: rgba(0,84,230,.08); border-color: rgba(0,84,230,.3); } + .mode-btn { transition: all .2s; background: var(--surface-800); } + .mode-btn.active { color: #0054e6; background: linear-gradient(rgba(0,84,230,.08),rgba(0,84,230,.08)),var(--surface-800); border-color: rgba(0,84,230,.3); } diff --git a/public/pdf.html b/public/pdf.html index 6836acb..9b6a0b9 100644 --- a/public/pdf.html +++ b/public/pdf.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 { @@ -66,7 +66,7 @@ /* Progress */ .progress-wrap { - background: var(--surface-700); + background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 12px; padding: 24px; @@ -134,7 +134,7 @@ display: flex; align-items: center; gap: 12px; - background: var(--surface-700); + background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 10px; padding: 12px 14px; @@ -205,7 +205,7 @@ .page-thumb-check svg { width:12px; height:12px; } /* Merge sortable list */ .merge-sort-list { display:flex; flex-direction:column; gap:6px; } - .merge-sort-item { display:flex; align-items:center; gap:10px; padding:10px 12px; background:var(--surface-700); border:1px solid var(--surface-600); border-radius:8px; cursor:grab; transition:all .15s; user-select:none; } + .merge-sort-item { display:flex; align-items:center; gap:10px; padding:10px 12px; background:var(--surface-800); border:1px solid var(--surface-600); border-radius:8px; cursor:grab; transition:all .15s; user-select:none; } .merge-sort-item:active { cursor:grabbing; } .merge-sort-item.dragging { opacity:0.4; } .merge-sort-item.drag-over { border-color:var(--accent); background:var(--accent-bg); } @@ -272,7 +272,7 @@ .op-tabs { display: flex; gap: 4px; - background: var(--surface-700); + background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 10px; padding: 4px; @@ -329,7 +329,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; @@ -357,7 +357,7 @@ padding: 7px 14px; border-radius: 7px; border: 1px solid var(--surface-600); - background: transparent; + background: var(--surface-800); color: var(--text-muted); font-family: 'Manrope', sans-serif; font-size: 12px; @@ -411,7 +411,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; } @@ -446,7 +446,7 @@ aspect-ratio: 1; border: 1px solid var(--surface-600); border-radius: 6px; - background: var(--surface-700); + background: var(--surface-800); cursor: pointer; transition: all .15s; display: flex; @@ -476,7 +476,7 @@ padding: 8px 14px; 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; @@ -489,7 +489,7 @@ /* Result card */ .result-card { - background: var(--surface-700); + background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 12px; padding: 24px; @@ -607,7 +607,7 @@ text-align: center; cursor: pointer; transition: border-color .2s, background .2s; - background: var(--surface-700); + background: var(--surface-800); } .drop-zone-sm:hover, .drop-zone-sm.drag-over { @@ -626,8 +626,45 @@ font-family: 'JetBrains Mono', monospace; } + /* ── Картинки → PDF ── */ + .ip-cta { display:flex; align-items:center; gap:12px; width:100%; margin-top:12px; padding:14px 16px; border:1px solid var(--surface-600); border-radius:12px; background:var(--surface-800); color:var(--text-primary); font-family:'Manrope',sans-serif; text-align:left; cursor:pointer; transition:border-color .2s, background .2s; } + .ip-cta:hover { border-color:var(--accent); background:var(--accent-bg); } + .ip-cta-icon { flex-shrink:0; width:40px; height:40px; border-radius:10px; display:flex; align-items:center; justify-content:center; background:var(--accent-bg); color:var(--accent); } + .ip-cta-title { font-size:14px; font-weight:700; } + .ip-cta-sub { font-size:12px; color:var(--text-muted); margin-top:2px; } + .ip-cta-arrow { margin-left:auto; color:var(--text-muted); font-size:18px; } + .ip-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:12px; flex-wrap:wrap; } + .ip-ghost { background:var(--surface-800); color:var(--text-secondary); border:1px solid var(--surface-600); } + .ip-ghost:hover { border-color:var(--accent); color:var(--text-primary); } + .ip-toolbar { display:flex; flex-wrap:wrap; align-items:center; gap:6px; margin:14px 0 10px; } + .ip-toolbar .grid-action-btn { padding:7px 12px; font-size:12px; } + .ip-select { padding:7px 10px; border-radius:8px; border:1px solid var(--surface-600); background:var(--surface-800); color:var(--text-primary); font-family:'Manrope',sans-serif; font-size:12px; font-weight:600; cursor:pointer; } + .ip-count { margin-left:auto; font-size:12px; color:var(--text-muted); font-family:'JetBrains Mono',monospace; } + .ip-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(128px, 1fr)); gap:10px; } + .ip-card { position:relative; border:2px solid var(--surface-600); border-radius:10px; background:var(--surface-800); overflow:hidden; cursor:grab; transition:border-color .15s, opacity .15s, transform .15s; user-select:none; } + .ip-card:hover { border-color:var(--text-muted); } + .ip-card.dragging { opacity:.35; } + .ip-card.drop-before { box-shadow:-4px 0 0 0 var(--accent); } + .ip-card.drop-after { box-shadow: 4px 0 0 0 var(--accent); } + .ip-thumb { aspect-ratio:1; display:flex; align-items:center; justify-content:center; background:var(--surface-800, rgba(0,0,0,.15)); overflow:hidden; } + .ip-thumb img { width:100%; height:100%; object-fit:contain; transition:transform .2s; pointer-events:none; } + .ip-noprev { display:flex; flex-direction:column; align-items:center; gap:4px; color:var(--text-muted); font-size:11px; font-weight:700; font-family:'JetBrains Mono',monospace; transition:transform .2s; } + .ip-num { position:absolute; top:6px; left:6px; min-width:22px; height:22px; padding:0 6px; border-radius:6px; background:var(--accent); color:#fff; font-size:11px; font-weight:700; font-family:'JetBrains Mono',monospace; display:flex; align-items:center; justify-content:center; } + .ip-rot { position:absolute; top:6px; right:6px; padding:2px 6px; border-radius:6px; background:rgba(0,0,0,.6); color:#fff; font-size:10px; font-family:'JetBrains Mono',monospace; } + .ip-name { padding:6px 8px 0; font-size:11px; font-weight:600; color:var(--text-secondary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + .ip-actions { display:flex; justify-content:space-between; padding:4px; gap:2px; } + .ip-actions button { flex:1; height:26px; border:none; border-radius:6px; background:transparent; color:var(--text-muted); font-size:14px; line-height:1; cursor:pointer; transition:background .15s, color .15s; } + .ip-actions button:hover { background:var(--accent-bg); color:var(--accent); } + .ip-actions button.ip-del:hover { background:rgba(239,68,68,.12); color:#ef4444; } + .ip-actions button:disabled { opacity:.3; cursor:default; background:transparent; color:var(--text-muted); } + .ip-settings { margin-top:16px; display:grid; gap:16px; background:var(--surface-800); } + .ip-settings .btn-option { padding:7px 12px; font-size:12px; } + .ip-settings .btn-option:disabled { opacity:.35; cursor:not-allowed; } + @media (max-width: 480px) { .result-stats { grid-template-columns: 1fr; } + .ip-grid { grid-template-columns:repeat(2, 1fr); } + .ip-count { margin-left:0; width:100%; } } @@ -665,9 +702,131 @@
Перетащите PDF-файлы или нажмите для выбора
-
PDF — можно несколько файлов сразу
+
PDF — можно несколько файлов сразу · картинки соберём в PDF
+ + + + + + + + + @@ -790,7 +949,14 @@ - + +
+
Перетащите 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 });