HEIC в /compress, конструктор «Картинки → PDF», белый фон зон загрузки и табов

HEIC:
- lib/image.js — openImage(): HEIC (HEVC) через heic-decode (libheif WASM), т.к. sharp
  декодирует только AV1-HEIF; непрозрачная альфа отбрасывается
- compress: общий encodeImage(); формат «оригинала» по содержимому (metadata), а не по MIME;
  HEIC/BMP в режиме «Оригинал» → JPEG с расширением .jpg

Картинки → PDF (/pdf):
- /pdf/fromImages переписан: любые форматы вкл. HEIC, порядок/повторы/поворот страниц,
  размер листа (по картинке/A4/A3/A5/Letter), ориентация, поля, качество, имя файла;
  повторы встраиваются один раз; нетронутые JPEG/PNG — байт-в-байт
- /pdf/thumb — серверные миниатюры для HEIC/TIFF (свой мягкий лимитер)
- UI-конструктор: сетка с превью, drag&drop, сортировка, массовый поворот, прогресс загрузки
- старая вкладка была нерабочей (поле files вместо images) и требовала загрузки PDF
- SVG не принимается (librsvg + внешние ссылки)
- фикс: потерянная обёртка panel-watermark ломала вложенность stepConfig
- фикс: pdfIcon недоступен из inline onerror

UI: белый фон (--surface-800) у зон загрузки, неактивных табов, опций и карточек
(compress, pdf, video, converter, formatter, sanitizer, parser)
This commit is contained in:
treamz 2026-09-22 20:37:48 +03:00
parent dabc26d70f
commit c65b3f5006
12 changed files with 816 additions and 166 deletions

39
lib/image.js Normal file
View File

@ -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 };

