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

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

UI: белый фон (--surface-800) у зон загрузки, неактивных табов, опций и карточек
(compress, pdf, video, converter, formatter, sanitizer, parser)
2026-09-22 20:37:48 +03:00

2543 lines
121 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WA Dev Tools — PDF инструменты</title>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'pdf';</script>
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<style>
/* Drop zone */
.drop-zone {
border: 2px dashed var(--surface-600);
border-radius: 16px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s;
background: var(--surface-800);
}
.drop-zone:hover,
.drop-zone.drag-over {
border-color: var(--accent);
background: var(--accent-bg);
}
.drop-zone.drag-over .drop-icon {
color: var(--accent);
}
.drop-icon {
color: var(--text-muted);
transition: color .2s;
margin-bottom: 16px;
display: flex;
justify-content: center;
}
.drop-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.drop-hint {
font-size: 12px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
/* Upload overlay */
.uploading-overlay {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--surface-600);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Progress */
.progress-wrap {
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 12px;
padding: 24px;
}
.progress-bar-track {
background: var(--surface-600);
border-radius: 99px;
height: 8px;
overflow: hidden;
margin: 14px 0 10px;
}
.progress-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width .3s ease;
width: 0%;
}
.progress-status {
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
color: var(--text-secondary);
}
.progress-percent {
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
color: var(--accent);
font-weight: 700;
}
/* Section labels */
.section-title {
font-size: 13px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
font-family: 'JetBrains Mono', monospace;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.section-num {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent-bg);
color: var(--accent);
font-size: 11px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* File list */
.file-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 10px;
padding: 12px 14px;
transition: border-color .15s;
}
.file-card.selected-file {
border-color: var(--accent);
background: var(--accent-bg);
}
.file-card-icon {
flex-shrink: 0;
color: var(--accent);
width: 48px;
height: 48px;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-600);
}
.file-card-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-card-info {
flex: 1;
min-width: 0;
}
.file-card-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-card-meta {
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
margin-top: 2px;
}
.file-card-remove {
flex-shrink: 0;
width: 26px;
height: 26px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: color .15s, background .15s;
}
/* Page thumbnails grid */
.page-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(80px,1fr)); gap:8px; margin-top:10px; max-height:400px; overflow-y:auto; }
.page-thumb { position:relative; border:2px solid var(--surface-600); border-radius:8px; overflow:hidden; cursor:pointer; transition:all .15s; aspect-ratio:3/4; background:var(--surface-700); }
.page-thumb:hover { border-color:var(--text-muted); }
.page-thumb.selected { border-color:var(--accent); box-shadow:0 0 0 2px rgba(0,84,230,.3); }
.page-thumb img { width:100%; height:100%; object-fit:cover; }
.page-thumb-num { position:absolute; bottom:0; left:0; right:0; background:rgba(0,0,0,.6); color:#fff; font-size:10px; text-align:center; padding:2px 0; font-family:'JetBrains Mono',monospace; }
.page-thumb-check { position:absolute; top:4px; right:4px; width:18px; height:18px; border-radius:50%; border:2px solid rgba(255,255,255,.5); background:rgba(0,0,0,.3); display:none; align-items:center; justify-content:center; }
.page-thumb.selected .page-thumb-check { display:flex; background:var(--accent); border-color:var(--accent); }
.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-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); }
.merge-sort-item .merge-num { width:24px; height:24px; border-radius:6px; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:700; font-family:'JetBrains Mono',monospace; flex-shrink:0; }
.merge-sort-item .merge-name { font-size:13px; font-weight:500; color:var(--text-primary); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.merge-sort-item .merge-meta { font-size:11px; color:var(--text-muted); font-family:'JetBrains Mono',monospace; flex-shrink:0; }
.merge-sort-item .merge-grip { color:var(--text-muted); flex-shrink:0; }
.page-thumb.rotate-90 img { transform: rotate(90deg); }
.page-thumb.rotate-180 img { transform: rotate(180deg); }
.page-thumb.rotate-270 img { transform: rotate(270deg); }
.page-thumb.dragging { opacity: 0.4; border-color: var(--accent); }
.page-thumb.drag-over { border-color: var(--accent); transform: scale(1.05); }
.page-thumb.marked-delete { border-color: #ef4444; opacity: 0.6; }
.page-thumb.marked-delete .page-thumb-check { display: flex; background: #ef4444; border-color: #ef4444; }
.page-thumb.marked-delete .page-thumb-num { background: rgba(239,68,68,.8); }
.grid-action-bar { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.grid-action-btn { padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; border: none; cursor: pointer; font-family: 'Manrope', sans-serif; transition: all .15s; }
.grid-action-btn.danger { background: rgba(239,68,68,.1); color: #ef4444; border: 1px solid rgba(239,68,68,.3); }
.grid-action-btn.danger:hover { background: rgba(239,68,68,.2); }
.grid-action-btn.primary { background: var(--accent); color: #fff; }
.grid-action-btn.primary:hover { background: var(--accent-dim); }
.grid-action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-grid-hint { font-size:11px; color:var(--text-muted); margin-top:6px; font-family:'JetBrains Mono',monospace; }
.file-card-remove:hover {
color: #ff5252;
background: rgba(255,82,82,.1);
}
.file-card-select {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid var(--surface-600);
cursor: pointer;
transition: border-color .15s, background .15s;
display: flex;
align-items: center;
justify-content: center;
}
.file-card.selected-file .file-card-select {
border-color: var(--accent);
background: var(--accent);
}
.file-card-select-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #fff;
display: none;
}
.file-card.selected-file .file-card-select-dot {
display: block;
}
/* Mode tabs — horizontal scrollable */
.op-tabs-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.op-tabs-wrap::-webkit-scrollbar { display: none; }
.op-tabs {
display: flex;
gap: 4px;
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 10px;
padding: 4px;
min-width: max-content;
}
.op-tab {
padding: 9px 14px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--text-muted);
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
white-space: nowrap;
}
.op-tab:hover {
color: var(--text-secondary);
background: var(--surface-600);
}
.op-tab.active {
background: var(--accent);
color: #fff;
}
/* Op panels */
.op-panel { display: none; }
.op-panel.active { display: block; }
/* Option group */
.option-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-family: 'JetBrains Mono', monospace;
}
/* Button group (format selector) */
.btn-group {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.btn-option {
padding: 8px 18px;
border-radius: 8px;
border: 1px solid var(--surface-600);
background: var(--surface-800);
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.btn-option:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.btn-option.selected {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* Sub-tabs (inside a panel) */
.sub-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
}
.sub-tab {
padding: 7px 14px;
border-radius: 7px;
border: 1px solid var(--surface-600);
background: var(--surface-800);
color: var(--text-muted);
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.sub-tab:hover {
color: var(--text-secondary);
background: var(--surface-600);
}
.sub-tab.active {
background: var(--accent-bg);
border-color: var(--accent);
color: var(--accent);
}
.sub-panel { display: none; }
.sub-panel.active { display: block; }
/* Slider */
.slider-wrap {
display: flex;
flex-direction: column;
gap: 6px;
}
.slider-row {
display: flex;
align-items: center;
gap: 12px;
}
input[type="range"] {
flex: 1;
height: 6px;
accent-color: var(--accent);
cursor: pointer;
}
.slider-val {
font-size: 13px;
font-weight: 700;
color: var(--accent);
font-family: 'JetBrains Mono', monospace;
min-width: 36px;
text-align: right;
}
/* Compress quality options */
.quality-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid var(--surface-600);
background: var(--surface-800);
cursor: pointer;
transition: all .2s;
}
.quality-option:hover {
border-color: var(--accent);
}
.quality-option.selected {
border-color: var(--accent);
background: var(--accent-bg);
}
.quality-option input[type="radio"] { display: none; }
.quality-label {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.quality-desc {
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
margin-top: 2px;
}
/* Position grid (3x3) */
.pos-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
max-width: 200px;
}
.pos-cell {
aspect-ratio: 1;
border: 1px solid var(--surface-600);
border-radius: 6px;
background: var(--surface-800);
cursor: pointer;
transition: all .15s;
display: flex;
align-items: center;
justify-content: center;
}
.pos-cell:hover {
border-color: var(--accent);
}
.pos-cell.selected {
border-color: var(--accent);
background: var(--accent-bg);
}
.pos-cell-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
transition: background .15s;
}
.pos-cell.selected .pos-cell-dot {
background: var(--accent);
}
/* Angle selector */
.angle-btn {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--surface-600);
background: var(--surface-800);
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.angle-btn:hover { border-color: var(--accent); color: var(--text-primary); }
.angle-btn.selected { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Result card */
.result-card {
background: var(--surface-800);
border: 1px solid var(--surface-600);
border-radius: 12px;
padding: 24px;
}
.result-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.result-stat { text-align: center; }
.result-stat-val {
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
}
.result-stat-val.accent { color: var(--accent); }
.result-stat-val.green { color: #22c55e; }
.result-stat-label {
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
margin-top: 4px;
}
/* Extracted text area */
.text-output {
width: 100%;
min-height: 200px;
background: var(--select-bg);
border: 1px solid var(--surface-600);
color: var(--select-color);
border-radius: 8px;
padding: 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
resize: vertical;
box-sizing: border-box;
line-height: 1.6;
}
/* Error */
.error-box {
background: rgba(255, 82, 82, 0.08);
border: 1px solid rgba(255, 82, 82, 0.25);
border-radius: 10px;
padding: 14px 16px;
font-size: 13px;
color: #ff5252;
font-family: 'JetBrains Mono', monospace;
}
/* Hint note */
.hint-note {
font-size: 12px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 8px;
padding: 10px 14px;
}
/* Color picker row */
.color-picker-row {
display: flex;
align-items: center;
gap: 10px;
}
input[type="color"] {
width: 36px;
height: 36px;
border: 1px solid var(--surface-600);
border-radius: 6px;
background: none;
cursor: pointer;
padding: 2px;
}
.color-hex {
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
color: var(--text-secondary);
}
/* Add more button */
.btn-add-more {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 10px;
border: 1px dashed var(--surface-600);
border-radius: 8px;
background: transparent;
color: var(--text-muted);
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.btn-add-more:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-bg);
}
/* Images drop zone (smaller) */
.drop-zone-sm {
border: 2px dashed var(--surface-600);
border-radius: 12px;
padding: 28px 16px;
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s;
background: var(--surface-800);
}
.drop-zone-sm:hover,
.drop-zone-sm.drag-over {
border-color: var(--accent);
background: var(--accent-bg);
}
/* Merge order drag indicator */
.file-card-order {
flex-shrink: 0;
width: 20px;
text-align: center;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
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%; }
}
</style>
</head>
<body class="font-sans antialiased">
<div class="main-content">
<div class="tool-container max-w-3xl mx-auto">
<!-- Page header -->
<div class="page-header" style="text-align:center;">
<div class="inline-flex items-center gap-2 mb-3 px-3 py-1 rounded-full border dark:border-surface-600 border-gray-300 dark:bg-surface-800/60 bg-white/60 text-xs font-mono dark:text-gray-400 text-gray-500 tracking-wide">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
Merge · Split · Rotate · Compress
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
PDF <span class="text-accent">инструменты</span>
</h1>
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">Объединение, разделение, сжатие, конвертация и защита</p>
</div>
<!-- Error display -->
<div id="errorBox" class="error-box" style="display:none; margin-bottom:20px;"></div>
<!-- Step 1: Upload -->
<div id="stepUpload" style="margin-bottom:24px;">
<div class="section-title">
Загрузка файлов
</div>
<div id="dropZone" class="drop-zone" tabindex="0" role="button" aria-label="Загрузить PDF">
<div id="dropZoneContent">
<div class="drop-icon">
<svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
</div>
<div class="drop-label">Перетащите 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>
</div>
<!-- Step 2+: shown after at least one file uploaded -->
<div id="stepConfig" style="display:none;">
<!-- Uploaded files list -->
<div style="margin-bottom:24px;">
<div class="section-title">
Загруженные файлы
</div>
<div class="file-list" id="fileList"></div>
<div style="margin-top:8px;">
<button id="btnAddMore" class="btn-add-more">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
Добавить ещё файлы
</button>
<input type="file" id="fileInputMore" accept=".pdf,application/pdf" multiple style="display:none;" />
</div>
</div>
<!-- Operation tabs -->
<div style="margin-bottom:20px;">
<div class="section-title">
Операция
</div>
<div class="op-tabs-wrap">
<div class="op-tabs" role="tablist">
<button class="op-tab active" data-op="merge" role="tab">Объединить</button>
<button class="op-tab" data-op="split" role="tab">Разделить</button>
<button class="op-tab" data-op="pages" role="tab">Страницы</button>
<button class="op-tab" data-op="watermark" role="tab">Водяной знак</button>
<button class="op-tab" data-op="pagenums" role="tab">Нумерация</button>
<button class="op-tab" data-op="compress" role="tab">Сжатие</button>
<button class="op-tab" data-op="convert" role="tab">Конвертация</button>
<button class="op-tab" data-op="security" role="tab">Безопасность</button>
</div>
</div>
</div>
<!-- ── Panel: Объединить ── -->
<div id="panel-merge" class="op-panel active" style="margin-bottom:24px;">
<div class="wa-card">
<div class="option-group">
<span class="option-label">Перетащите файлы для изменения порядка объединения</span>
<div id="mergeFileList" class="merge-sort-list"></div>
<div class="hint-note" id="mergeHint">Нужно минимум 2 файла</div>
</div>
</div>
</div>
<!-- ── Panel: Разделить ── -->
<div id="panel-split" class="op-panel" style="margin-bottom:24px;">
<div class="wa-card">
<div class="option-group">
<span class="option-label">Файл для разделения</span>
<div id="splitFileSelector" class="file-list" style="margin-bottom:12px;"></div>
<span class="option-label">Диапазон страниц</span>
<input type="text" id="splitRanges" class="wa-input" placeholder="Например: 1-3, 5, 7-9" style="width:100%;box-sizing:border-box;" />
<div class="hint-note">Используйте запятые для разделения диапазонов. Тире для диапазона страниц.</div>
</div>
</div>
</div>
<!-- ── Panel: Страницы ── -->
<div id="panel-pages" 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="pagesFileSelector" class="file-list"></div>
</div>
</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="rotate">Повернуть</button>
<button class="sub-tab" data-sub="delete">Удалить</button>
<button class="sub-tab" data-sub="reorder">Изменить порядок</button>
</div>
<!-- Rotate sub-panel -->
<div id="subpanel-rotate" class="sub-panel active wa-card">
<div class="option-group">
<span class="option-label">Угол поворота</span>
<div class="btn-group" id="angleGroup">
<button class="angle-btn selected" data-angle="90">90°</button>
<button class="angle-btn" data-angle="180">180°</button>
<button class="angle-btn" data-angle="270">270° (−90°)</button>
</div>
<span class="option-label" style="margin-top:8px;">Нажмите на страницы для поворота</span>
<div class="page-grid" id="rotatePageGrid"></div>
<div class="grid-action-bar">
<span class="page-grid-hint" id="rotateHint">Нажмите на страницу для поворота</span>
</div>
</div>
</div>
<!-- Delete sub-panel -->
<div id="subpanel-delete" class="sub-panel wa-card">
<div class="option-group">
<span class="option-label">Нажмите на страницы для удаления</span>
<div class="page-grid" id="deletePageGrid"></div>
<div class="grid-action-bar">
<button class="grid-action-btn danger" id="btnDeleteSelected" disabled onclick="executeDeletePages()">Удалить выбранные (<span id="deleteCount">0</span>)</button>
<button class="grid-action-btn" id="btnUndoDelete" style="display:none;background:var(--surface-600);color:var(--text-secondary);" onclick="undoDeletePages()">Отменить</button>
<span class="page-grid-hint" id="deleteHint"></span>
</div>
</div>
</div>
<!-- Reorder sub-panel -->
<div id="subpanel-reorder" class="sub-panel wa-card">
<div class="option-group">
<span class="option-label">Перетаскивайте страницы мышкой для нового порядка</span>
<div class="page-grid" id="reorderPageGrid"></div>
<div class="grid-action-bar">
<span class="page-grid-hint" id="reorderHint">Перетащите и отпустите — порядок сохранится автоматически</span>
</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="option-group">
<span class="option-label">Текст водяного знака <span style="color:var(--text-muted);font-weight:400;">(необязательно)</span></span>
<input type="text" id="wmText" class="wa-input" placeholder="КОНФИДЕНЦИАЛЬНО" style="width:100%;box-sizing:border-box;" />
<span class="option-label" style="margin-top:8px;">Размер шрифта</span>
<div class="slider-wrap">
<div class="slider-row">
<input type="range" id="wmFontSize" min="12" max="72" value="36" />
<span class="slider-val" id="wmFontSizeVal">36</span>
</div>
</div>
<span class="option-label" style="margin-top:8px;">Прозрачность</span>
<div class="slider-wrap">
<div class="slider-row">
<input type="range" id="wmOpacity" min="0.1" max="1.0" step="0.05" value="0.3" />
<span class="slider-val" id="wmOpacityVal">0.3</span>
</div>
</div>
<span class="option-label" style="margin-top:8px;">Цвет</span>
<div class="color-picker-row">
<input type="color" id="wmColor" value="#ff0000" />
<span class="color-hex" id="wmColorHex">#ff0000</span>
</div>
</div>
</div>
</div>
<!-- ── Panel: Нумерация страниц ── -->
<div id="panel-pagenums" 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="pagenumFileSelector" class="file-list"></div>
</div>
</div>
<div class="wa-card">
<div class="option-group">
<span class="option-label">Положение</span>
<div style="display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap;">
<div class="pos-grid" id="posGrid">
<div class="pos-cell" data-pos="top-left"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="top-center"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="top-right"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="mid-left"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="mid-center"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="mid-right"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell selected" data-pos="bot-center"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="bot-left"> <div class="pos-cell-dot"></div></div>
<div class="pos-cell" data-pos="bot-right"> <div class="pos-cell-dot"></div></div>
</div>
<div style="font-size:11px;color:var(--text-muted);font-family:'JetBrains Mono',monospace;line-height:1.8;">
Выбрано:<br>
<span id="posLabel" style="color:var(--accent);font-weight:700;">Снизу по центру</span>
</div>
</div>
<span class="option-label" style="margin-top:8px;">Начинать с номера</span>
<input type="number" id="pageNumStart" class="wa-input" min="1" value="1" style="width:120px;" />
</div>
</div>
</div>
<!-- ── Panel: Сжатие ── -->
<div id="panel-compress" 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="compressFileSelector" class="file-list"></div>
</div>
</div>
<div class="wa-card">
<div class="option-group">
<span class="option-label">Качество сжатия</span>
<label class="quality-option selected" data-quality="screen">
<input type="radio" name="compressQuality" value="screen" checked />
<div>
<div class="quality-label">Экран</div>
<div class="quality-desc">минимальный размер, для экранного просмотра</div>
</div>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" style="color:var(--accent);flex-shrink:0;display:none;" class="q-check">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
</label>
<label class="quality-option" data-quality="ebook">
<input type="radio" name="compressQuality" value="ebook" />
<div>
<div class="quality-label">Электронная книга</div>
<div class="quality-desc">баланс размера и качества</div>
</div>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" style="color:var(--accent);flex-shrink:0;display:none;" class="q-check">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
</label>
<label class="quality-option" data-quality="printer">
<input type="radio" name="compressQuality" value="printer" />
<div>
<div class="quality-label">Печать</div>
<div class="quality-desc">максимальное качество, для печати</div>
</div>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" style="color:var(--accent);flex-shrink:0;display:none;" class="q-check">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
</label>
</div>
</div>
</div>
<!-- ── Panel: Конвертация ── -->
<div id="panel-convert" 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="convertFileSelector" class="file-list"></div>
</div>
</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="toimg">PDF → Картинки</button>
<button class="sub-tab" data-sub="fromimg">Картинки → PDF</button>
</div>
<!-- PDF → Images -->
<div id="subpanel-toimg" class="sub-panel active wa-card">
<div class="option-group">
<span class="option-label">Формат</span>
<div class="btn-group" id="imgFormatGroup">
<button class="btn-option selected" data-value="png">PNG</button>
<button class="btn-option" data-value="jpg">JPG</button>
</div>
<span class="option-label" style="margin-top:8px;">Разрешение (DPI)</span>
<div class="btn-group" id="dpiGroup">
<button class="btn-option selected" data-value="150">150 dpi</button>
<button class="btn-option" data-value="300">300 dpi</button>
</div>
</div>
</div>
<!-- Images → PDF -->
<div id="subpanel-fromimg" class="sub-panel wa-card">
<div class="hint-note" style="margin-bottom:12px;">Сборка PDF из картинок — в отдельном конструкторе: порядок, поворот, повтор страниц, формат листа.</div>
<button type="button" id="btnImgModeConvert" class="wa-btn">Открыть конструктор «Картинки → PDF»</button>
</div>
</div>
<!-- ── Panel: Безопасность ── -->
<div id="panel-security" 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="securityFileSelector" class="file-list"></div>
</div>
</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="protect">Защитить</button>
<button class="sub-tab" data-sub="extract">Извлечь текст</button>
</div>
<!-- Protect -->
<div id="subpanel-protect" class="sub-panel active wa-card">
<div class="option-group">
<span class="option-label">Пароль</span>
<input type="password" id="protectPassword" class="wa-input" placeholder="Введите пароль" style="width:100%;box-sizing:border-box;" />
<div class="hint-note">Файл будет защищён от открытия без пароля.</div>
</div>
</div>
<!-- Extract text -->
<div id="subpanel-extract" class="sub-panel wa-card">
<div class="hint-note">Текст будет извлечён из PDF и показан ниже для копирования.</div>
</div>
</div>
<!-- Step 3: Action + Progress -->
<div id="step3Wrap" style="margin-bottom:24px;">
<div class="section-title">
Обработка
</div>
<div id="actionWrap">
<button id="btnProcess" class="wa-btn" style="width:100%;padding:14px 24px;font-size:15px;">
Выполнить
</button>
</div>
<div id="progressWrap" 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="progressStatus">Обработка...</span>
<span class="progress-percent" id="progressPercent">0%</span>
</div>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="progressFill"></div>
</div>
<div style="font-size:11px;color:var(--text-muted);font-family:'JetBrains Mono',monospace;">
Не закрывайте вкладку во время обработки
</div>
</div>
</div>
<!-- Step 4: Result -->
<div id="stepResult" style="display:none;margin-bottom:24px;">
<div class="section-title">
Результат
</div>
<div class="result-card">
<div id="resultStats" class="result-stats">
<div class="result-stat">
<div class="result-stat-val accent" id="resultSize">—</div>
<div class="result-stat-label">размер файла</div>
</div>
<div class="result-stat">
<div class="result-stat-val green" id="resultSavings" style="display:none;">—</div>
<div class="result-stat-label" id="resultSavingsLabel" style="display:none;">экономия</div>
</div>
</div>
<!-- Extracted text output -->
<div id="resultTextWrap" style="display:none;margin-bottom:16px;">
<div class="option-label" style="margin-bottom:8px;">Извлечённый текст</div>
<textarea id="resultText" class="text-output" readonly></textarea>
<div style="margin-top:8px;">
<button id="btnCopyText" class="wa-btn" style="background:var(--surface-600);color:var(--text-secondary);font-size:12px;padding:8px 16px;">
Копировать текст
</button>
</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<a id="downloadLink" href="#" class="wa-btn" style="flex:1;text-align:center;text-decoration:none;padding:12px 24px;display:none;">
Скачать файл
</a>
<button id="btnBack" class="wa-btn" style="background:var(--surface-600);color:var(--text-secondary);flex:0 0 auto;padding:12px 18px;">
Назад
</button>
</div>
</div>
</div>
</div><!-- /stepConfig -->
</div>
</div>
<script>
(function () {
'use strict';
// ─── State ────────────────────────────────────────────────────────────────
const state = {
files: [], // [{ id, name, size, pages }]
selectedFileId: null,
currentOp: 'merge',
// pages sub-ops
pagesSubOp: 'rotate',
rotateAngle: 90,
// convert sub-ops
convertSub: 'toimg',
imgFormat: 'png',
imgDpi: '150',
// compress
compressQuality: 'screen',
// pagenums
pageNumPosition: 'bot-center',
// watermark color
wmColor: '#ff0000',
};
// ─── DOM refs ─────────────────────────────────────────────────────────────
const dropZone = document.getElementById('dropZone');
const dropContent = document.getElementById('dropZoneContent');
const fileInput = document.getElementById('fileInput');
const fileInputMore = document.getElementById('fileInputMore');
const stepUpload = document.getElementById('stepUpload');
const stepConfig = document.getElementById('stepConfig');
const stepResult = document.getElementById('stepResult');
const errorBox = document.getElementById('errorBox');
const fileList = document.getElementById('fileList');
const btnAddMore = document.getElementById('btnAddMore');
const btnProcess = document.getElementById('btnProcess');
const actionWrap = document.getElementById('actionWrap');
const progressWrap = document.getElementById('progressWrap');
const progressFill = document.getElementById('progressFill');
const progressStatus = document.getElementById('progressStatus');
const progressPercent = document.getElementById('progressPercent');
const downloadLink = document.getElementById('downloadLink');
const btnBack = document.getElementById('btnBack');
// ─── Helpers ──────────────────────────────────────────────────────────────
function formatSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
function showError(msg) {
errorBox.textContent = msg;
errorBox.style.display = 'block';
errorBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function clearError() {
errorBox.style.display = 'none';
errorBox.textContent = '';
}
function pdfIcon(size = 18) {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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>`;
}
window.pdfIcon = pdfIcon; // нужен inline-onerror превью файлов
// ─── Render file list ─────────────────────────────────────────────────────
function renderFileList() {
fileList.innerHTML = '';
state.files.forEach((f, idx) => {
const card = document.createElement('div');
card.className = 'file-card';
card.dataset.id = f.id;
card.innerHTML = `
<div class="file-card-order">${idx + 1}</div>
<div class="file-card-icon"><img src="/pdf/preview/${f.id}" alt="" onerror="this.parentNode.innerHTML=pdfIcon(20)"></div>
<div class="file-card-info">
<div class="file-card-name">${escHtml(f.name)}</div>
<div class="file-card-meta">${formatSize(f.size)} · ${f.pages} стр.</div>
</div>
<button class="file-card-remove" data-id="${f.id}" title="Удалить">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>`;
fileList.appendChild(card);
});
// Remove buttons
fileList.querySelectorAll('.file-card-remove').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = btn.dataset.id;
state.files = state.files.filter(f => f.id !== id);
if (state.selectedFileId === id) state.selectedFileId = state.files[0]?.id || null;
renderFileList();
renderSingleFileSelectors();
if (state.files.length === 0) resetToUpload();
});
});
renderSingleFileSelectors();
renderMergeList();
}
// Render file selector for single-file operations
function renderSingleFileSelectors() {
const containers = [
'splitFileSelector',
'pagesFileSelector',
'watermarkFileSelector',
'pagenumFileSelector',
'compressFileSelector',
'convertFileSelector',
'securityFileSelector',
];
containers.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = '';
state.files.forEach(f => {
const card = document.createElement('div');
const isSelected = f.id === state.selectedFileId;
card.className = 'file-card' + (isSelected ? ' selected-file' : '');
card.dataset.id = f.id;
card.style.cursor = 'pointer';
card.innerHTML = `
<div class="file-card-select"><div class="file-card-select-dot"></div></div>
<div class="file-card-icon">${pdfIcon(16)}</div>
<div class="file-card-info">
<div class="file-card-name">${escHtml(f.name)}</div>
<div class="file-card-meta">${formatSize(f.size)} · ${f.pages} стр.</div>
</div>`;
card.addEventListener('click', () => {
state.selectedFileId = f.id;
renderSingleFileSelectors();
});
el.appendChild(card);
});
});
// Render page thumbnails for selected file
const _sel = state.files.find(f => f.id === state.selectedFileId);
if (_sel && typeof renderPageGrid === 'function') {
renderPageGrid('deletePageGrid', 'deleteHint', _sel.id, _sel.pages, 'delete');
renderPageGrid('rotatePageGrid', 'rotateHint', _sel.id, _sel.pages, 'rotate');
if (_sel.pages > 1) {
renderPageGrid('reorderPageGrid', 'reorderHint', _sel.id, _sel.pages, 'reorder');
} else {
var rg = document.getElementById('reorderPageGrid');
var rh = document.getElementById('reorderHint');
if (rg) rg.innerHTML = '';
if (rh) rh.textContent = 'Нужно больше одной страницы';
}
}
}
function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── Drop zone ────────────────────────────────────────────────────────────
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
handleFiles(Array.from(e.dataTransfer.files));
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) handleFiles(Array.from(fileInput.files));
});
btnAddMore.addEventListener('click', () => fileInputMore.click());
fileInputMore.addEventListener('change', () => {
if (fileInputMore.files.length) handleFiles(Array.from(fileInputMore.files));
});
// ─── Upload files ─────────────────────────────────────────────────────────
async function handleFiles(files) {
clearError();
const pdfFiles = files.filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
if (pdfFiles.length === 0) {
// Одни картинки — сразу в конструктор «Картинки → PDF»
const images = files.filter(isImageFile);
if (images.length) { openImgStudio(images); return; }
showError('Выберите PDF-файлы или изображения.');
return;
}
const MAX = 100 * 1024 * 1024;
const oversized = pdfFiles.find(f => f.size > MAX);
if (oversized) {
showError(`Файл "${oversized.name}" слишком большой. Максимальный размер — 100 МБ.`);
return;
}
// Show upload progress in drop zone (only if step still visible)
if (stepUpload.style.display !== 'none') {
dropContent.innerHTML = `
<div class="uploading-overlay">
<div class="spinner"></div>
<div class="drop-label">Загрузка файлов...</div>
<div style="font-size:24px;font-weight:800;color:var(--accent);font-family:'JetBrains Mono',monospace;" id="uploadPercent">0%</div>
<div class="progress-bar-track" style="width:100%;max-width:300px;">
<div class="progress-bar-fill" id="uploadFill" style="width:0%"></div>
</div>
<div class="drop-hint" style="margin-top:8px;">${pdfFiles.length} файл(ов)</div>
</div>`;
}
const formData = new FormData();
pdfFiles.forEach(f => formData.append('files', f));
try {
const data = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/pdf/upload');
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
const el = document.getElementById('uploadPercent');
const fill = document.getElementById('uploadFill');
if (el) el.textContent = pct + '%';
if (fill) fill.style.width = pct + '%';
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch { reject(new Error('Ошибка разбора ответа')); }
} else {
try { reject(new Error(JSON.parse(xhr.responseText).error)); }
catch { reject(new Error('Ошибка загрузки: ' + xhr.status)); }
}
};
xhr.onerror = () => reject(new Error('Сеть недоступна'));
xhr.send(formData);
});
onUploadSuccess(data);
} catch (err) {
restoreDropZone();
showError(err.message || 'Не удалось загрузить файлы');
}
}
function onUploadSuccess(data) {
const uploaded = data.files || [];
uploaded.forEach(f => {
if (!state.files.find(ex => ex.id === f.id)) {
state.files.push(f);
}
});
if (!state.selectedFileId && state.files.length > 0) {
state.selectedFileId = state.files[0].id;
}
renderFileList();
stepUpload.style.display = 'none';
stepConfig.style.display = 'block';
fileInput.value = '';
fileInputMore.value = '';
}
function restoreDropZone() {
dropContent.innerHTML = `
<div class="drop-icon">
<svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
</div>
<div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div>
<div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу · картинки соберём в PDF</div>`;
}
// ─── Operation tabs ───────────────────────────────────────────────────────
document.querySelectorAll('.op-tab').forEach(tab => {
tab.addEventListener('click', () => {
state.currentOp = tab.dataset.op;
document.querySelectorAll('.op-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('.op-panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById('panel-' + tab.dataset.op);
if (panel) panel.classList.add('active');
// Reset result and progress
stepResult.style.display = 'none';
downloadLink.style.display = 'none';
progressWrap.style.display = 'none';
// Hide step 3+4 entirely for tabs with inline actions
const inlineTabs = ['pages'];
const isInline = inlineTabs.includes(tab.dataset.op);
var s3 = document.getElementById('step3Wrap');
if (s3) s3.style.display = isInline ? 'none' : '';
actionWrap.style.display = isInline ? 'none' : 'block';
});
});
// ─── Sub-tabs (pages, convert, security) ─────────────────────────────────
function initSubTabs(panelId, stateKey) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.querySelectorAll('.sub-tab').forEach(tab => {
tab.addEventListener('click', () => {
state[stateKey] = tab.dataset.sub;
panel.querySelectorAll('.sub-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
panel.querySelectorAll('.sub-panel').forEach(p => p.classList.remove('active'));
const sp = document.getElementById('subpanel-' + tab.dataset.sub);
if (sp) sp.classList.add('active');
});
});
}
initSubTabs('panel-pages', 'pagesSubOp');
initSubTabs('panel-convert', 'convertSub');
initSubTabs('panel-security', 'securitySub');
// ─── Angle buttons ────────────────────────────────────────────────────────
document.getElementById('angleGroup').addEventListener('click', e => {
const btn = e.target.closest('.angle-btn');
if (!btn) return;
document.querySelectorAll('.angle-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
state.rotateAngle = parseInt(btn.dataset.angle, 10);
});
// ─── Img format + DPI groups ──────────────────────────────────────────────
function initBtnGroup(id, stateKey) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('click', e => {
const btn = e.target.closest('.btn-option');
if (!btn) return;
el.querySelectorAll('.btn-option').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
state[stateKey] = btn.dataset.value;
});
}
initBtnGroup('imgFormatGroup', 'imgFormat');
initBtnGroup('dpiGroup', 'imgDpi');
// ─── Compress quality ─────────────────────────────────────────────────────
document.querySelectorAll('.quality-option').forEach(label => {
label.addEventListener('click', () => {
document.querySelectorAll('.quality-option').forEach(l => l.classList.remove('selected'));
label.classList.add('selected');
state.compressQuality = label.dataset.quality;
});
});
// ─── Sliders (watermark) ──────────────────────────────────────────────────
const wmFontSize = document.getElementById('wmFontSize');
const wmOpacity = document.getElementById('wmOpacity');
wmFontSize.addEventListener('input', () => {
document.getElementById('wmFontSizeVal').textContent = wmFontSize.value;
});
wmOpacity.addEventListener('input', () => {
document.getElementById('wmOpacityVal').textContent = parseFloat(wmOpacity.value).toFixed(2);
});
// ─── Color picker ─────────────────────────────────────────────────────────
document.getElementById('wmColor').addEventListener('input', e => {
state.wmColor = e.target.value;
document.getElementById('wmColorHex').textContent = e.target.value;
});
// ─── Position grid ────────────────────────────────────────────────────────
const posLabels = {
'top-left': 'Вверху слева',
'top-center': 'Вверху по центру',
'top-right': 'Вверху справа',
'mid-left': 'По центру слева',
'mid-center': 'По центру',
'mid-right': 'По центру справа',
'bot-left': 'Внизу слева',
'bot-center': 'Внизу по центру',
'bot-right': 'Внизу справа',
};
document.getElementById('posGrid').addEventListener('click', e => {
const cell = e.target.closest('.pos-cell');
if (!cell) return;
document.querySelectorAll('.pos-cell').forEach(c => c.classList.remove('selected'));
cell.classList.add('selected');
state.pageNumPosition = cell.dataset.pos;
document.getElementById('posLabel').textContent = posLabels[cell.dataset.pos] || cell.dataset.pos;
});
// ─── Process ──────────────────────────────────────────────────────────────
btnProcess.addEventListener('click', startProcess);
async function startProcess() {
clearError();
const op = state.currentOp;
// Validation
if (op === 'merge' && state.files.length < 2) {
showError('Для объединения нужно минимум 2 файла.');
return;
}
const needsOne = ['split','pages','watermark','pagenums','compress','convert','security'];
if (needsOne.includes(op) && !state.selectedFileId) {
showError('Выберите файл для обработки.');
return;
}
if (op === 'pages' && state.pagesSubOp === 'delete' && getSelectedPages('deletePageGrid').length === 0) {
showError('Выберите страницы для удаления');
return;
}
if (op === 'security' && state.securitySub === 'protect' && !document.getElementById('protectPassword').value) {
showError('Введите пароль для защиты файла.');
return;
}
if (op === 'convert' && state.convertSub === 'fromimg') {
openImgStudio([]);
return;
}
actionWrap.style.display = 'none';
progressWrap.style.display = 'block';
stepResult.style.display = 'none';
setProgress(10, 'Отправка запроса...');
try {
const result = await sendRequest(op);
onProcessDone(op, result);
} catch (err) {
showProcessError(err.message || 'Ошибка обработки');
}
}
async function sendRequest(op) {
let url, body, isMultipart = false;
const fileId = state.selectedFileId;
switch (op) {
case 'merge':
url = '/pdf/merge';
body = JSON.stringify({ fileIds: state.mergeOrder || state.files.map(f => f.id) });
break;
case 'split':
url = '/pdf/split';
body = JSON.stringify({ fileId, ranges: document.getElementById('splitRanges').value.trim() });
break;
case 'pages':
if (state.pagesSubOp === 'rotate') {
url = '/pdf/rotate';
body = JSON.stringify({ fileId, pages: (getSelectedPages('rotatePageGrid').length ? getSelectedPages('rotatePageGrid').join(',') : 'all'), angle: state.rotateAngle });
} else if (state.pagesSubOp === 'delete') {
url = '/pdf/delete';
body = JSON.stringify({ fileId, pages: getSelectedPages('deletePageGrid').join(',') });
} else {
url = '/pdf/reorder';
body = JSON.stringify({ fileId, order: Array.from(document.getElementById('reorderPageGrid').querySelectorAll('.page-thumb')).map(t => parseInt(t.dataset.page)) });
}
break;
case 'pagenums':
url = '/pdf/pagenumbers';
body = JSON.stringify({
fileId,
position: state.pageNumPosition,
startFrom: parseInt(document.getElementById('pageNumStart').value, 10) || 1,
});
break;
case 'watermark':
url = '/pdf/watermark';
body = JSON.stringify({
fileId,
text: document.getElementById('wmText').value.trim(),
fontSize: parseInt(document.getElementById('wmFontSize').value, 10),
opacity: parseFloat(document.getElementById('wmOpacity').value),
color: state.wmColor,
});
break;
case 'compress':
url = '/pdf/compress';
body = JSON.stringify({ fileId, quality: state.compressQuality });
break;
case 'convert':
url = '/pdf/toImages';
body = JSON.stringify({ fileId, format: state.imgFormat, dpi: parseInt(state.imgDpi, 10) });
break;
case 'security':
if (state.securitySub === 'protect') {
url = '/pdf/protect';
body = JSON.stringify({ fileId, password: document.getElementById('protectPassword').value });
} else {
url = '/pdf/extract-text';
body = JSON.stringify({ fileId });
}
break;
default:
throw new Error('Неизвестная операция: ' + op);
}
setProgress(40, 'Обработка на сервере...');
const headers = isMultipart ? {} : { 'Content-Type': 'application/json' };
const res = await fetch(url, { method: 'POST', headers, body });
setProgress(90, 'Получение результата...');
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Ошибка сервера ' + res.status }));
throw new Error(err.error || 'Ошибка сервера ' + res.status);
}
return res.json();
}
function setProgress(pct, text) {
progressFill.style.width = pct + '%';
progressPercent.textContent = pct + '%';
progressStatus.textContent = text;
}
function onProcessDone(op, data) {
setProgress(100, 'Готово!');
// Reset result UI
document.getElementById('resultTextWrap').style.display = 'none';
downloadLink.style.display = 'none';
document.getElementById('resultSavings').style.display = 'none';
document.getElementById('resultSavingsLabel').style.display = 'none';
// Fill stats
if (data.size) {
document.getElementById('resultSize').textContent = formatSize(data.size);
} else {
document.getElementById('resultSize').textContent = '—';
}
if (data.savings) {
const savingsEl = document.getElementById('resultSavings');
const savingsLbl = document.getElementById('resultSavingsLabel');
savingsEl.textContent = '−' + data.savings + '%';
savingsEl.style.display = '';
savingsLbl.style.display = '';
}
// Extract text: show textarea
if (op === 'security' && state.securitySub === 'extract') {
document.getElementById('resultTextWrap').style.display = 'block';
document.getElementById('resultText').value = data.text || '';
}
// Download link
if (data.downloadUrl) {
downloadLink.href = data.downloadUrl;
downloadLink.setAttribute('download', '');
downloadLink.style.display = '';
}
stepResult.style.display = 'block';
stepResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function showProcessError(msg) {
progressWrap.style.display = 'none';
actionWrap.style.display = 'block';
showError(msg);
}
// ─── Copy extracted text ──────────────────────────────────────────────────
document.getElementById('btnCopyText').addEventListener('click', () => {
const ta = document.getElementById('resultText');
ta.select();
try {
navigator.clipboard.writeText(ta.value).catch(() => document.execCommand('copy'));
} catch {
document.execCommand('copy');
}
const btn = document.getElementById('btnCopyText');
const orig = btn.textContent;
btn.textContent = 'Скопировано!';
setTimeout(() => { btn.textContent = orig; }, 2000);
});
// ─── Back button ──────────────────────────────────────────────────────────
btnBack.addEventListener('click', () => {
stepResult.style.display = 'none';
actionWrap.style.display = 'block';
progressWrap.style.display = 'none';
setProgress(0, 'Обработка...');
clearError();
});
// ─── Reset to upload ──────────────────────────────────────────────────────
function resetToUpload() {
state.files = [];
state.selectedFileId = null;
stepConfig.style.display = 'none';
stepResult.style.display = 'none';
stepUpload.style.display = 'block';
actionWrap.style.display = 'block';
progressWrap.style.display = 'none';
restoreDropZone();
clearError();
fileInput.value = '';
fileInputMore.value = '';
}
// ─── 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 = `
<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 ─────────────────────────────────────────────────
function renderPageGrid(gridId, hintId, fileId, pages, mode) {
const grid = document.getElementById(gridId);
const hint = document.getElementById(hintId);
if (!grid) return;
if (!fileId || pages < 1) {
grid.innerHTML = '';
if (hint) hint.textContent = '';
return;
}
if (hint) hint.textContent = '';
grid.innerHTML = '';
for (let i = 1; i <= Math.min(pages, 100); i++) {
const thumb = document.createElement('div');
thumb.className = 'page-thumb';
thumb.dataset.page = i;
thumb.innerHTML = '<img src="/pdf/preview/' + fileId + '/' + i + '" alt="' + i + '" loading="lazy"><div class="page-thumb-num">' + i + '</div><div class="page-thumb-check"><svg fill="none" stroke="#fff" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg></div>';
if (mode === 'delete') {
thumb.addEventListener('click', () => {
thumb.classList.toggle('selected');
thumb.classList.toggle('marked-delete');
updateDeleteCount();
});
} else if (mode === 'rotate') {
thumb.addEventListener('click', () => {
// Immediate rotate on click
const angle = state.rotateAngle || 90;
thumb.classList.add('rotate-' + angle);
thumb.style.pointerEvents = 'none';
thumb.style.opacity = '0.6';
// Auto-execute rotate for this page
autoRotatePage(parseInt(thumb.dataset.page), angle);
});
} else if (mode === 'reorder') {
// Drag and drop
thumb.draggable = true;
thumb.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', i);
thumb.classList.add('dragging');
});
thumb.addEventListener('dragend', () => thumb.classList.remove('dragging'));
thumb.addEventListener('dragover', e => { e.preventDefault(); thumb.classList.add('drag-over'); });
thumb.addEventListener('dragleave', () => thumb.classList.remove('drag-over'));
thumb.addEventListener('drop', e => {
e.preventDefault();
thumb.classList.remove('drag-over');
const fromPage = e.dataTransfer.getData('text/plain');
const fromEl = grid.querySelector('[data-page="' + fromPage + '"]');
if (fromEl && fromEl !== thumb) {
// Insert dragged element before or after drop target
const rect = thumb.getBoundingClientRect();
const midX = rect.left + rect.width / 2;
if (e.clientX < midX) {
grid.insertBefore(fromEl, thumb);
} else {
grid.insertBefore(fromEl, thumb.nextSibling);
}
// Update visual order numbers
Array.from(grid.children).forEach((el, idx) => {
el.querySelector('.page-thumb-num').textContent = idx + 1;
});
// Auto-save reorder
autoSaveReorder();
}
});
}
grid.appendChild(thumb);
}
}
function updateDeleteCount() {
const count = getSelectedPages('deletePageGrid').length;
const el = document.getElementById('deleteCount');
const btn = document.getElementById('btnDeleteSelected');
if (el) el.textContent = count;
if (btn) btn.disabled = count === 0;
}
function updateRotateCount() {
const count = getSelectedPages('rotatePageGrid').length;
const el = document.getElementById('rotateCount');
const btn = document.getElementById('btnRotateSelected');
if (el) el.textContent = count;
if (btn) btn.disabled = count === 0;
}
function getSelectedPages(gridId) {
const grid = document.getElementById(gridId);
if (!grid) return [];
return Array.from(grid.querySelectorAll('.page-thumb.selected')).map(t => parseInt(t.dataset.page));
}
// Direct execute for page operations (from grid action buttons)
window.executeDeletePages = async function() {
const pages = getSelectedPages('deletePageGrid');
if (!pages.length) { showError('Выберите страницы'); return; }
const fileId = state.selectedFileId;
if (!fileId) { showError('Выберите файл'); return; }
const selFile = state.files.find(f => f.id === fileId);
if (selFile && pages.length >= selFile.pages) {
showError('Нельзя удалить все страницы');
return;
}
clearError();
// Save backup for undo
const backupFile = { ...selFile };
_deleteBackup = { file: backupFile };
// Hide deleted pages visually
const grid = document.getElementById('deletePageGrid');
if (grid) {
grid.querySelectorAll('.page-thumb.selected').forEach(el => {
el.style.transition = 'opacity .3s, transform .3s';
el.style.opacity = '0';
el.style.transform = 'scale(0.8)';
setTimeout(() => el.remove(), 300);
});
}
document.getElementById('btnDeleteSelected').disabled = true;
document.getElementById('btnDeleteSelected').textContent = 'Удаление...';
try {
const res = await fetch('/pdf/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ fileId, pages: pages.join(',') }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
await syncAfterOperation(data);
} catch(e) { showError(e.message); }
document.getElementById('btnDeleteSelected').textContent = 'Удалить выбранные (0)';
document.getElementById('btnUndoDelete').style.display = '';
updateDeleteCount();
// Auto-hide undo after 30 seconds
setTimeout(() => {
_deleteBackup = null;
var ub = document.getElementById('btnUndoDelete');
if (ub) ub.style.display = 'none';
}, 30000);
};
window.executeRotatePages = async function() {
const pages = getSelectedPages('rotatePageGrid');
const fileId = state.selectedFileId;
if (!fileId) { showError('Выберите файл'); return; }
clearError();
try {
const res = await fetch('/pdf/rotate', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ fileId, pages: pages.length ? pages.join(',') : 'all', angle: state.rotateAngle || 90 }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
await syncAfterOperation(data);
} catch(e) { showError(e.message); }
};
window.executeReorderPages = async function() {
const grid = document.getElementById('reorderPageGrid');
if (!grid) return;
const order = Array.from(grid.querySelectorAll('.page-thumb')).map(t => parseInt(t.dataset.page));
const fileId = state.selectedFileId;
if (!fileId) { showError('Выберите файл'); return; }
clearError();
try {
const res = await fetch('/pdf/reorder', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ fileId, order }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
showResult(data);
} catch(e) { showError(e.message); }
};
function showResult(data) {
if (data.downloadUrl) {
document.getElementById('downloadLink').href = data.downloadUrl;
document.getElementById('downloadLink').setAttribute('download', '');
document.getElementById('downloadLink').style.display = '';
if (data.size) document.getElementById('resultSize').textContent = formatSize(data.size);
// Show result section (it's inside stepConfig, so don't hide stepConfig)
stepResult.style.display = 'block';
// Scroll to result
stepResult.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// Auto-save reorder on drop
let _reorderTimeout = null;
function autoSaveReorder() {
clearTimeout(_reorderTimeout);
const hint = document.getElementById('reorderHint');
if (hint) hint.textContent = 'Сохранение...';
_reorderTimeout = setTimeout(async () => {
const grid = document.getElementById('reorderPageGrid');
if (!grid) return;
const order = Array.from(grid.querySelectorAll('.page-thumb')).map(t => parseInt(t.dataset.page));
const fileId = state.selectedFileId;
if (!fileId) return;
try {
const res = await fetch('/pdf/reorder', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ fileId, order }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
if (hint) hint.textContent = 'Порядок сохранён';
await syncAfterOperation(data);
} catch(e) {
if (hint) hint.textContent = 'Ошибка: ' + e.message;
}
}, 800);
}
// After any page operation, re-upload the result as the new working file
async function syncAfterOperation(data) {
if (!data.downloadUrl) return;
showResult(data);
// Download the result and re-upload as new working file
try {
const res = await fetch(data.downloadUrl);
const blob = await res.blob();
const formData = new FormData();
const selFile = state.files.find(f => f.id === state.selectedFileId);
const fileName = selFile ? selFile.name : 'modified.pdf';
formData.append('files', blob, fileName);
const uploadRes = await fetch('/pdf/upload', { method: 'POST', body: formData });
const uploadData = await uploadRes.json();
if (uploadData.files && uploadData.files.length) {
const newFile = uploadData.files[0];
// Replace the old file in state
const oldId = state.selectedFileId;
state.files = state.files.map(f => f.id === oldId ? newFile : f);
state.selectedFileId = newFile.id;
// Re-render everything
renderFileList();
}
} catch(e) {
// Silent — result is still downloadable
}
}
// Auto-rotate single page on click
let _rotateTimeout = null;
function autoRotatePage(pageNum, angle) {
clearTimeout(_rotateTimeout);
const hint = document.getElementById('rotateHint');
if (hint) hint.textContent = 'Поворот страницы ' + pageNum + '...';
_rotateTimeout = setTimeout(async () => {
const fileId = state.selectedFileId;
if (!fileId) return;
try {
const res = await fetch('/pdf/rotate', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ fileId, pages: String(pageNum), angle })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
if (hint) hint.textContent = 'Страница ' + pageNum + ' повёрнута';
await syncAfterOperation(data);
} catch(e) {
if (hint) hint.textContent = 'Ошибка: ' + e.message;
}
}, 300);
}
// ─── Merge sortable file list ─────────────────────────────────────────────
function renderMergeList() {
const list = document.getElementById('mergeFileList');
const hint = document.getElementById('mergeHint');
if (!list) return;
list.innerHTML = '';
if (state.files.length < 2) {
if (hint) hint.textContent = 'Нужно минимум 2 файла';
return;
}
if (hint) hint.textContent = state.files.length + ' файлов — перетащите для изменения порядка';
// Use mergeOrder or default
if (!state.mergeOrder || state.mergeOrder.length !== state.files.length) {
state.mergeOrder = state.files.map(f => f.id);
}
state.mergeOrder.forEach((fileId, idx) => {
const f = state.files.find(x => x.id === fileId);
if (!f) return;
const item = document.createElement('div');
item.className = 'merge-sort-item';
item.dataset.id = f.id;
item.draggable = true;
item.innerHTML = '<div class="merge-grip"><svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg></div>'
+ '<div class="merge-num">' + (idx + 1) + '</div>'
+ '<div class="merge-name">' + escHtml(f.name) + '</div>'
+ '<div class="merge-meta">' + f.pages + ' стр. · ' + formatSize(f.size) + '</div>';
item.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', f.id);
item.classList.add('dragging');
});
item.addEventListener('dragend', () => item.classList.remove('dragging'));
item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); });
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', e => {
e.preventDefault();
item.classList.remove('drag-over');
const fromId = e.dataTransfer.getData('text/plain');
if (fromId === f.id) return;
// Reorder mergeOrder array
const fromIdx = state.mergeOrder.indexOf(fromId);
const toIdx = state.mergeOrder.indexOf(f.id);
if (fromIdx < 0 || toIdx < 0) return;
state.mergeOrder.splice(fromIdx, 1);
state.mergeOrder.splice(toIdx, 0, fromId);
renderMergeList();
});
list.appendChild(item);
});
}
// ─── Undo delete ──────────────────────────────────────────────────────────
let _deleteBackup = null; // { fileId, file object before delete }
window.undoDeletePages = function() {
if (!_deleteBackup) return;
// Restore the backed-up file
const old = _deleteBackup;
state.files = state.files.map(f => f.id === state.selectedFileId ? old.file : f);
state.selectedFileId = old.file.id;
_deleteBackup = null;
document.getElementById('btnUndoDelete').style.display = 'none';
stepResult.style.display = 'none';
downloadLink.style.display = 'none';
renderFileList();
};
})();
</script>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108185982', 'ym');
ym(108185982, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/108185982" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</body>
</html>