32
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@adminjs/sequelize": "^4.1.1", "@adminjs/sequelize": "^4.1.1",
"@adminjs/sql": "^2.2.6", "@adminjs/sql": "^2.2.6",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"@pdf-lib/fontkit": "^1.1.1",
"adminjs": "^7.8.17", "adminjs": "^7.8.17",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@ -24,6 +25,7 @@
"express-rate-limit": "^8.3.0", "express-rate-limit": "^8.3.0",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"geoip-lite": "^1.4.10", "geoip-lite": "^1.4.10",
"heic-decode": "^2.1.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.2",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
@ -3368,6 +3370,15 @@
"node": ">= 8" "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": { "node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
@ -7096,6 +7107,18 @@
"node": ">= 0.4" "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": { "node_modules/helmet": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
@ -7784,6 +7807,15 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",

View File

@ -30,6 +30,7 @@
"express-rate-limit": "^8.3.0", "express-rate-limit": "^8.3.0",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"geoip-lite": "^1.4.10", "geoip-lite": "^1.4.10",
"heic-decode": "^2.1.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.2",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",

View File

@ -26,14 +26,14 @@
.quality-val { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-top: 3px; text-align: center; } .quality-val { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-top: 3px; text-align: center; }
/* ── Summary bar ── */ /* ── 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-bar.hidden { display: none; }
.summary-stat { font-size: 12px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); } .summary-stat { font-size: 12px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); }
.summary-stat strong { color: var(--text-primary); } .summary-stat strong { color: var(--text-primary); }
.summary-savings { font-size: 18px; font-weight: 800; font-family: 'JetBrains Mono', monospace; } .summary-savings { font-size: 18px; font-weight: 800; font-family: 'JetBrains Mono', monospace; }
/* ── File list ── */ /* ── 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-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; } .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 @@
Перетащите файлы сюда или <span style="color:var(--accent);font-weight:600">выберите</span> Перетащите файлы сюда или <span style="color:var(--accent);font-weight:600">выберите</span>
</p> </p>
<p style="font-size:11px;color:var(--text-muted);margin-top:4px;font-family:'JetBrains Mono',monospace">Файлы загружаются мгновенно, по одному</p> <p style="font-size:11px;color:var(--text-muted);margin-top:4px;font-family:'JetBrains Mono',monospace">Файлы загружаются мгновенно, по одному</p>
<input type="file" multiple accept="image/*" id="fileInput" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;" tabindex="-1"> <input type="file" multiple accept="image/*,.heic,.heif" id="fileInput" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;" tabindex="-1">
</div> </div>
<!-- Settings row --> <!-- Settings row -->
@ -369,9 +369,12 @@ function createRow(id, file) {
img.src = e.target.result; img.src = e.target.result;
img.className = 'file-thumb'; img.className = 'file-thumb';
img.alt = ''; img.alt = '';
// HEIC браузеры (кроме Safari) не рисуют — оставляем заглушку вместо битой картинки
img.onload = () => {
const ph = document.getElementById('thumb-' + id); const ph = document.getElementById('thumb-' + id);
if (ph) ph.replaceWith(img); if (ph) ph.replaceWith(img);
}; };
};
reader.readAsDataURL(file); reader.readAsDataURL(file);
// Info // Info

View File

@ -12,7 +12,7 @@
<script src="/vendor/marked.min.js"></script> <script src="/vendor/marked.min.js"></script>
<style> <style>
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0} @keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
.tool-btn{transition:all .2s}.tool-btn.active{color:#0054e6;background:rgba(0,84,230,.08);border-color:rgba(0,84,230,.3)} .tool-btn{transition:all .2s;background:var(--surface-800)}.tool-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)}
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical} textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
</style> </style>
</head> </head>

View File

@ -11,7 +11,7 @@
<link href="/shared.css" rel="stylesheet"> <link href="/shared.css" rel="stylesheet">
<style> <style>
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0} @keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
.tool-btn{transition:all .2s}.tool-btn.active{color:#0054e6;background:rgba(0,84,230,.08);border-color:rgba(0,84,230,.3)} .tool-btn{transition:all .2s;background:var(--surface-800)}.tool-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)}
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical} textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
</style> </style>
</head> </head>

View File

@ -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, .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; } .article-content th { background: var(--surface-700); font-weight: 600; }
.mode-btn { transition: all .2s; } .mode-btn { transition: all .2s; background: var(--surface-800); }
.mode-btn.active { color: #0054e6; background: rgba(0,84,230,.08); border-color: rgba(0,84,230,.3); } .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); }
</style> </style>
</head> </head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased"> <body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">

View File

@ -18,7 +18,7 @@
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: border-color .2s, background .2s; transition: border-color .2s, background .2s;
background: var(--surface-700); background: var(--surface-800);
} }
.drop-zone:hover, .drop-zone:hover,
.drop-zone.drag-over { .drop-zone.drag-over {
@ -66,7 +66,7 @@
/* Progress */ /* Progress */
.progress-wrap { .progress-wrap {
background: var(--surface-700); background: var(--surface-800);
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 24px;
@ -134,7 +134,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
background: var(--surface-700); background: var(--surface-800);
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
border-radius: 10px; border-radius: 10px;
padding: 12px 14px; padding: 12px 14px;
@ -205,7 +205,7 @@
.page-thumb-check svg { width:12px; height:12px; } .page-thumb-check svg { width:12px; height:12px; }
/* Merge sortable list */ /* Merge sortable list */
.merge-sort-list { display:flex; flex-direction:column; gap:6px; } .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:active { cursor:grabbing; }
.merge-sort-item.dragging { opacity:0.4; } .merge-sort-item.dragging { opacity:0.4; }
.merge-sort-item.drag-over { border-color:var(--accent); background:var(--accent-bg); } .merge-sort-item.drag-over { border-color:var(--accent); background:var(--accent-bg); }
@ -272,7 +272,7 @@
.op-tabs { .op-tabs {
display: flex; display: flex;
gap: 4px; gap: 4px;
background: var(--surface-700); background: var(--surface-800);
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
border-radius: 10px; border-radius: 10px;
padding: 4px; padding: 4px;
@ -329,7 +329,7 @@
padding: 8px 18px; padding: 8px 18px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
background: var(--surface-700); background: var(--surface-800);
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 13px; font-size: 13px;
@ -357,7 +357,7 @@
padding: 7px 14px; padding: 7px 14px;
border-radius: 7px; border-radius: 7px;
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
background: transparent; background: var(--surface-800);
color: var(--text-muted); color: var(--text-muted);
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
font-size: 12px; font-size: 12px;
@ -411,7 +411,7 @@
padding: 12px 16px; padding: 12px 16px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
background: var(--surface-700); background: var(--surface-800);
cursor: pointer; cursor: pointer;
transition: all .2s; transition: all .2s;
} }
@ -446,7 +446,7 @@
aspect-ratio: 1; aspect-ratio: 1;
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
border-radius: 6px; border-radius: 6px;
background: var(--surface-700); background: var(--surface-800);
cursor: pointer; cursor: pointer;
transition: all .15s; transition: all .15s;
display: flex; display: flex;
@ -476,7 +476,7 @@
padding: 8px 14px; padding: 8px 14px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
background: var(--surface-700); background: var(--surface-800);
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 13px; font-size: 13px;
@ -489,7 +489,7 @@
/* Result card */ /* Result card */
.result-card { .result-card {
background: var(--surface-700); background: var(--surface-800);
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 24px;
@ -607,7 +607,7 @@
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: border-color .2s, background .2s; transition: border-color .2s, background .2s;
background: var(--surface-700); background: var(--surface-800);
} }
.drop-zone-sm:hover, .drop-zone-sm:hover,
.drop-zone-sm.drag-over { .drop-zone-sm.drag-over {
@ -626,8 +626,45 @@
font-family: 'JetBrains Mono', monospace; 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) { @media (max-width: 480px) {
.result-stats { grid-template-columns: 1fr; } .result-stats { grid-template-columns: 1fr; }
.ip-grid { grid-template-columns:repeat(2, 1fr); }
.ip-count { margin-left:0; width:100%; }
} }
</style> </style>
</head> </head>
@ -665,9 +702,131 @@
</svg> </svg>
</div> </div>
<div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div> <div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div>
<div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу</div> <div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу · картинки соберём в PDF</div>
</div>
<input type="file" id="fileInput" accept=".pdf,application/pdf,image/*,.heic,.heif" multiple style="display:none;" />
</div>
<button type="button" id="btnImgMode" class="ip-cta">
<span class="ip-cta-icon">
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.6"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg>
</span>
<span>
<span class="ip-cta-title" style="display:block;">Собрать PDF из картинок</span>
<span class="ip-cta-sub" style="display:block;">JPG, PNG, WebP, HEIC и др. · порядок, поворот, повтор страниц, формат A4</span>
</span>
<span class="ip-cta-arrow">→</span>
</button>
</div>
<!-- Картинки → PDF -->
<div id="stepImages" style="display:none;margin-bottom:24px;">
<div class="ip-head">
<div class="section-title" style="margin:0;">Картинки → PDF</div>
<button type="button" id="ipBack" class="grid-action-btn ip-ghost">← К PDF-инструментам</button>
</div>
<div id="ipDrop" class="drop-zone-sm" tabindex="0" role="button" aria-label="Добавить изображения">
<div class="drop-icon" style="margin-bottom:8px;">
<svg width="32" height="32" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
</div>
<div class="drop-label" style="font-size:13px;">Перетащите изображения или нажмите — добавятся в конец</div>
<div class="drop-hint">JPG, PNG, WebP, HEIC, AVIF, TIFF, GIF, BMP · до 50 файлов по 50 МБ</div>
<input type="file" id="ipInput" accept="image/*,.heic,.heif" multiple style="display:none;" />
</div>
<div id="ipWork" style="display:none;">
<div class="ip-toolbar">
<select id="ipSort" class="ip-select" aria-label="Сортировка">
<option value="">Сортировать…</option>
<option value="name-asc">Имя: А → Я</option>
<option value="name-desc">Имя: Я → А</option>
<option value="date-asc">Дата файла: старые сначала</option>
<option value="date-desc">Дата файла: новые сначала</option>
<option value="size-asc">Размер: меньше сначала</option>
<option value="size-desc">Размер: больше сначала</option>
</select>
<button type="button" class="grid-action-btn ip-ghost" data-bulk="reverse">⇅ Обратный порядок</button>
<button type="button" class="grid-action-btn ip-ghost" data-bulk="rotl" title="Повернуть все влево">↺ Все</button>
<button type="button" class="grid-action-btn ip-ghost" data-bulk="rotr" title="Повернуть все вправо">↻ Все</button>
<button type="button" class="grid-action-btn danger" data-bulk="clear">Очистить</button>
<span id="ipCount" class="ip-count"></span>
</div>
<div id="ipGrid" class="ip-grid"></div>
<div class="page-grid-hint">Перетаскивайте карточки мышью, чтобы изменить порядок · ⧉ — повторить страницу</div>
<div class="wa-card ip-settings">
<div class="option-group">
<span class="option-label">Размер страницы</span>
<div class="btn-group" id="ipPageSize">
<button type="button" class="btn-option selected" data-value="fit">По картинке</button>
<button type="button" class="btn-option" data-value="a4">A4</button>
<button type="button" class="btn-option" data-value="a3">A3</button>
<button type="button" class="btn-option" data-value="a5">A5</button>
<button type="button" class="btn-option" data-value="letter">Letter</button>
</div>
</div>
<div class="option-group">
<span class="option-label">Ориентация</span>
<div class="btn-group" id="ipOrient">
<button type="button" class="btn-option selected" data-value="auto">Авто</button>
<button type="button" class="btn-option" data-value="portrait">Книжная</button>
<button type="button" class="btn-option" data-value="landscape">Альбомная</button>
</div>
</div>
<div class="option-group">
<span class="option-label">Поля</span>
<div class="btn-group" id="ipMargin">
<button type="button" class="btn-option selected" data-value="0">Без полей</button>
<button type="button" class="btn-option" data-value="5">5 мм</button>
<button type="button" class="btn-option" data-value="10">10 мм</button>
<button type="button" class="btn-option" data-value="20">20 мм</button>
</div>
</div>
<div class="option-group">
<span class="option-label">Качество картинок</span>
<div class="btn-group" id="ipQuality">
<button type="button" class="btn-option selected" data-value="original">Оригинал</button>
<button type="button" class="btn-option" data-value="high">Высокое</button>
<button type="button" class="btn-option" data-value="medium">Среднее</button>
<button type="button" class="btn-option" data-value="low">Лёгкий файл</button>
</div>
<div class="hint-note" id="ipQualityHint"></div>
</div>
<div class="option-group">
<span class="option-label">Имя файла</span>
<input type="text" id="ipName" class="wa-input" placeholder="images" maxlength="120" style="width:100%;box-sizing:border-box;" />
</div>
</div>
<div style="margin-top:16px;">
<button type="button" id="ipBuild" class="wa-btn" style="width:100%;padding:14px 24px;font-size:15px;">Собрать PDF</button>
<div id="ipProgress" class="progress-wrap" style="display:none;margin-top:0;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="progress-status" id="ipStatus">Загрузка…</span>
<span class="progress-percent" id="ipPct">0%</span>
</div>
<div class="progress-bar-track"><div class="progress-bar-fill" id="ipFill"></div></div>
</div>
</div>
<div id="ipResult" class="result-card" style="display:none;margin-top:16px;">
<div class="result-stats">
<div class="result-stat">
<div class="result-stat-val accent" id="ipResSize">—</div>
<div class="result-stat-label">размер файла</div>
</div>
<div class="result-stat">
<div class="result-stat-val" id="ipResPages">—</div>
<div class="result-stat-label">страниц</div>
</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<a id="ipDownload" href="#" class="wa-btn" style="flex:1;text-align:center;text-decoration:none;padding:12px 24px;" download>Скачать PDF</a>
<button type="button" id="ipAgain" class="wa-btn" style="background:var(--surface-600);color:var(--text-secondary);flex:0 0 auto;padding:12px 18px;">Изменить</button>
</div>
</div> </div>
<input type="file" id="fileInput" accept=".pdf,application/pdf" multiple style="display:none;" />
</div> </div>
</div> </div>
@ -790,7 +949,14 @@
</div> </div>
</div> </div>
<!-- ── Panel: Водяной знак ── -->
<div id="panel-watermark" class="op-panel" style="margin-bottom:24px;">
<div class="wa-card" style="margin-bottom:12px;">
<div class="option-group">
<span class="option-label">Файл</span>
<div id="watermarkFileSelector" class="file-list"></div>
</div>
</div>
<div class="wa-card"> <div class="wa-card">
<div class="option-group"> <div class="option-group">
<span class="option-label">Текст водяного знака <span style="color:var(--text-muted);font-weight:400;">(необязательно)</span></span> <span class="option-label">Текст водяного знака <span style="color:var(--text-muted);font-weight:400;">(необязательно)</span></span>
@ -932,20 +1098,8 @@
<!-- Images → PDF --> <!-- Images → PDF -->
<div id="subpanel-fromimg" class="sub-panel wa-card"> <div id="subpanel-fromimg" class="sub-panel wa-card">
<div class="option-group"> <div class="hint-note" style="margin-bottom:12px;">Сборка PDF из картинок — в отдельном конструкторе: порядок, поворот, повтор страниц, формат листа.</div>
<span class="option-label">Загрузить изображения</span> <button type="button" id="btnImgModeConvert" class="wa-btn">Открыть конструктор «Картинки → PDF»</button>
<div id="imgDropZone" class="drop-zone-sm" tabindex="0" role="button">
<div class="drop-icon" style="margin-bottom:8px;">
<svg width="32" height="32" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3 9.75h.375a2.25 2.25 0 012.25 2.25v.375"/>
</svg>
</div>
<div class="drop-label" style="font-size:13px;">Перетащите изображения или нажмите</div>
<div class="drop-hint">PNG, JPG, JPEG, WebP</div>
<input type="file" id="imgFileInput" accept="image/png,image/jpeg,image/webp" multiple style="display:none;" />
</div>
<div id="imgFileList" class="file-list" style="margin-top:8px;"></div>
</div>
</div> </div>
</div> </div>
@ -1061,8 +1215,6 @@
convertSub: 'toimg', convertSub: 'toimg',
imgFormat: 'png', imgFormat: 'png',
imgDpi: '150', imgDpi: '150',
// images for fromImages
imgFiles: [],
// compress // compress
compressQuality: 'screen', compressQuality: 'screen',
// pagenums // pagenums
@ -1115,6 +1267,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>
</svg>`; </svg>`;
} }
window.pdfIcon = pdfIcon; // нужен inline-onerror превью файлов
// ─── Render file list ───────────────────────────────────────────────────── // ─── Render file list ─────────────────────────────────────────────────────
function renderFileList() { function renderFileList() {
@ -1230,53 +1383,16 @@
if (fileInputMore.files.length) handleFiles(Array.from(fileInputMore.files)); 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 = `
<div class="file-card-order">${idx + 1}</div>
<div class="file-card-icon" style="color:var(--text-muted);">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909"/>
</svg>
</div>
<div class="file-card-info">
<div class="file-card-name">${escHtml(f.name)}</div>
<div class="file-card-meta">${formatSize(f.size)}</div>
</div>`;
imgFileList.appendChild(card);
});
}
// ─── Upload files ───────────────────────────────────────────────────────── // ─── Upload files ─────────────────────────────────────────────────────────
async function handleFiles(files) { async function handleFiles(files) {
clearError(); clearError();
const pdfFiles = files.filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')); const pdfFiles = files.filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
if (pdfFiles.length === 0) { if (pdfFiles.length === 0) {
showError('Выберите PDF-файлы.'); // Одни картинки — сразу в конструктор «Картинки → PDF»
const images = files.filter(isImageFile);
if (images.length) { openImgStudio(images); return; }
showError('Выберите PDF-файлы или изображения.');
return; return;
} }
@ -1366,7 +1482,7 @@
</svg> </svg>
</div> </div>
<div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div> <div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div>
<div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу</div>`; <div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу · картинки соберём в PDF</div>`;
} }
// ─── Operation tabs ─────────────────────────────────────────────────────── // ─── Operation tabs ───────────────────────────────────────────────────────
@ -1506,8 +1622,8 @@
showError('Введите пароль для защиты файла.'); showError('Введите пароль для защиты файла.');
return; return;
} }
if (op === 'convert' && state.convertSub === 'fromimg' && state.imgFiles.length === 0) { if (op === 'convert' && state.convertSub === 'fromimg') {
showError('Добавьте изображения для конвертации в PDF.'); openImgStudio([]);
return; return;
} }
@ -1573,16 +1689,8 @@
body = JSON.stringify({ fileId, quality: state.compressQuality }); body = JSON.stringify({ fileId, quality: state.compressQuality });
break; break;
case 'convert': case 'convert':
if (state.convertSub === 'toimg') {
url = '/pdf/toImages'; url = '/pdf/toImages';
body = JSON.stringify({ fileId, format: state.imgFormat, dpi: parseInt(state.imgDpi, 10) }); 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;
}
break; break;
case 'security': case 'security':
if (state.securitySub === 'protect') { if (state.securitySub === 'protect') {
@ -1693,7 +1801,6 @@
function resetToUpload() { function resetToUpload() {
state.files = []; state.files = [];
state.selectedFileId = null; state.selectedFileId = null;
state.imgFiles = [];
stepConfig.style.display = 'none'; stepConfig.style.display = 'none';
stepResult.style.display = 'none'; stepResult.style.display = 'none';
@ -1710,6 +1817,374 @@
// ─── Security sub-op default ────────────────────────────────────────────── // ─── Security sub-op default ──────────────────────────────────────────────
state.securitySub = 'protect'; 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 = `
<div class="ip-thumb" data-fid="${f.id}" data-rot="${it.rot}">${ipThumbHtml(f, it.rot)}</div>
<span class="ip-num">${idx + 1}</span>
${it.rot ? `<span class="ip-rot">${it.rot}°</span>` : ''}
<div class="ip-name" title="${escHtml(f.file.name)}">${escHtml(f.file.name)}</div>
<div class="ip-actions">
<button type="button" data-a="left" title="Переместить раньше" ${idx === 0 ? 'disabled' : ''}>←</button>
<button type="button" data-a="rotl" title="Повернуть влево">↺</button>
<button type="button" data-a="rotr" title="Повернуть вправо">↻</button>
<button type="button" data-a="dup" title="Повторить страницу">⧉</button>
<button type="button" data-a="right" title="Переместить позже" ${idx === ip.items.length - 1 ? 'disabled' : ''}>→</button>
<button type="button" data-a="del" title="Удалить" class="ip-del">✕</button>
</div>`;
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 '<div class="ip-noprev"><div class="spinner" style="width:22px;height:22px;border-width:2px;"></div>превью…</div>';
}
if (f.thumb === 'none' || !f.url) {
const ext = (f.file.name.split('.').pop() || 'IMG').toUpperCase().slice(0, 5);
return `<div class="ip-noprev" style="transform:rotate(${rot}deg)">
<svg width="28" height="28" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg>
${escHtml(ext)}<span style="font-weight:500;">без превью</span></div>`;
}
return `<img src="${f.url}" alt="" style="transform:rotate(${rot}deg)">`;
}
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 ───────────────────────────────────────────────── // ─── Page Grid Thumbnails ─────────────────────────────────────────────────
function renderPageGrid(gridId, hintId, fileId, pages, mode) { function renderPageGrid(gridId, hintId, fileId, pages, mode) {
const grid = document.getElementById(gridId); const grid = document.getElementById(gridId);

View File

@ -12,8 +12,9 @@
<style> <style>
select,select option{background:var(--select-bg);color:var(--select-color)} select,select option{background:var(--select-bg);color:var(--select-color)}
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0} @keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
.tool-btn{transition:all .2s}.tool-btn.active{color:#0054e6;background:rgba(0,84,230,.08);border-color:rgba(0,84,230,.3)} .tool-btn{transition:all .2s;background:var(--surface-800)}.tool-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)}
.sub-btn.active{color:#0054e6;background:rgba(0,84,230,.12);border-color:rgba(0,84,230,.3)} .sub-btn{background:var(--surface-800)}
.sub-btn.active{color:#0054e6;background:linear-gradient(rgba(0,84,230,.12),rgba(0,84,230,.12)),var(--surface-800);border-color:rgba(0,84,230,.3)}
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical} textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
</style> </style>
</head> </head>

View File

@ -18,7 +18,7 @@
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: border-color .2s, background .2s; transition: border-color .2s, background .2s;
background: var(--surface-700); background: var(--surface-800);
} }
.drop-zone:hover, .drop-zone:hover,
.drop-zone.drag-over { .drop-zone.drag-over {
@ -49,7 +49,7 @@
/* Video info card */ /* Video info card */
.info-card { .info-card {
background: var(--surface-700); background: var(--surface-800);
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
@ -96,7 +96,7 @@
.mode-tabs { .mode-tabs {
display: flex; display: flex;
gap: 4px; gap: 4px;
background: var(--surface-700); background: var(--surface-800);
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
border-radius: 10px; border-radius: 10px;
padding: 4px; padding: 4px;
@ -157,7 +157,7 @@
padding: 8px 18px; padding: 8px 18px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
background: var(--surface-700); background: var(--surface-800);
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 13px; font-size: 13px;
@ -187,7 +187,7 @@
padding: 12px 16px; padding: 12px 16px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
background: var(--surface-700); background: var(--surface-800);
cursor: pointer; cursor: pointer;
transition: all .2s; transition: all .2s;
} }
@ -250,7 +250,7 @@
/* Progress */ /* Progress */
.progress-wrap { .progress-wrap {
background: var(--surface-700); background: var(--surface-800);
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 24px;
@ -283,7 +283,7 @@
/* Result card */ /* Result card */
.result-card { .result-card {
background: var(--surface-700); background: var(--surface-800);
border: 1px solid var(--surface-600); border: 1px solid var(--surface-600);
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 24px;

View File

@ -10,6 +10,7 @@ const crypto = require('crypto');
const log = require('../lib/logger'); const log = require('../lib/logger');
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage'); const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
const owner = require('../lib/owner'); const owner = require('../lib/owner');
const { openImage } = require('../lib/image');
const router = express.Router(); const router = express.Router();
@ -96,9 +97,34 @@ function transliterate(str) {
.replace(/[^\w.\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, ''); .replace(/[^\w.\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
} }
function getOutputExtension(format, originalName) { // Формат источника по содержимому (metadata sharp), а не по MIME от браузера
const extMap = { webp: '.webp', jpeg: '.jpg', png: '.png', avif: '.avif', tiff: '.tiff', gif: '.gif' }; function sourceFormatOf(meta) {
return extMap[format] || path.extname(originalName); 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) { function changeExtension(filename, newExt) {
@ -152,7 +178,7 @@ router.post('/', limiter, uploadArray, async (req, res) => {
for (const file of req.files) { for (const file of req.files) {
const inputPath = file.path; const inputPath = file.path;
const originalSize = file.size; const originalSize = file.size;
const image = sharp(inputPath); const image = await openImage(file);
const metadata = await image.metadata(); const metadata = await image.metadata();
if (resize > 0 && metadata.width && metadata.height && Math.max(metadata.width, metadata.height) > resize) { 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); log.logToFile(msg);
} }
let buffer; const { buffer, ext } = await encodeImage(image, format, metadata, quality);
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 readableName = fixMulterFilename(file.originalname); const readableName = fixMulterFilename(file.originalname);
const newExt = getOutputExtension(format, readableName); const rawName = ext ? changeExtension(readableName, ext) : readableName;
const rawName = format === 'original' ? readableName : changeExtension(readableName, newExt);
const outputName = transliterate(rawName); const outputName = transliterate(rawName);
archive.append(buffer, { name: outputName }); archive.append(buffer, { name: outputName });
@ -237,7 +246,7 @@ router.post('/single', limiter, uploadSingle, async (req, res) => {
const originalSize = req.file.size; const originalSize = req.file.size;
try { try {
const image = sharp(inputPath); const image = await openImage(req.file);
const metadata = await image.metadata(); const metadata = await image.metadata();
if (resize > 0 && metadata.width && metadata.height && Math.max(metadata.width, metadata.height) > resize) { 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); log.info(msg); log.logToFile(msg);
} }
let buffer; const { buffer, mimetype: outputMimetype, ext } = await encodeImage(image, format, metadata, quality);
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 readableName = fixMulterFilename(req.file.originalname); const readableName = fixMulterFilename(req.file.originalname);
const newExt = getOutputExtension(format, readableName); const rawName = ext ? changeExtension(readableName, ext) : readableName;
const rawName = format === 'original' ? readableName : changeExtension(readableName, newExt);
const outputFilename = 'single_' + Date.now() + '_' + crypto.randomBytes(6).toString('hex') + '_' + transliterate(rawName); const outputFilename = 'single_' + Date.now() + '_' + crypto.randomBytes(6).toString('hex') + '_' + transliterate(rawName);
const outputPath = require('path').join(RESULTS_DIR, outputFilename); const outputPath = require('path').join(RESULTS_DIR, outputFilename);

View File

@ -9,6 +9,7 @@ const archiver = require('archiver');
const log = require('../lib/logger'); const log = require('../lib/logger');
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage'); const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
const owner = require('../lib/owner'); const owner = require('../lib/owner');
const { openImage } = require('../lib/image');
const router = express.Router(); const router = express.Router();
@ -18,7 +19,9 @@ const MAX_IMAGES = 50;
const pdfFileFilter = (req, file, cb) => { const pdfFileFilter = (req, file, cb) => {
if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) cb(null, true); 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 и изображения')); else cb(new Error('Только PDF и изображения'));
}; };
@ -34,6 +37,27 @@ function uploadFiles(field, maxCount) {
const limiter = createLimiter({ windowMs: 60000, max: 30 }); 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) // Rate-limit ALL pdf routes (several spawn Ghostscript / do heavy pdf-lib work)
router.use(limiter); router.use(limiter);
@ -98,11 +122,11 @@ function getFile(id) {
return f; 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 name = `result_${Date.now()}_${crypto.randomBytes(6).toString('hex')}${ext}`;
const outPath = path.join(RESULTS_DIR, name); const outPath = path.join(RESULTS_DIR, name);
fs.writeFileSync(outPath, buffer); 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); setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
return `/pdf/download/${name}`; return `/pdf/download/${name}`;
} }
@ -604,31 +628,112 @@ router.post('/toImages', express.json(), async (req, res) => {
}); });
// Images to PDF // Images to PDF
const MM = 72 / 25.4;
const PAGE_SIZES = { a4: [595.28, 841.89], a3: [841.89, 1190.55], a5: [419.53, 595.28], letter: [612, 792] };
const IMG_QUALITY = {
original: { maxSide: 0, jpeg: 92 },
high: { maxSide: 3000, jpeg: 88 },
medium: { maxSide: 2000, jpeg: 80 },
low: { maxSide: 1400, jpeg: 68 },
};
const MAX_PDF_PAGES = 500;
// Готовит картинку к вставке в PDF: EXIF-ориентация, поворот, ужатие. Нетронутые
// JPEG/PNG в режиме «оригинал» вставляются байт-в-байт — без потери качества.
async function prepareImageForPdf(file, rotate, quality) {
const q = IMG_QUALITY[quality] || IMG_QUALITY.original;
const image = await openImage(file);
const meta = await image.metadata();
const untouched = !rotate && !q.maxSide && (!meta.orientation || meta.orientation === 1);
if (untouched && (meta.format === 'jpeg' || meta.format === 'png')) {
return { bytes: await fs.promises.readFile(file.path), kind: meta.format === 'png' ? 'png' : 'jpg' };
}
image.autoOrient();
if (rotate) image.rotate(rotate);
if (q.maxSide) image.resize({ width: q.maxSide, height: q.maxSide, fit: 'inside', withoutEnlargement: true });
if (meta.hasAlpha) return { bytes: await image.png({ compressionLevel: 9 }).toBuffer(), kind: 'png' };
return { bytes: await image.jpeg({ quality: q.jpeg, mozjpeg: true }).toBuffer(), kind: 'jpg' };
}
function safePdfName(name) {
const base = String(name || '').replace(/\.pdf$/i, '').replace(/[\\/:*?"<>|\x00-\x1f]+/g, ' ').trim().slice(0, 120);
return (base || 'images') + '.pdf';
}
// multipart: images[] — уникальные файлы; options (JSON):
// pages: [{ f: индекс файла, r: 0|90|180|270 }] — порядок страниц, повторы допустимы
// pageSize: fit|a4|a3|a5|letter, orientation: auto|portrait|landscape,
// margin: мм (0–50), quality: original|high|medium|low, filename
router.post('/fromImages', uploadFiles('images', MAX_IMAGES), async (req, res) => { 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 { try {
const { PDFDocument } = await import('pdf-lib'); const { PDFDocument } = await import('pdf-lib');
const doc = await PDFDocument.create(); 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) { // Повторы одной картинки с тем же поворотом встраиваются в PDF один раз
const imgBytes = fs.readFileSync(file.path); const embedded = new Map();
let img; for (const p of pages) {
if (file.mimetype === 'image/png') img = await doc.embedPng(imgBytes); const key = p.f + ':' + p.r;
else img = await doc.embedJpg(imgBytes); let img = embedded.get(key);
if (!img) {
const page = doc.addPage([img.width, img.height]); const { bytes, kind } = await prepareImageForPdf(files[p.f], p.r, quality);
page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height }); img = kind === 'png' ? await doc.embedPng(bytes) : await doc.embedJpg(bytes);
embedded.set(key, img);
try { fs.unlinkSync(file.path); } catch {}
} }
const result = await doc.save(); let pw, ph, dw, dh;
const url = saveResult(Buffer.from(result), '.pdf', req); if (pageSize === 'fit') {
res.json({ downloadUrl: url, size: result.length, pages: req.files.length }); // 1 px = 1 pt, как и раньше; огромные фото ограничиваем 5080 pt (≈180 см — предел удобного просмотра)
const k = Math.min(1, 5080 / Math.max(img.width, img.height));
dw = img.width * k; dh = img.height * k;
pw = dw + margin * 2; ph = dh + margin * 2;
} else {
const [a, b] = PAGE_SIZES[pageSize];
const landscape = orientation === 'landscape' || (orientation === 'auto' && img.width > img.height);
pw = landscape ? b : a; ph = landscape ? a : b;
const k = Math.min((pw - margin * 2) / img.width, (ph - margin * 2) / img.height);
dw = img.width * k; dh = img.height * k;
}
const page = doc.addPage([pw, ph]);
page.drawImage(img, { x: (pw - dw) / 2, y: (ph - dh) / 2, width: dw, height: dh });
}
const result = Buffer.from(await doc.save());
const url = saveResult(result, '.pdf', req, filename);
const msg = `[pdf] fromImages: ${files.length} files -> ${pages.length} pages size=${pageSize} q=${quality} (${(result.length / 1024).toFixed(0)}KB)`;
log.info(msg);
res.json({ downloadUrl: url, size: result.length, pages: pages.length, filename });
} catch (err) { } catch (err) {
req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} }); log.error('PDF fromImages error', { error: err.message });
res.status(500).json({ 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); const filePath = path.join(RESULTS_DIR, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' }); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
res.download(filePath); res.download(filePath, owner.displayName(filename) || filename);
}); });
// Multer error handler // Multer error handler
router.use((err, req, res, next) => { router.use((err, req, res, next) => {
if (err instanceof multer.MulterError) { 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_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 }); return res.status(400).json({ error: err.message });
} }
if (err) return res.status(400).json({ error: err.message }); if (err) return res.status(400).json({ error: err.message });