Files
pdf2scan_web/app/static/index.html

3265 lines
149 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" data-prelang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!--SEO_HEAD-->
<title>pdf2scan — сделать скан из PDF онлайн бесплатно</title>
<meta name="description" content="Сделайте скан из PDF онлайн — бесплатно, без регистрации. Добавьте эффект сканирования, сшивку (нитка, ленточка, пружинка, уголок, дырокол), нумерацию страниц и лист «прошито-пронумеровано».">
<meta name="keywords" content="сделать скан из pdf онлайн, pdf в скан онлайн, сканирование pdf бесплатно, pdf с эффектом сканирования, прошить документ онлайн, сшивка документов онлайн, растровый pdf онлайн, pdf2scan">
<link rel="canonical" href="https://pdf2scan.online/">
<link rel="alternate" hreflang="ru" href="https://pdf2scan.online/">
<link rel="alternate" hreflang="en" href="https://pdf2scan.online/">
<link rel="alternate" hreflang="de" href="https://pdf2scan.online/">
<link rel="alternate" hreflang="fr" href="https://pdf2scan.online/">
<link rel="alternate" hreflang="it" href="https://pdf2scan.online/">
<link rel="alternate" hreflang="es" href="https://pdf2scan.online/">
<link rel="alternate" hreflang="pt" href="https://pdf2scan.online/">
<link rel="alternate" hreflang="x-default" href="https://pdf2scan.online/">
<meta property="og:title" content="pdf2scan — сделать скан из PDF онлайн бесплатно">
<meta property="og:description" content="Сделайте скан из PDF онлайн — бесплатно, без регистрации. Сшивка, нумерация, эффект сканирования в один клик.">
<meta property="og:url" content="https://pdf2scan.online/">
<meta property="og:type" content="website">
<meta property="og:image" content="https://pdf2scan.online/static/logo.png">
<meta property="og:image:width" content="640">
<meta property="og:image:height" content="640">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="pdf2scan — сделать скан из PDF онлайн бесплатно">
<meta name="twitter:description" content="Сделайте скан из PDF онлайн — бесплатно, без регистрации. Сшивка, нумерация, эффект сканирования в один клик.">
<meta name="twitter:image" content="https://pdf2scan.online/static/logo.png">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="shortcut icon" href="/favicon.ico">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "pdf2scan",
"url": "https://pdf2scan.online",
"description": "Бесплатный онлайн-инструмент для создания PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Нумерация страниц, титульный лист, PDF/A.",
"applicationCategory": "UtilitiesApplication",
"operatingSystem": "Any",
"browserRequirements": "Requires JavaScript",
"inLanguage": ["ru", "en", "de", "fr", "it", "es", "pt"],
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "RUB" },
"featureList": [
"Эффект сканирования PDF онлайн",
"Сшивка нитками, ленточкой, уголком, пружинкой, дыроколом",
"Нумерация страниц",
"Титульный лист",
"Лист прошито и пронумеровано",
"Формат PDF/A",
"Цветной и чёрно-белый режим",
"Конвертация Word в PDF со сканированием"
],
"creator": { "@type": "Organization", "name": "pdf2scan", "url": "https://pdf2scan.online" }
}
</script>
<!--/SEO_HEAD-->
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--blue: #007AFF;
--blue-hover: #0066D6;
--blue-light: #E8F2FF;
--gray: #8E8E93;
--gray-light: #F2F2F7;
--border: #D1D1D6;
--text: #1C1C1E;
--text-secondary: #636366;
--radius: 14px;
--radius-sm: 10px;
--shadow: 0 4px 24px rgba(0,0,0,0.08);
--shadow-sm: 0 2px 8px rgba(0,0,0,0.06);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Arial, sans-serif;
background: #F2F2F7;
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
/* ── Header ── */
header {
width: 100%;
background: rgba(255,255,255,0.85);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
padding: 8px 24px;
display: flex;
align-items: center;
gap: 10px;
}
/* ── Language switcher ── */
#lang-switcher {
position: relative;
flex-shrink: 0;
}
#lang-btn {
background: none;
border: 1px solid var(--border);
border-radius: 8px;
padding: 5px 8px;
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
gap: 5px;
transition: background 0.15s;
}
#lang-btn:hover { background: var(--gray-light); }
#lang-btn .lang-arrow {
font-size: 9px;
color: var(--gray);
line-height: 1;
}
.flag-img {
width: 20px;
height: 15px;
border-radius: 2px;
display: block;
object-fit: cover;
}
.lang-option .flag-img {
width: 22px;
height: 16px;
}
#lang-dropdown {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
overflow: hidden;
min-width: 150px;
z-index: 200;
}
#lang-dropdown.open { display: block; }
.lang-option {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
cursor: pointer;
font-size: 14px;
transition: background 0.1s;
border: none;
background: none;
width: 100%;
text-align: left;
font-family: inherit;
color: var(--text);
}
.lang-option:hover { background: var(--gray-light); }
.lang-option.active { background: var(--blue-light); color: var(--blue); font-weight: 600; }
.lang-option .lang-flag { font-size: 18px; }
.logo-icon {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.logo-text {
font-size: 20px;
font-weight: 700;
color: var(--blue);
letter-spacing: -0.3px;
}
.logo-subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 1px;
}
/* ── Telegram auth in header ── */
#header-auth {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#tg-user-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text);
}
#tg-user-info img {
width: 30px;
height: 30px;
border-radius: 50%;
}
#tg-logout-btn {
background: none;
border: none;
font-size: 12px;
color: var(--gray);
cursor: pointer;
font-family: inherit;
padding: 0;
}
#tg-login-header-btn {
padding: 7px 14px;
background: var(--blue);
color: white;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
display: flex;
align-items: center;
gap: 6px;
}
#tg-login-header-btn:hover { background: var(--blue-hover); }
/* ── Main card ── */
main {
width: 100%;
max-width: 640px;
padding: 14px 16px 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
background: white;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.card-title {
font-size: 12px;
font-weight: 600;
padding: 9px 20px 7px;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
letter-spacing: 0.2px;
text-transform: uppercase;
}
/* ── Drop zone ── */
.drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 18px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
position: relative;
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--blue);
background: var(--blue-light);
}
.drop-zone input[type=file] {
display: none;
}
.drop-icon {
width: 40px;
height: 40px;
background: var(--blue-light);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.drop-icon svg { fill: var(--blue); }
.drop-title {
font-size: 15px;
font-weight: 600;
}
.drop-hint {
font-size: 14px;
color: var(--text-secondary);
}
/* ── Batch file list ── */
#batch-list { display: flex; flex-direction: column; gap: 8px; }
.batch-row {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 10px 12px;
background: var(--blue-light);
border-radius: 10px;
font-size: 14px;
}
.batch-name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1; min-width: 0; font-weight: 500;
}
.batch-status { flex-shrink: 0; }
.bs {
display: inline-flex; align-items: center; gap: 4px;
font-size: 13px; font-weight: 600; white-space: nowrap;
padding: 3px 10px; border-radius: 8px; text-decoration: none;
}
.bs-wait { color: var(--text-secondary); background: rgba(120,120,128,0.12); }
.bs-proc { color: #FF9500; background: #FFF8EC; }
.bs-done { color: #fff; background: var(--blue); }
.bs-done:hover { filter: brightness(0.95); }
.bs-err { color: #fff; background: #FF3B30; cursor: help; }
#batch-actions { display: flex; gap: 10px; flex-wrap: wrap; padding: 0 12px 12px; }
#zip-btn, #batch-another-btn {
flex: 1; min-width: 160px; padding: 12px 18px;
border: none; border-radius: 12px; font-size: 15px; font-weight: 600;
cursor: pointer; font-family: inherit;
}
#zip-btn { background: var(--blue); color: #fff; }
#zip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
#batch-another-btn { background: rgba(120,120,128,0.14); color: var(--text); }
.file-badge {
background: var(--blue-light);
color: var(--blue);
border-radius: 8px;
padding: 6px 14px;
font-size: 14px;
font-weight: 500;
display: none;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Settings ── */
.setting-row {
padding: 9px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border);
}
.setting-row:last-child { border-bottom: none; }
.setting-label {
font-size: 15px;
flex: 1;
}
/* Segmented control */
.seg {
display: flex;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.seg input[type=radio] { display: none; }
.seg label {
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.seg label:not(:last-of-type) { border-right: 1px solid var(--border); }
.seg input[type=radio]:checked + label {
background: var(--blue);
color: white;
}
/* Stitch grid */
.stitch-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 8px 14px;
}
.stitch-grid input[type=radio] { display: none; }
.stitch-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 4px;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
font-size: 13px;
text-align: center;
}
.stitch-btn .icon { font-size: 20px; }
.stitch-grid input[type=radio]:checked + .stitch-btn {
border-color: var(--blue);
background: var(--blue-light);
color: var(--blue);
font-weight: 600;
}
/* Toggle switch */
.toggle {
position: relative;
width: 51px;
height: 31px;
flex-shrink: 0;
}
.toggle input { display: none; }
.toggle-track {
display: block;
width: 100%;
height: 100%;
border-radius: 100px;
background: var(--border);
transition: background 0.2s;
cursor: pointer;
}
.toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 27px;
height: 27px;
border-radius: 50%;
background: white;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
transition: transform 0.2s;
}
.toggle input:checked + .toggle-track {
background: var(--blue);
}
.toggle input:checked + .toggle-track::after {
transform: translateX(20px);
}
/* ── Auth banner ── */
#auth-banner {
display: none;
background: linear-gradient(135deg, var(--blue) 0%, #5AC8FA 100%);
color: white;
border-radius: var(--radius);
padding: 24px 20px;
text-align: center;
flex-direction: column;
align-items: center;
gap: 14px;
}
#auth-banner h3 { font-size: 18px; font-weight: 700; }
#auth-banner p { font-size: 14px; opacity: 0.9; line-height: 1.4; }
/* ── Submit button ── */
#submit-btn {
width: 100%;
padding: 16px;
background: var(--blue);
color: white;
border: none;
border-radius: var(--radius);
font-size: 17px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
font-family: inherit;
}
#submit-btn:hover { background: var(--blue-hover); }
#submit-btn:active { transform: scale(0.98); }
#submit-btn:disabled {
background: var(--gray);
cursor: not-allowed;
transform: none;
}
/* ── Progress ── */
#progress-card { display: none; }
.progress-label {
display: flex;
justify-content: space-between;
padding: 16px 20px 6px;
font-size: 14px;
}
.progress-label span:last-child { color: var(--blue); font-weight: 600; }
.progress-bar-track {
height: 8px;
background: var(--gray-light);
border-radius: 100px;
margin: 0 20px 6px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--blue);
border-radius: 100px;
width: 0%;
transition: width 0.4s ease;
}
.progress-speed {
padding: 0 20px 14px;
font-size: 12px;
color: var(--text-secondary);
min-height: 18px;
}
/* ── Result ── */
#result-card { display: none; }
.result-content {
padding: 14px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
}
.success-icon {
width: 48px;
height: 48px;
background: #34C759;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.success-icon svg { fill: white; }
.result-title { font-size: 20px; font-weight: 700; }
.result-sub { font-size: 14px; color: var(--text-secondary); }
#download-btn {
padding: 14px 40px;
background: #34C759;
color: white;
border: none;
border-radius: var(--radius);
font-size: 16px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
text-decoration: none;
display: inline-block;
}
#download-btn:hover { opacity: 0.85; }
#another-btn {
background: none;
border: none;
color: var(--blue);
font-size: 15px;
cursor: pointer;
font-family: inherit;
}
/* ── Error ── */
.error-msg {
background: #FFF0F0;
border: 1px solid #FFD6D6;
border-radius: var(--radius-sm);
padding: 12px 16px;
color: #C00;
font-size: 14px;
display: none;
}
/* ── Info ── */
.info-msg {
background: var(--blue-light);
border: 1px solid #B3D4FF;
border-radius: var(--radius-sm);
padding: 12px 16px;
color: var(--blue);
font-size: 14px;
display: none;
}
/* ── Donate card ── */
#donate-card {
display: none;
background: linear-gradient(135deg, #fffbf0 0%, #fff8e6 100%);
border: 1px solid #ffe0a0;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.donate-inner {
padding: 16px 20px;
display: flex;
align-items: center;
gap: 18px;
}
.donate-text { flex: 1; min-width: 0; }
.donate-icon { font-size: 22px; margin-bottom: 2px; }
.donate-title {
font-size: 15px;
font-weight: 700;
color: #b45309;
margin-bottom: 4px;
}
.donate-desc {
font-size: 13px;
color: #92400e;
line-height: 1.4;
margin-bottom: 10px;
}
.donate-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.donate-btn {
padding: 7px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
display: inline-block;
transition: opacity 0.15s;
}
.donate-btn:hover { opacity: 0.85; }
.donate-btn-sbp {
background: #1a56db;
color: white;
}
.donate-qr {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.donate-qr img {
width: 100px;
height: 100px;
border-radius: 8px;
border: 1px solid #ffe0a0;
background: white;
padding: 4px;
display: block;
}
.donate-qr-hint {
font-size: 10px;
color: #92400e;
text-align: center;
max-width: 100px;
line-height: 1.3;
}
.pay-amt {
padding: 7px 12px;
border: 1px solid var(--border, #d0d7e2);
background: #fff;
border-radius: 8px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
color: #92400e;
}
.pay-amt.active {
background: #f59e0b;
border-color: #f59e0b;
color: #fff;
font-weight: 600;
}
@media (max-width: 420px) {
.donate-inner { flex-direction: column; }
.donate-qr { flex-direction: row; align-items: center; gap: 12px; width: 100%; }
.donate-qr img { width: 80px; height: 80px; }
.donate-qr-hint { max-width: none; }
/* Батч-список на узких экранах */
#batch-actions { flex-direction: column; }
#zip-btn, #batch-another-btn { min-width: 0; width: 100%; }
.batch-row { padding: 9px 10px; font-size: 13px; }
.bs { font-size: 12px; padding: 3px 8px; }
}
/* ── SEO section ── */
#seo-section {
width: 100%;
max-width: 640px;
padding: 0 16px 8px;
}
.seo-heading {
font-size: 17px;
font-weight: 700;
color: var(--text);
margin-bottom: 10px;
text-align: center;
}
.seo-steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.seo-step {
background: white;
border-radius: var(--radius-sm);
padding: 12px 10px;
text-align: center;
box-shadow: var(--shadow-sm);
}
.seo-step-num {
width: 28px;
height: 28px;
background: var(--blue);
color: white;
border-radius: 50%;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 6px;
}
.seo-step-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
margin-bottom: 3px;
}
.seo-step-desc {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.4;
}
.seo-about {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.55;
text-align: center;
padding: 0 4px;
}
/* ── Footer ── */
footer {
padding: 10px;
text-align: center;
font-size: 13px;
color: var(--text-secondary);
}
</style>
</head>
<body>
<header>
<!-- Language switcher -->
<div id="lang-switcher">
<button id="lang-btn" onclick="toggleLangDropdown(event)" aria-label="Select language">
<img id="lang-flag-img" class="flag-img" src="/static/flags/ru.png" alt="RU">
<span class="lang-arrow"></span>
</button>
<div id="lang-dropdown">
<button class="lang-option" onclick="setLang('ru')"><img class="flag-img" src="/static/flags/ru.png" alt="RU"> Русский</button>
<button class="lang-option" onclick="setLang('en')"><img class="flag-img" src="/static/flags/gb.png" alt="GB"> English</button>
<button class="lang-option" onclick="setLang('de')"><img class="flag-img" src="/static/flags/de.png" alt="DE"> Deutsch</button>
<button class="lang-option" onclick="setLang('fr')"><img class="flag-img" src="/static/flags/fr.png" alt="FR"> Français</button>
<button class="lang-option" onclick="setLang('it')"><img class="flag-img" src="/static/flags/it.png" alt="IT"> Italiano</button>
<button class="lang-option" onclick="setLang('es')"><img class="flag-img" src="/static/flags/es.png" alt="ES"> Español</button>
<button class="lang-option" onclick="setLang('pt')"><img class="flag-img" src="/static/flags/pt.png" alt="PT"> Português</button>
</div>
</div>
<div class="logo-icon">
<img src="/static/logo.png" width="40" height="40" alt="pdf2scan logo" style="display:block;">
</div>
<div>
<div class="logo-text">pdf2scan</div>
<div class="logo-subtitle" data-i18n="logo_subtitle">Создание растровых pdf с эффектом сканирования из электронных pdf документов</div>
</div>
<div id="header-auth" style="position:relative;">
<button id="tg-login-header-btn" onclick="toggleLoginMenu(event)" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L8.32 13.617l-2.96-.924c-.643-.204-.657-.643.136-.953l11.57-4.461c.537-.194 1.006.131.828.942z"/></svg>
<span data-i18n="login_btn">Войти</span>
</button>
<div id="login-menu" style="display:none;position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid var(--border,#d0d7e2);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.12);padding:6px;z-index:50;min-width:190px;">
<button onclick="startTgAuth();closeLoginMenu();" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="#2AABEE"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L8.32 13.617l-2.96-.924c-.643-.204-.657-.643.136-.953l11.57-4.461c.537-.194 1.006.131.828.942z"/></svg>
<span data-i18n="login_via_tg">Через Telegram</span>
</button>
<button onclick="location.href='/api/auth/yandex/start';" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
<span style="display:inline-flex;width:18px;height:18px;background:#FC3F1D;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:12px;">Я</span>
<span data-i18n="login_via_yandex">Через Яндекс</span>
</button>
<button onclick="location.href='/api/auth/vk/start';" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
<span style="display:inline-flex;width:22px;height:18px;background:#07f;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:11px;">VK</span>
<span data-i18n="login_via_vk">Через VK</span>
</button>
<button onclick="closeLoginMenu();openEmailLogin();" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
<span style="display:inline-flex;width:22px;height:18px;background:#64748b;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:12px;"></span>
<span data-i18n="login_via_email">Через почту</span>
</button>
</div>
<div id="tg-user-info" style="display:none;">
<img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'">
<span id="tg-user-name"></span>
<button id="tg-logout-btn" onclick="doLogout()" data-i18n="logout_btn">Выйти</button>
</div>
</div>
</header>
<!-- Модалка входа по почте -->
<div id="email-login-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:100;align-items:center;justify-content:center;padding:16px;">
<div style="background:#fff;border-radius:14px;max-width:340px;width:100%;padding:22px;box-shadow:0 10px 40px rgba(0,0,0,.25);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<strong style="font-size:17px;color:#1c2b45" data-i18n="email_login_title">Вход по почте</strong>
<button onclick="closeEmailLogin()" style="background:none;border:none;font-size:22px;line-height:1;cursor:pointer;color:#94a3b8">&times;</button>
</div>
<div id="email-step-1">
<input type="email" id="email-login-input" data-i18n-ph="email_ph" placeholder="ваш email" style="width:100%;padding:10px 12px;border:1px solid #d0d7e2;border-radius:8px;font-size:15px;font-family:inherit;margin-bottom:10px">
<button id="email-send-btn" onclick="emailSendCode()" style="width:100%;padding:11px;background:var(--blue,#007AFF);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="email_send_code">Получить код</button>
</div>
<div id="email-step-2" style="display:none">
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 10px" data-i18n="email_code_sent">Код отправлен на почту. Введите его:</p>
<input type="text" id="email-code-input" inputmode="numeric" maxlength="6" placeholder="000000" style="width:100%;padding:10px 12px;border:1px solid #d0d7e2;border-radius:8px;font-size:20px;letter-spacing:6px;text-align:center;font-family:inherit;margin-bottom:10px">
<button id="email-verify-btn" onclick="emailVerifyCode()" style="width:100%;padding:11px;background:#10b981;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="email_login_btn">Войти</button>
</div>
<div id="email-login-msg" style="font-size:12px;color:#e05555;margin-top:10px;min-height:16px"></div>
</div>
</div>
<!-- Модалка «оплата прошла» -->
<div id="pay-success-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:110;align-items:center;justify-content:center;padding:16px;">
<div style="background:#fff;border-radius:16px;max-width:340px;width:100%;padding:28px 24px;box-shadow:0 12px 44px rgba(0,0,0,.28);text-align:center;">
<div style="font-size:52px;line-height:1;margin-bottom:10px"></div>
<div id="pay-success-msg" style="font-size:17px;font-weight:600;color:#1c2b45;margin-bottom:16px"></div>
<button onclick="document.getElementById('pay-success-overlay').style.display='none'" style="padding:11px 28px;background:var(--blue,#007AFF);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="pay_ok_btn">Отлично</button>
</div>
</div>
<main>
<!-- Description -->
<p data-i18n="page_description" style="font-size:13px; line-height:1.45; color:var(--text-secondary); padding: 0 4px;">
🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.
</p>
<!-- Upload -->
<div class="card">
<div class="card-title" data-i18n="card_file">Файл</div>
<div style="padding: 12px;">
<div class="drop-zone" id="drop-zone">
<input type="file" id="file-input" accept=".pdf,.doc,.docx,.zip" multiple style="display:none" />
<input type="file" id="folder-input" webkitdirectory directory multiple style="display:none" />
<div class="drop-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
</div>
<div class="drop-title" data-i18n="drop_title">Выберите PDF или Word</div>
<div class="drop-hint" data-i18n="drop_hint">или перетащите файлы сюда (до 20)</div>
<div class="file-badge" id="file-badge"></div>
</div>
<div style="text-align:center;margin-top:8px">
<button type="button" id="folder-btn" style="background:none;border:1px solid var(--border,#2b3a52);color:var(--text-secondary);border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer;font-family:inherit" data-i18n="folder_pick">📁 Папкой</button>
</div>
</div>
</div>
<!-- Batch list (несколько файлов) -->
<div class="card" id="batch-card" style="display:none">
<div class="card-title"><span data-i18n="batch_files">Файлы</span> <span id="batch-count"></span></div>
<div style="padding: 12px;">
<div id="batch-list"></div>
</div>
<div id="batch-actions" style="display:none">
<button id="zip-btn" type="button" data-i18n="download_all_zip">⬇ Скачать всё архивом</button>
<button id="batch-another-btn" type="button" onclick="resetApp()" data-i18n="another_btn_batch">Обработать ещё файлы</button>
</div>
</div>
<!-- Stitching -->
<div class="card">
<div class="card-title" data-i18n="card_stitch">Тип сшивки</div>
<div class="stitch-grid">
<input type="radio" name="stitching" id="s-thread" value="нитка">
<label class="stitch-btn" for="s-thread"><span class="icon">🧵</span><span data-i18n="stitch_thread">Нитка</span></label>
<input type="radio" name="stitching" id="s-ribbon" value="ленточка">
<label class="stitch-btn" for="s-ribbon"><span class="icon">🎀</span><span data-i18n="stitch_ribbon">Ленточка</span></label>
<input type="radio" name="stitching" id="s-corner" value="уголок">
<label class="stitch-btn" for="s-corner"><span class="icon">📐</span><span data-i18n="stitch_corner">Уголок</span></label>
<input type="radio" name="stitching" id="s-spiral" value="пружинка">
<label class="stitch-btn" for="s-spiral"><span class="icon">🌀</span><span data-i18n="stitch_spiral">Пружинка</span></label>
<input type="radio" name="stitching" id="s-punch" value="дырокол">
<label class="stitch-btn" for="s-punch"><span class="icon"></span><span data-i18n="stitch_punch">Дырокол</span></label>
<input type="radio" name="stitching" id="s-none" value="без сшивки" checked>
<label class="stitch-btn" for="s-none"><span class="icon">📄</span><span data-i18n="stitch_none">Без сшивки</span></label>
</div>
</div>
<!-- Extra settings -->
<div class="card">
<div class="card-title" data-i18n="card_extra">Дополнительно</div>
<div class="setting-row">
<span class="setting-label" data-i18n="color_mode">Режим цвета</span>
<div class="seg">
<input type="radio" name="color" id="c-color" value="color">
<label for="c-color" data-i18n="color_color">Цветной</label>
<input type="radio" name="color" id="c-bw" value="bw" checked>
<label for="c-bw" data-i18n="color_bw">Ч/Б</label>
</div>
</div>
<div class="setting-row">
<span class="setting-label" data-i18n="quality">Качество</span>
<div class="seg">
<input type="radio" name="quality" id="q-high" value="high">
<label for="q-high" data-i18n="quality_high">Высокое</label>
<input type="radio" name="quality" id="q-medium" value="medium" checked>
<label for="q-medium" data-i18n="quality_medium">Среднее</label>
<input type="radio" name="quality" id="q-low" value="low">
<label for="q-low" data-i18n="quality_low">Низкое</label>
</div>
</div>
<div class="setting-row">
<span class="setting-label" data-i18n="title_page">Титульный лист</span>
<label class="toggle">
<input type="checkbox" id="title-check">
<span class="toggle-track"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label" data-i18n="page_numbers">Нумерация страниц</span>
<label class="toggle">
<input type="checkbox" id="num-check">
<span class="toggle-track"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label" data-i18n="certified_page">Лист «прошито-пронумеровано»</span>
<label class="toggle">
<input type="checkbox" id="flip-check">
<span class="toggle-track"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label" data-i18n="pdfa_format">Формат PDF/A</span>
<label class="toggle">
<input type="checkbox" id="pdfa-check" checked>
<span class="toggle-track"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">
<span data-i18n="ocr_label">Распознать текст (OCR)</span>
<span style="display:block;font-size:11px;color:var(--text-secondary);margin-top:2px" data-i18n="ocr_hint">Скан станет искомым: текст можно копировать и находить поиском</span>
</span>
<label class="toggle">
<input type="checkbox" id="ocr-check">
<span class="toggle-track"></span>
</label>
</div>
</div>
<!-- Auth banner (не используется — заменён пейволлом) -->
<div id="auth-banner" style="display:none">
<h3 data-i18n="auth_required">🔐 Требуется авторизация</h3>
<p data-i18n="auth_required_desc">Для файлов больше 100 страниц или 50 МБ необходим вход через Telegram.</p>
<button onclick="startTgAuth()" style="padding:10px 24px;background:white;color:#007AFF;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;" data-i18n="login_tg">
Войти через Telegram
</button>
</div>
<!-- Paywall: большой файл (>50 МБ / >100 стр.) -->
<div id="paywall-card" style="display:none;background:#fff;border:1px solid var(--border,#e2e8f0);border-radius:var(--radius);box-shadow:var(--shadow-sm);padding:18px 20px;margin-bottom:12px;text-align:center;">
<div style="font-size:32px;margin-bottom:4px">💳</div>
<h3 style="margin:0 0 6px;font-size:18px" data-i18n="paywall_title">Файл больше бесплатного лимита</h3>
<p style="margin:0 0 14px;font-size:14px;color:var(--text-secondary)" data-i18n="paywall_desc">Бесплатно — до 50 МБ и 100 страниц. Для файлов больше — разовая оплата или подписка.</p>
<input type="email" id="paywall-email" data-i18n-ph="pay_email_ph" placeholder="email для чека" style="width:100%;max-width:280px;padding:9px 12px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-size:14px;margin-bottom:12px;font-family:inherit">
<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center">
<button id="paywall-file-btn" onclick="payForFile()" style="flex:1;min-width:150px;padding:12px;background:var(--blue,#007AFF);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit"><span data-i18n="paywall_pay_file">Оплатить файл</span> · <span id="paywall-file-price">99</span></button>
<button id="paywall-sub-btn" onclick="payForSub()" style="flex:1;min-width:150px;padding:12px;background:#10b981;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit"><span data-i18n="paywall_pay_sub">Подписка</span> · <span id="paywall-sub-price">299</span> ₽/<span data-i18n="paywall_month">мес</span></button>
</div>
<div id="paywall-msg" style="font-size:12px;color:var(--text-secondary);margin-top:10px"></div>
<button onclick="resetApp()" style="margin-top:8px;background:none;border:none;color:var(--text-secondary);font-size:13px;cursor:pointer;text-decoration:underline;font-family:inherit" data-i18n="paywall_cancel">Отмена</button>
</div>
<!-- Info -->
<div class="info-msg" id="info-msg"></div>
<!-- Error -->
<div class="error-msg" id="error-msg"></div>
<!-- Submit -->
<button id="submit-btn" disabled data-i18n="btn_select_file">Выберите файл</button>
<!-- Progress -->
<div class="card" id="progress-card">
<div class="progress-label">
<span id="progress-status" data-i18n="progress_processing">Обработка...</span>
<span id="progress-pct"></span>
</div>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="progress-fill"></div>
</div>
<div class="progress-speed" id="progress-speed"></div>
</div>
<!-- Result -->
<div class="card" id="result-card">
<div class="result-content">
<div class="success-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
<div class="result-title" data-i18n="result_title">Готово!</div>
<div class="result-sub" id="result-sub" data-i18n="result_sub">Ваш PDF обработан и готов к скачиванию.</div>
<div style="font-size:13px;color:#FF9500;background:#FFF8EC;border-radius:8px;padding:8px 14px;" data-i18n="result_timer">
⏱ Ссылка активна 30 минут — скачайте файл сейчас
</div>
<a id="download-btn" href="#" download data-i18n="download_btn">⬇ Скачать результат</a>
<button id="another-btn" onclick="resetApp()" data-i18n="another_btn">Обработать ещё один файл</button>
</div>
</div>
<!-- Donate -->
<div id="donate-card">
<div class="donate-inner">
<div class="donate-text">
<div class="donate-icon">🍩</div>
<div class="donate-title" data-i18n="donate_title">Поддержите проект</div>
<div class="donate-desc" data-i18n="donate_desc">Если сервис полезен — поддержите его работу донатом. Это помогает оплачивать сервер и развивать проект.</div>
<div class="donate-buttons">
<a class="donate-btn donate-btn-sbp" href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019cbe28-6b54-7eb8-8a52-189633e608ea" target="_blank" rel="noopener" data-i18n="donate_sbp">СБП / Ozon Pay</a>
</div>
<!-- Оплата картой через ЮKassa -->
<div class="donate-pay" style="margin-top:14px">
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
<button type="button" class="pay-amt" onclick="setPayAmt(100,this)">100 ₽</button>
<button type="button" class="pay-amt active" onclick="setPayAmt(300,this)">300 ₽</button>
<button type="button" class="pay-amt" onclick="setPayAmt(500,this)">500 ₽</button>
<input type="number" id="pay-amt-custom" min="10" max="100000" placeholder="₽" oninput="onCustomAmt()"
style="width:76px;padding:7px 8px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-family:inherit;font-size:14px">
</div>
<input type="email" id="pay-email" data-i18n-ph="pay_email_ph" placeholder="email для чека"
style="width:100%;max-width:260px;padding:8px 10px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-family:inherit;font-size:14px;margin-bottom:8px">
<div>
<button type="button" id="pay-card-btn" onclick="payDonate()" class="donate-btn" data-i18n="pay_card_btn">💳 Оплатить картой</button>
</div>
<div id="pay-msg" style="font-size:12px;color:var(--text-secondary);margin-top:6px"></div>
</div>
</div>
<div class="donate-qr">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https%3A%2F%2Ffinance.ozon.ru%2Fapps%2Fsbp%2Fozonbankpay%2F019cbe28-6b54-7eb8-8a52-189633e608ea" alt="QR donate" loading="lazy">
<div class="donate-qr-hint" data-i18n="donate_qr_hint">Сканируйте для оплаты</div>
</div>
</div>
</div>
<!-- Личный кабинет: Мои документы (видно после входа) -->
<div class="card" id="cabinet-card" style="display:none">
<div class="card-title" data-i18n="cabinet_title">Мои документы</div>
<div style="padding: 12px;">
<div id="cabinet-list"></div>
</div>
</div>
</main>
<section id="seo-section">
<h2 class="seo-heading" data-i18n="seo_title">Как работает pdf2scan</h2>
<div class="seo-steps">
<div class="seo-step">
<div class="seo-step-num">1</div>
<div class="seo-step-title" data-i18n="seo_step1_title">Загрузите файл</div>
<div class="seo-step-desc" data-i18n="seo_step1_desc">PDF или Word — перетащите или выберите с устройства</div>
</div>
<div class="seo-step">
<div class="seo-step-num">2</div>
<div class="seo-step-title" data-i18n="seo_step2_title">Настройте параметры</div>
<div class="seo-step-desc" data-i18n="seo_step2_desc">Тип сшивки, цвет, качество, нумерация и другие опции</div>
</div>
<div class="seo-step">
<div class="seo-step-num">3</div>
<div class="seo-step-title" data-i18n="seo_step3_title">Скачайте результат</div>
<div class="seo-step-desc" data-i18n="seo_step3_desc">Готовый PDF со сканом доступен для скачивания 30 минут</div>
</div>
</div>
<p class="seo-about" data-i18n="seo_about">pdf2scan — бесплатный онлайн-инструмент для создания растрового PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Идеально для подготовки документов к сдаче в архив, нотариусу или государственные органы. Без регистрации, без установки программ.</p>
</section>
<footer>
pdf2scan &mdash; <span data-i18n="footer_text">превращаем PDF в скан</span> &nbsp;|&nbsp; <a href="https://t.me/pdf2scan_bot" style="color:var(--blue); text-decoration:none;" data-i18n="footer_tg">Telegram-бот</a> &nbsp;|&nbsp; <a href="/developers" style="color:var(--blue); text-decoration:none;" data-i18n="footer_api">API для бизнеса</a> &nbsp;|&nbsp; <a href="#" onclick="showDonateCard(false);document.getElementById('donate-card').scrollIntoView({behavior:'smooth'});return false;" style="color:var(--blue); text-decoration:none;" data-i18n="footer_donate">🍩 Поддержать</a>
</footer>
<script>
// ── Translations ──────────────────────────────────────────────────────────
const LANGS = {
ru: { img: '/static/flags/ru.png', alt: 'RU', name: 'Русский' },
en: { img: '/static/flags/gb.png', alt: 'GB', name: 'English' },
de: { img: '/static/flags/de.png', alt: 'DE', name: 'Deutsch' },
fr: { img: '/static/flags/fr.png', alt: 'FR', name: 'Français' },
it: { img: '/static/flags/it.png', alt: 'IT', name: 'Italiano' },
es: { img: '/static/flags/es.png', alt: 'ES', name: 'Español' },
pt: { img: '/static/flags/pt.png', alt: 'PT', name: 'Português' },
};
const TR = {
ru: {
page_title: 'pdf2scan — онлайн сканирование PDF',
logo_subtitle: 'Создание растровых pdf с эффектом сканирования из электронных pdf документов',
login_btn: 'Войти',
login_via_tg: 'Через Telegram',
login_via_yandex: 'Через Яндекс',
login_via_vk: 'Через VK',
login_via_email: 'Через почту',
email_login_title: 'Вход по почте',
email_ph: 'ваш email',
email_send_code: 'Получить код',
email_code_sent: 'Код отправлен на почту. Введите его:',
email_login_btn: 'Войти',
email_sending: 'Отправляем код…',
email_bad_code: 'Неверный код',
err_login: 'Не удалось войти. Попробуйте снова.',
logout_btn: 'Выйти',
page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.',
card_file: 'Файл',
drop_title: 'Выберите PDF или Word',
drop_hint: 'или перетащите файлы сюда (до 20)',
file_selected: 'Файл выбран',
batch_files: 'Файлы',
files_selected: (n) => `Выбрано ${n} ${pluralizeRu(n, 'файл', 'файла', 'файлов')}`,
btn_process_n: (n) => `Обработать ${n} ${pluralizeRu(n, 'файл', 'файла', 'файлов')}`,
err_too_many: (max) => `Слишком много файлов — максимум ${max}. Лишние не добавлены.`,
st_uploading: 'Загрузка…',
st_queued: 'В очереди',
st_processing: 'Обработка…',
st_done: 'Готово',
st_error: 'Ошибка',
download_all_zip: '⬇ Скачать всё архивом',
another_btn_batch: 'Обработать ещё файлы',
zip_preparing: 'Архивируем…',
err_zip: 'Не удалось создать архив',
card_stitch: 'Тип сшивки',
stitch_thread: 'Нитка',
stitch_ribbon: 'Ленточка',
stitch_corner: 'Уголок',
stitch_spiral: 'Пружинка',
stitch_punch: 'Дырокол',
stitch_none: 'Без сшивки',
card_extra: 'Дополнительно',
color_mode: 'Режим цвета',
color_color: 'Цветной',
color_bw: 'Ч/Б',
quality: 'Качество',
quality_high: 'Высокое',
quality_medium: 'Среднее',
quality_low: 'Низкое',
title_page: 'Титульный лист',
page_numbers: 'Нумерация страниц',
certified_page: 'Лист «прошито-пронумеровано»',
pdfa_format: 'Формат PDF/A',
ocr_label: 'Распознать текст (OCR)',
ocr_hint: 'Скан станет искомым: текст можно копировать и находить поиском',
cabinet_title: 'Мои документы',
cabinet_empty: 'Пока нет обработанных документов',
cabinet_dl: '⬇ Скачать',
cabinet_expired: 'истёк',
cabinet_processing: 'обработка',
folder_pick: '📁 Папкой',
auth_required: '🔐 Требуется авторизация',
auth_required_desc:'Для файлов больше 100 страниц или 50 МБ необходим вход через Telegram.',
login_tg: 'Войти через Telegram',
btn_select_file: 'Выберите файл',
btn_process: 'Обработать PDF',
btn_sending: 'Отправка...',
btn_processing: 'Обработка...',
btn_auth_required: 'Необходима авторизация',
progress_loading: 'Загрузка файла...',
progress_uploaded: 'Файл загружен, ожидание обработки...',
progress_processing:'Обработка...',
result_title: 'Готово!',
result_sub: 'Ваш PDF обработан и готов к скачиванию.',
result_timer: '⏱ Ссылка активна 30 минут — скачайте файл сейчас',
download_btn: '⬇ Скачать результат',
another_btn: 'Обработать ещё один файл',
footer_text: 'превращаем PDF в скан',
footer_tg: 'Telegram-бот',
footer_api: 'API для бизнеса',
footer_donate: '🍩 Поддержать',
err_no_file: 'Файл не выбран',
err_task_not_found:'Задача не найдена. Попробуйте отправить файл снова.',
err_network: 'Ошибка сети при загрузке файла',
err_timeout: 'Превышено время ожидания',
err_auth_start: 'Не удалось начать авторизацию.',
err_auth_expired: 'Время авторизации истекло. Попробуйте снова.',
err_auth_launch: 'Ошибка при запуске авторизации.',
err_processing: 'Произошла ошибка обработки.',
info_auth_waiting: 'Откройте Telegram-бота и нажмите Start — авторизация произойдёт автоматически...',
queue_text: (pos) => `В очереди (${pos} ${pluralizeRu(pos, 'задание', 'задания', 'заданий')})`,
upload_speed: (loaded, total, speed) => `${loaded} / ${total} МБ · ${speed} МБ/с`,
err_server: (code) => `Ошибка ${code}`,
donate_title: 'Поддержите проект',
donate_desc: 'Если сервис полезен — поддержите его работу донатом. Это помогает оплачивать сервер и развивать проект.',
donate_desc_heavy: '⚠️ Большие файлы сильно нагружают сервер. Если сервис полезен — поддержите проект донатом.',
donate_sbp: 'СБП / Ozon Pay',
donate_qr_hint: 'Сканируйте для оплаты',
pay_card_btn: '💳 Оплатить картой',
pay_email_ph: 'email для чека',
pay_need_email: 'Укажите корректный email для чека',
pay_bad_amount: 'Некорректная сумма',
pay_creating: 'Создаём платёж…',
pay_error: 'Не удалось создать платёж',
pay_thanks: '✅ Спасибо за поддержку! Оплата прошла.',
pay_thanks_file: 'Оплата прошла! Файл обрабатывается…',
pay_ok_btn: 'Отлично',
pay_canceled: 'Платёж отменён',
paywall_title: 'Файл больше бесплатного лимита',
paywall_desc: 'Бесплатно — до 50 МБ и 100 страниц. Для файлов больше — разовая оплата или подписка.',
paywall_pay_file: 'Оплатить файл',
paywall_pay_sub: 'Подписка',
paywall_month: 'мес',
paywall_cancel: 'Отмена',
paywall_need_login: 'Для подписки войдите в аккаунт',
seo_title: 'Как работает pdf2scan',
seo_step1_title: 'Загрузите файл',
seo_step1_desc: 'PDF или Word — перетащите или выберите с устройства',
seo_step2_title: 'Настройте параметры',
seo_step2_desc: 'Тип сшивки, цвет, качество, нумерация и другие опции',
seo_step3_title: 'Скачайте результат',
seo_step3_desc: 'Готовый PDF со сканом доступен для скачивания 30 минут',
seo_about: 'pdf2scan — бесплатный онлайн-инструмент для создания растрового PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Идеально для подготовки документов к сдаче в архив, нотариусу или государственные органы. Без регистрации, без установки программ.',
},
en: {
page_title: 'pdf2scan — online PDF scan',
logo_subtitle: 'Convert PDF to scanned document online',
login_btn: 'Login',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
login_via_email: 'Via email',
email_login_title: 'Email login',
email_ph: 'your email',
email_send_code: 'Get code',
email_code_sent: 'Code sent to your email. Enter it:',
email_login_btn: 'Log in',
email_sending: 'Sending code…',
email_bad_code: 'Invalid code',
err_login: 'Login failed. Please try again.',
logout_btn: 'Logout',
page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.',
card_file: 'File',
drop_title: 'Select PDF or Word',
drop_hint: 'or drag and drop files here (up to 20)',
file_selected: 'File selected',
batch_files: 'Files',
files_selected: (n) => `${n} ${n === 1 ? 'file' : 'files'} selected`,
btn_process_n: (n) => `Process ${n} ${n === 1 ? 'file' : 'files'}`,
err_too_many: (max) => `Too many files — maximum ${max}. Extra files were not added.`,
st_uploading: 'Uploading…',
st_queued: 'Queued',
st_processing: 'Processing…',
st_done: 'Ready',
st_error: 'Error',
download_all_zip: '⬇ Download all as ZIP',
another_btn_batch: 'Process more files',
zip_preparing: 'Zipping…',
err_zip: 'Failed to create archive',
card_stitch: 'Binding type',
stitch_thread: 'Thread',
stitch_ribbon: 'Ribbon',
stitch_corner: 'Corner',
stitch_spiral: 'Spiral',
stitch_punch: 'Hole punch',
stitch_none: 'No binding',
card_extra: 'Options',
color_mode: 'Color mode',
color_color: 'Color',
color_bw: 'B&W',
quality: 'Quality',
quality_high: 'High',
quality_medium: 'Medium',
quality_low: 'Low',
title_page: 'Title page',
page_numbers: 'Page numbers',
certified_page: 'Certification page',
pdfa_format: 'PDF/A format',
ocr_label: 'Recognize text (OCR)',
ocr_hint: 'The scan becomes searchable — copy and find text',
cabinet_title: 'My documents',
cabinet_empty: 'No processed documents yet',
cabinet_dl: '⬇ Download',
cabinet_expired: 'expired',
cabinet_processing: 'processing',
folder_pick: '📁 Folder',
auth_required: '🔐 Authorization required',
auth_required_desc:'Files with more than 100 pages or 50 MB require Telegram login.',
login_tg: 'Login with Telegram',
btn_select_file: 'Select file',
btn_process: 'Process PDF',
btn_sending: 'Uploading...',
btn_processing: 'Processing...',
btn_auth_required: 'Authorization required',
progress_loading: 'Uploading file...',
progress_uploaded: 'File uploaded, waiting for processing...',
progress_processing:'Processing...',
result_title: 'Done!',
result_sub: 'Your PDF has been processed and is ready to download.',
result_timer: '⏱ Link active for 30 minutes — download now',
download_btn: '⬇ Download result',
another_btn: 'Process another file',
footer_text: 'convert PDF to scan',
footer_tg: 'Telegram bot',
footer_api: 'API for business',
footer_donate: '🍩 Support',
err_no_file: 'No file selected',
err_task_not_found:'Task not found. Please try submitting the file again.',
err_network: 'Network error while uploading',
err_timeout: 'Request timed out',
err_auth_start: 'Failed to start authorization.',
err_auth_expired: 'Authorization expired. Please try again.',
err_auth_launch: 'Error launching authorization.',
err_processing: 'An error occurred during processing.',
info_auth_waiting: 'Open the Telegram bot and press Start — authorization will happen automatically...',
queue_text: (pos) => `In queue (position ${pos})`,
upload_speed: (loaded, total, speed) => `${loaded} / ${total} MB · ${speed} MB/s`,
err_server: (code) => `Error ${code}`,
donate_title: 'Support the project',
donate_desc: 'If this service is useful to you, please consider supporting it with a donation.',
donate_desc_heavy: '⚠️ Large files put heavy load on the server. If the service is useful, please support the project.',
donate_sbp: 'SBP / Ozon Pay',
donate_yoo: 'YooMoney',
donate_qr_hint: 'Scan to pay',
pay_card_btn: '💳 Pay by card',
pay_email_ph: 'email for receipt',
pay_need_email: 'Enter a valid email for the receipt',
pay_bad_amount: 'Invalid amount',
pay_creating: 'Creating payment…',
pay_error: 'Failed to create payment',
pay_thanks: '✅ Thank you for your support! Payment received.',
pay_thanks_file: 'Payment received! Your file is processing…',
pay_ok_btn: 'Great',
pay_canceled: 'Payment canceled',
paywall_title: 'File exceeds the free limit',
paywall_desc: 'Free up to 50 MB and 100 pages. Larger files — one-time payment or subscription.',
paywall_pay_file: 'Pay for file',
paywall_pay_sub: 'Subscription',
paywall_month: 'mo',
paywall_cancel: 'Cancel',
paywall_need_login: 'Log in to subscribe',
seo_title: 'How pdf2scan works',
seo_step1_title: 'Upload your file',
seo_step1_desc: 'PDF or Word — drag & drop or choose from device',
seo_step2_title: 'Configure options',
seo_step2_desc: 'Binding type, color, quality, page numbers and more',
seo_step3_title: 'Download the result',
seo_step3_desc: 'Your scanned PDF is ready to download for 30 minutes',
seo_about: 'pdf2scan is a free online tool to create raster PDFs with a scan effect. Supports thread, ribbon, corner, spiral and hole-punch binding. Perfect for preparing documents for archives, notaries or government agencies. No registration, no software required.',
},
de: {
page_title: 'pdf2scan — Online-PDF-Scan',
logo_subtitle: 'PDF online in gescannte Dokumente umwandeln',
login_btn: 'Anmelden',
login_via_tg: 'Über Telegram',
login_via_yandex: 'Über Yandex',
login_via_vk: 'Über VK',
login_via_email: 'Per E-Mail',
email_login_title: 'E-Mail-Anmeldung',
email_ph: 'Ihre E-Mail',
email_send_code: 'Code erhalten',
email_code_sent: 'Code an Ihre E-Mail gesendet. Eingeben:',
email_login_btn: 'Anmelden',
email_sending: 'Code wird gesendet…',
email_bad_code: 'Ungültiger Code',
err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
logout_btn: 'Abmelden',
page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.',
card_file: 'Datei',
drop_title: 'PDF oder Word auswählen',
drop_hint: 'oder Dateien hier ablegen (bis zu 20)',
file_selected: 'Datei ausgewählt',
batch_files: 'Dateien',
files_selected: (n) => `${n} ${n === 1 ? 'Datei' : 'Dateien'} ausgewählt`,
btn_process_n: (n) => `${n} ${n === 1 ? 'Datei' : 'Dateien'} verarbeiten`,
err_too_many: (max) => `Zu viele Dateien — maximal ${max}. Überzählige wurden nicht hinzugefügt.`,
st_uploading: 'Hochladen…',
st_queued: 'In Warteschlange',
st_processing: 'Verarbeitung…',
st_done: 'Fertig',
st_error: 'Fehler',
download_all_zip: '⬇ Alle als ZIP herunterladen',
another_btn_batch: 'Weitere Dateien verarbeiten',
zip_preparing: 'Archivierung…',
err_zip: 'Archiv konnte nicht erstellt werden',
card_stitch: 'Bindungstyp',
stitch_thread: 'Faden',
stitch_ribbon: 'Band',
stitch_corner: 'Ecke',
stitch_spiral: 'Spirale',
stitch_punch: 'Lochstanzer',
stitch_none: 'Ohne Bindung',
card_extra: 'Optionen',
color_mode: 'Farbmodus',
color_color: 'Farbe',
color_bw: 'S/W',
quality: 'Qualität',
quality_high: 'Hoch',
quality_medium: 'Mittel',
quality_low: 'Niedrig',
title_page: 'Titelseite',
page_numbers: 'Seitennummerierung',
certified_page: 'Zertifizierungsseite',
pdfa_format: 'PDF/A-Format',
ocr_label: 'Text erkennen (OCR)',
ocr_hint: 'Der Scan wird durchsuchbar — Text kopieren und finden',
cabinet_title: 'Meine Dokumente',
cabinet_empty: 'Noch keine verarbeiteten Dokumente',
cabinet_dl: '⬇ Herunterladen',
cabinet_expired: 'abgelaufen',
cabinet_processing: 'Verarbeitung',
folder_pick: '📁 Ordner',
auth_required: '🔐 Anmeldung erforderlich',
auth_required_desc:'Dateien mit mehr als 100 Seiten oder 50 MB erfordern eine Telegram-Anmeldung.',
login_tg: 'Mit Telegram anmelden',
btn_select_file: 'Datei auswählen',
btn_process: 'PDF verarbeiten',
btn_sending: 'Hochladen...',
btn_processing: 'Verarbeitung...',
btn_auth_required: 'Anmeldung erforderlich',
progress_loading: 'Datei wird hochgeladen...',
progress_uploaded: 'Datei hochgeladen, warte auf Verarbeitung...',
progress_processing:'Verarbeitung...',
result_title: 'Fertig!',
result_sub: 'Ihr PDF wurde verarbeitet und ist zum Download bereit.',
result_timer: '⏱ Link 30 Minuten aktiv — jetzt herunterladen',
download_btn: '⬇ Ergebnis herunterladen',
another_btn: 'Eine weitere Datei verarbeiten',
footer_text: 'PDF in Scan umwandeln',
footer_tg: 'Telegram-Bot',
footer_api: 'API für Unternehmen',
footer_donate: '🍩 Unterstützen',
err_no_file: 'Keine Datei ausgewählt',
err_task_not_found:'Aufgabe nicht gefunden. Bitte versuchen Sie, die Datei erneut zu senden.',
err_network: 'Netzwerkfehler beim Hochladen',
err_timeout: 'Zeitüberschreitung',
err_auth_start: 'Autorisierung konnte nicht gestartet werden.',
err_auth_expired: 'Autorisierung abgelaufen. Bitte erneut versuchen.',
err_auth_launch: 'Fehler beim Starten der Autorisierung.',
err_processing: 'Bei der Verarbeitung ist ein Fehler aufgetreten.',
info_auth_waiting: 'Öffnen Sie den Telegram-Bot und drücken Sie Start — die Autorisierung erfolgt automatisch...',
queue_text: (pos) => `In der Warteschlange (Position ${pos})`,
upload_speed: (loaded, total, speed) => `${loaded} / ${total} MB · ${speed} MB/s`,
err_server: (code) => `Fehler ${code}`,
donate_title: 'Projekt unterstützen',
donate_desc: 'Wenn dieser Dienst nützlich ist, unterstützen Sie ihn bitte mit einer Spende.',
donate_desc_heavy: '⚠️ Große Dateien belasten den Server stark. Wenn der Dienst nützlich ist, unterstützen Sie das Projekt.',
donate_sbp: 'SBP / Ozon Pay',
donate_yoo: 'YooMoney',
donate_qr_hint: 'Zum Bezahlen scannen',
pay_card_btn: '💳 Mit Karte zahlen',
pay_email_ph: 'E-Mail für Beleg',
pay_need_email: 'Gültige E-Mail für den Beleg angeben',
pay_bad_amount: 'Ungültiger Betrag',
pay_creating: 'Zahlung wird erstellt…',
pay_error: 'Zahlung konnte nicht erstellt werden',
pay_thanks: '✅ Danke für die Unterstützung! Zahlung erhalten.',
pay_thanks_file: 'Zahlung erhalten! Datei wird verarbeitet…',
pay_ok_btn: 'Super',
pay_canceled: 'Zahlung storniert',
paywall_title: 'Datei über dem Gratis-Limit',
paywall_desc: 'Kostenlos bis 50 MB und 100 Seiten. Größere Dateien — Einmalzahlung oder Abo.',
paywall_pay_file: 'Datei bezahlen',
paywall_pay_sub: 'Abo',
paywall_month: 'Mon',
paywall_cancel: 'Abbrechen',
paywall_need_login: 'Zum Abonnieren anmelden',
seo_title: 'So funktioniert pdf2scan',
seo_step1_title: 'Datei hochladen',
seo_step1_desc: 'PDF oder Word — per Drag & Drop oder vom Gerät',
seo_step2_title: 'Optionen einstellen',
seo_step2_desc: 'Bindungstyp, Farbe, Qualität, Seitennummern und mehr',
seo_step3_title: 'Ergebnis herunterladen',
seo_step3_desc: 'Ihr gescanntes PDF steht 30 Minuten zum Download bereit',
seo_about: 'pdf2scan ist ein kostenloses Online-Tool zur Erstellung von Raster-PDFs mit Scan-Effekt. Unterstützt Faden-, Band-, Ecken-, Spiral- und Lochbindung. Perfekt für die Vorbereitung von Dokumenten für Archive, Notare oder Behörden. Keine Registrierung, keine Software erforderlich.',
},
fr: {
page_title: 'pdf2scan — scan PDF en ligne',
logo_subtitle: 'Convertir des PDF en documents scannés en ligne',
login_btn: 'Connexion',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
login_via_email: 'Par e-mail',
email_login_title: 'Connexion par e-mail',
email_ph: 'votre e-mail',
email_send_code: 'Recevoir le code',
email_code_sent: 'Code envoyé par e-mail. Saisissez-le :',
email_login_btn: 'Se connecter',
email_sending: 'Envoi du code…',
email_bad_code: 'Code invalide',
err_login: 'Échec de connexion. Réessayez.',
logout_btn: 'Déconnexion',
page_description: '🧷 Sélectionnez le type de reliure et les paramètres optionnels, puis téléchargez votre fichier — obtenez un scan prêt à l\'emploi.',
card_file: 'Fichier',
drop_title: 'Sélectionner PDF ou Word',
drop_hint: 'ou déposez les fichiers ici (max. 20)',
file_selected: 'Fichier sélectionné',
batch_files: 'Fichiers',
files_selected: (n) => `${n} fichier${n === 1 ? '' : 's'} sélectionné${n === 1 ? '' : 's'}`,
btn_process_n: (n) => `Traiter ${n} fichier${n === 1 ? '' : 's'}`,
err_too_many: (max) => `Trop de fichiers — maximum ${max}. Les fichiers en trop nont pas été ajoutés.`,
st_uploading: 'Téléversement…',
st_queued: 'En file',
st_processing: 'Traitement…',
st_done: 'Prêt',
st_error: 'Erreur',
download_all_zip: '⬇ Tout télécharger en ZIP',
another_btn_batch: 'Traiter dautres fichiers',
zip_preparing: 'Archivage…',
err_zip: 'Échec de création de larchive',
card_stitch: 'Type de reliure',
stitch_thread: 'Fil',
stitch_ribbon: 'Ruban',
stitch_corner: 'Coin',
stitch_spiral: 'Spirale',
stitch_punch: 'Perforateur',
stitch_none: 'Sans reliure',
card_extra: 'Options',
color_mode: 'Mode couleur',
color_color: 'Couleur',
color_bw: 'N/B',
quality: 'Qualité',
quality_high: 'Haute',
quality_medium: 'Moyenne',
quality_low: 'Basse',
title_page: 'Page de titre',
page_numbers: 'Numérotation des pages',
certified_page: 'Page de certification',
pdfa_format: 'Format PDF/A',
ocr_label: 'Reconnaître le texte (OCR)',
ocr_hint: 'Le scan devient consultable — copier et rechercher le texte',
cabinet_title: 'Mes documents',
cabinet_empty: 'Aucun document traité',
cabinet_dl: '⬇ Télécharger',
cabinet_expired: 'expiré',
cabinet_processing: 'traitement',
folder_pick: '📁 Dossier',
auth_required: '🔐 Autorisation requise',
auth_required_desc:'Les fichiers de plus de 100 pages ou 50 Mo nécessitent une connexion Telegram.',
login_tg: 'Se connecter via Telegram',
btn_select_file: 'Sélectionner un fichier',
btn_process: 'Traiter le PDF',
btn_sending: 'Envoi...',
btn_processing: 'Traitement...',
btn_auth_required: 'Autorisation requise',
progress_loading: 'Téléchargement du fichier...',
progress_uploaded: 'Fichier téléchargé, en attente de traitement...',
progress_processing:'Traitement...',
result_title: 'Terminé!',
result_sub: 'Votre PDF a été traité et est prêt à être téléchargé.',
result_timer: '⏱ Lien actif pendant 30 minutes — téléchargez maintenant',
download_btn: '⬇ Télécharger le résultat',
another_btn: 'Traiter un autre fichier',
footer_text: 'convertir PDF en scan',
footer_tg: 'Bot Telegram',
footer_api: 'API pour entreprises',
footer_donate: '🍩 Soutenir',
err_no_file: 'Aucun fichier sélectionné',
err_task_not_found:'Tâche introuvable. Veuillez soumettre le fichier à nouveau.',
err_network: 'Erreur réseau lors du téléchargement',
err_timeout: 'Délai d\'attente dépassé',
err_auth_start: 'Impossible de démarrer l\'autorisation.',
err_auth_expired: 'Autorisation expirée. Veuillez réessayer.',
err_auth_launch: 'Erreur lors du lancement de l\'autorisation.',
err_processing: 'Une erreur s\'est produite lors du traitement.',
info_auth_waiting: 'Ouvrez le bot Telegram et appuyez sur Démarrer — l\'autorisation se fera automatiquement...',
queue_text: (pos) => `En attente (position ${pos})`,
upload_speed: (loaded, total, speed) => `${loaded} / ${total} Mo · ${speed} Mo/s`,
err_server: (code) => `Erreur ${code}`,
donate_title: 'Soutenir le projet',
donate_desc: 'Si ce service vous est utile, soutenez-le avec un don.',
donate_desc_heavy: '⚠️ Les gros fichiers sollicitent fortement le serveur. Si le service est utile, soutenez le projet.',
donate_sbp: 'SBP / Ozon Pay',
donate_yoo: 'YooMoney',
donate_qr_hint: 'Scannez pour payer',
pay_card_btn: '💳 Payer par carte',
pay_email_ph: 'email pour le reçu',
pay_need_email: 'Saisissez un email valide pour le reçu',
pay_bad_amount: 'Montant invalide',
pay_creating: 'Création du paiement…',
pay_error: 'Échec de création du paiement',
pay_thanks: '✅ Merci pour votre soutien ! Paiement reçu.',
pay_thanks_file: 'Paiement reçu ! Le fichier est en traitement…',
pay_ok_btn: 'Parfait',
pay_canceled: 'Paiement annulé',
paywall_title: 'Fichier au-delà de la limite gratuite',
paywall_desc: 'Gratuit : 50 Mo et 100 pages max. Au-delà — paiement unique ou abonnement.',
paywall_pay_file: 'Payer le fichier',
paywall_pay_sub: 'Abonnement',
paywall_month: 'mois',
paywall_cancel: 'Annuler',
paywall_need_login: 'Connectez-vous pour vous abonner',
seo_title: 'Comment fonctionne pdf2scan',
seo_step1_title: 'Téléchargez votre fichier',
seo_step1_desc: 'PDF ou Word — glisser-déposer ou choisir depuis l\'appareil',
seo_step2_title: 'Configurez les options',
seo_step2_desc: 'Type de reliure, couleur, qualité, numérotation et plus',
seo_step3_title: 'Téléchargez le résultat',
seo_step3_desc: 'Votre PDF scanné est disponible 30 minutes au téléchargement',
seo_about: 'pdf2scan est un outil en ligne gratuit pour créer des PDF raster avec effet de scan. Supporte la reliure par fil, ruban, coin, spirale et perforateur. Idéal pour préparer des documents pour les archives, notaires ou administrations. Sans inscription, sans logiciel.',
},
it: {
page_title: 'pdf2scan — scansione PDF online',
logo_subtitle: 'Converti PDF in documenti scansionati online',
login_btn: 'Accedi',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
login_via_email: 'Via email',
email_login_title: 'Accesso via email',
email_ph: 'la tua email',
email_send_code: 'Ricevi codice',
email_code_sent: 'Codice inviato via email. Inseriscilo:',
email_login_btn: 'Accedi',
email_sending: 'Invio codice…',
email_bad_code: 'Codice non valido',
err_login: 'Accesso non riuscito. Riprova.',
logout_btn: 'Esci',
page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.',
card_file: 'File',
drop_title: 'Seleziona PDF o Word',
drop_hint: 'o trascina i file qui (fino a 20)',
file_selected: 'File selezionato',
batch_files: 'File',
files_selected: (n) => `${n} file selezionat${n === 1 ? 'o' : 'i'}`,
btn_process_n: (n) => `Elabora ${n} file`,
err_too_many: (max) => `Troppi file — massimo ${max}. I file in eccesso non sono stati aggiunti.`,
st_uploading: 'Caricamento…',
st_queued: 'In coda',
st_processing: 'Elaborazione…',
st_done: 'Pronto',
st_error: 'Errore',
download_all_zip: '⬇ Scarica tutto in ZIP',
another_btn_batch: 'Elabora altri file',
zip_preparing: 'Compressione…',
err_zip: 'Impossibile creare larchivio',
card_stitch: 'Tipo di rilegatura',
stitch_thread: 'Filo',
stitch_ribbon: 'Nastro',
stitch_corner: 'Angolo',
stitch_spiral: 'Spirale',
stitch_punch: 'Perforatore',
stitch_none: 'Senza rilegatura',
card_extra: 'Opzioni',
color_mode: 'Modalità colore',
color_color: 'Colore',
color_bw: 'B/N',
quality: 'Qualità',
quality_high: 'Alta',
quality_medium: 'Media',
quality_low: 'Bassa',
title_page: 'Pagina del titolo',
page_numbers: 'Numerazione pagine',
certified_page: 'Pagina di certificazione',
pdfa_format: 'Formato PDF/A',
ocr_label: 'Riconosci testo (OCR)',
ocr_hint: 'La scansione diventa ricercabile — copia e cerca il testo',
cabinet_title: 'I miei documenti',
cabinet_empty: 'Nessun documento elaborato',
cabinet_dl: '⬇ Scarica',
cabinet_expired: 'scaduto',
cabinet_processing: 'elaborazione',
folder_pick: '📁 Cartella',
auth_required: '🔐 Autorizzazione richiesta',
auth_required_desc:'I file con più di 100 pagine o 50 MB richiedono il login Telegram.',
login_tg: 'Accedi con Telegram',
btn_select_file: 'Seleziona file',
btn_process: 'Elabora PDF',
btn_sending: 'Caricamento...',
btn_processing: 'Elaborazione...',
btn_auth_required: 'Autorizzazione richiesta',
progress_loading: 'Caricamento file...',
progress_uploaded: 'File caricato, in attesa di elaborazione...',
progress_processing:'Elaborazione...',
result_title: 'Pronto!',
result_sub: 'Il tuo PDF è stato elaborato ed è pronto per il download.',
result_timer: '⏱ Link attivo per 30 minuti — scarica ora',
download_btn: '⬇ Scarica il risultato',
another_btn: 'Elabora un altro file',
footer_text: 'converti PDF in scansione',
footer_tg: 'Bot Telegram',
footer_api: 'API per aziende',
footer_donate: '🍩 Sostieni',
err_no_file: 'Nessun file selezionato',
err_task_not_found:'Attività non trovata. Prova a inviare nuovamente il file.',
err_network: 'Errore di rete durante il caricamento',
err_timeout: 'Timeout della richiesta',
err_auth_start: 'Impossibile avviare l\'autorizzazione.',
err_auth_expired: 'Autorizzazione scaduta. Riprova.',
err_auth_launch: 'Errore nell\'avvio dell\'autorizzazione.',
err_processing: 'Si è verificato un errore durante l\'elaborazione.',
info_auth_waiting: 'Apri il bot Telegram e premi Start — l\'autorizzazione avverrà automaticamente...',
queue_text: (pos) => `In coda (posizione ${pos})`,
upload_speed: (loaded, total, speed) => `${loaded} / ${total} MB · ${speed} MB/s`,
err_server: (code) => `Errore ${code}`,
donate_title: 'Sostieni il progetto',
donate_desc: 'Se questo servizio ti è utile, considera di supportarlo con una donazione.',
donate_desc_heavy: '⚠️ I file grandi caricano molto il server. Se il servizio è utile, sostieni il progetto.',
donate_sbp: 'SBP / Ozon Pay',
donate_yoo: 'YooMoney',
donate_qr_hint: 'Scansiona per pagare',
pay_card_btn: '💳 Paga con carta',
pay_email_ph: 'email per ricevuta',
pay_need_email: 'Inserisci un email valido per la ricevuta',
pay_bad_amount: 'Importo non valido',
pay_creating: 'Creazione pagamento…',
pay_error: 'Impossibile creare il pagamento',
pay_thanks: '✅ Grazie per il supporto! Pagamento ricevuto.',
pay_thanks_file: 'Pagamento ricevuto! Il file è in elaborazione…',
pay_ok_btn: 'Ottimo',
pay_canceled: 'Pagamento annullato',
paywall_title: 'File oltre il limite gratuito',
paywall_desc: 'Gratis fino a 50 MB e 100 pagine. File più grandi — pagamento singolo o abbonamento.',
paywall_pay_file: 'Paga file',
paywall_pay_sub: 'Abbonamento',
paywall_month: 'mese',
paywall_cancel: 'Annulla',
paywall_need_login: 'Accedi per abbonarti',
seo_title: 'Come funziona pdf2scan',
seo_step1_title: 'Carica il file',
seo_step1_desc: 'PDF o Word — trascina o scegli dal dispositivo',
seo_step2_title: 'Configura le opzioni',
seo_step2_desc: 'Tipo di rilegatura, colore, qualità, numerazione e altro',
seo_step3_title: 'Scarica il risultato',
seo_step3_desc: 'Il tuo PDF scansionato è disponibile per 30 minuti',
seo_about: 'pdf2scan è uno strumento online gratuito per creare PDF raster con effetto scansione. Supporta rilegatura con filo, nastro, angolo, spirale e perforatore. Ideale per preparare documenti per archivi, notai o enti pubblici. Senza registrazione, senza software.',
},
es: {
page_title: 'pdf2scan — escaneo PDF en línea',
logo_subtitle: 'Convierte PDF en documentos escaneados en línea',
login_btn: 'Iniciar sesión',
login_via_tg: 'Con Telegram',
login_via_yandex: 'Con Yandex',
login_via_vk: 'Con VK',
login_via_email: 'Por correo',
email_login_title: 'Acceso por correo',
email_ph: 'tu correo',
email_send_code: 'Obtener código',
email_code_sent: 'Código enviado a tu correo. Ingrésalo:',
email_login_btn: 'Entrar',
email_sending: 'Enviando código…',
email_bad_code: 'Código inválido',
err_login: 'Error de inicio de sesión. Inténtalo de nuevo.',
logout_btn: 'Cerrar sesión',
page_description: '🧷 Selecciona el tipo de encuadernación y configuraciones opcionales, luego sube tu archivo — obtén un escaneo listo.',
card_file: 'Archivo',
drop_title: 'Seleccionar PDF o Word',
drop_hint: 'o arrastra los archivos aquí (hasta 20)',
file_selected: 'Archivo seleccionado',
batch_files: 'Archivos',
files_selected: (n) => `${n} archivo${n === 1 ? '' : 's'} seleccionado${n === 1 ? '' : 's'}`,
btn_process_n: (n) => `Procesar ${n} archivo${n === 1 ? '' : 's'}`,
err_too_many: (max) => `Demasiados archivos — máximo ${max}. Los archivos sobrantes no se añadieron.`,
st_uploading: 'Subiendo…',
st_queued: 'En cola',
st_processing: 'Procesando…',
st_done: 'Listo',
st_error: 'Error',
download_all_zip: '⬇ Descargar todo en ZIP',
another_btn_batch: 'Procesar más archivos',
zip_preparing: 'Comprimiendo…',
err_zip: 'No se pudo crear el archivo',
card_stitch: 'Tipo de encuadernación',
stitch_thread: 'Hilo',
stitch_ribbon: 'Cinta',
stitch_corner: 'Esquina',
stitch_spiral: 'Espiral',
stitch_punch: 'Perforadora',
stitch_none: 'Sin encuadernación',
card_extra: 'Opciones',
color_mode: 'Modo de color',
color_color: 'Color',
color_bw: 'B/N',
quality: 'Calidad',
quality_high: 'Alta',
quality_medium: 'Media',
quality_low: 'Baja',
title_page: 'Página de título',
page_numbers: 'Numeración de páginas',
certified_page: 'Página de certificación',
pdfa_format: 'Formato PDF/A',
ocr_label: 'Reconocer texto (OCR)',
ocr_hint: 'El escaneo se vuelve buscable — copia y busca texto',
cabinet_title: 'Mis documentos',
cabinet_empty: 'Aún no hay documentos procesados',
cabinet_dl: '⬇ Descargar',
cabinet_expired: 'expirado',
cabinet_processing: 'procesando',
folder_pick: '📁 Carpeta',
auth_required: '🔐 Autorización requerida',
auth_required_desc:'Los archivos con más de 100 páginas o 50 MB requieren inicio de sesión en Telegram.',
login_tg: 'Iniciar sesión con Telegram',
btn_select_file: 'Seleccionar archivo',
btn_process: 'Procesar PDF',
btn_sending: 'Enviando...',
btn_processing: 'Procesando...',
btn_auth_required: 'Autorización requerida',
progress_loading: 'Subiendo archivo...',
progress_uploaded: 'Archivo subido, esperando procesamiento...',
progress_processing:'Procesando...',
result_title: '¡Listo!',
result_sub: 'Tu PDF ha sido procesado y está listo para descargar.',
result_timer: '⏱ Enlace activo por 30 minutos — descarga ahora',
download_btn: '⬇ Descargar resultado',
another_btn: 'Procesar otro archivo',
footer_text: 'convierte PDF en escaneo',
footer_tg: 'Bot de Telegram',
footer_api: 'API para empresas',
footer_donate: '🍩 Apoyar',
err_no_file: 'Ningún archivo seleccionado',
err_task_not_found:'Tarea no encontrada. Intenta enviar el archivo de nuevo.',
err_network: 'Error de red al subir',
err_timeout: 'Tiempo de espera agotado',
err_auth_start: 'No se pudo iniciar la autorización.',
err_auth_expired: 'Autorización expirada. Inténtalo de nuevo.',
err_auth_launch: 'Error al iniciar la autorización.',
err_processing: 'Se produjo un error durante el procesamiento.',
info_auth_waiting: 'Abre el bot de Telegram y pulsa Iniciar — la autorización ocurrirá automáticamente...',
queue_text: (pos) => `En cola (posición ${pos})`,
upload_speed: (loaded, total, speed) => `${loaded} / ${total} MB · ${speed} MB/s`,
err_server: (code) => `Error ${code}`,
donate_title: 'Apoya el proyecto',
donate_desc: 'Si este servicio te es útil, considera apoyarlo con una donación.',
donate_desc_heavy: '⚠️ Los archivos grandes sobrecargan el servidor. Si el servicio es útil, apoya el proyecto.',
donate_sbp: 'SBP / Ozon Pay',
donate_yoo: 'YooMoney',
donate_qr_hint: 'Escanea para pagar',
pay_card_btn: '💳 Pagar con tarjeta',
pay_email_ph: 'email para recibo',
pay_need_email: 'Ingresa un email válido para el recibo',
pay_bad_amount: 'Importe inválido',
pay_creating: 'Creando pago…',
pay_error: 'No se pudo crear el pago',
pay_thanks: '✅ ¡Gracias por tu apoyo! Pago recibido.',
pay_thanks_file: 'Pago recibido! El archivo se está procesando…',
pay_ok_btn: 'Genial',
pay_canceled: 'Pago cancelado',
paywall_title: 'Archivo supera el límite gratis',
paywall_desc: 'Gratis hasta 50 MB y 100 páginas. Archivos mayores — pago único o suscripción.',
paywall_pay_file: 'Pagar archivo',
paywall_pay_sub: 'Suscripción',
paywall_month: 'mes',
paywall_cancel: 'Cancelar',
paywall_need_login: 'Inicia sesión para suscribirte',
seo_title: 'Cómo funciona pdf2scan',
seo_step1_title: 'Sube tu archivo',
seo_step1_desc: 'PDF o Word — arrastra o elige desde el dispositivo',
seo_step2_title: 'Configura las opciones',
seo_step2_desc: 'Tipo de encuadernación, color, calidad, numeración y más',
seo_step3_title: 'Descarga el resultado',
seo_step3_desc: 'Tu PDF escaneado está disponible 30 minutos para descargar',
seo_about: 'pdf2scan es una herramienta online gratuita para crear PDFs rasterizados con efecto de escáner. Soporta encuadernación con hilo, cinta, esquina, espiral y perforadora. Ideal para preparar documentos para archivos, notarios o administraciones. Sin registro, sin software.',
},
pt: {
page_title: 'pdf2scan — digitalização PDF online',
logo_subtitle: 'Converta PDF em documentos digitalizados online',
login_btn: 'Entrar',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
login_via_email: 'Por e-mail',
email_login_title: 'Entrar por e-mail',
email_ph: 'seu e-mail',
email_send_code: 'Receber código',
email_code_sent: 'Código enviado ao seu e-mail. Digite:',
email_login_btn: 'Entrar',
email_sending: 'Enviando código…',
email_bad_code: 'Código inválido',
err_login: 'Falha no login. Tente novamente.',
logout_btn: 'Sair',
page_description: '🧷 Selecione o tipo de encadernação e configurações opcionais, depois faça upload do arquivo — obtenha uma digitalização pronta.',
card_file: 'Arquivo',
drop_title: 'Selecionar PDF ou Word',
drop_hint: 'ou arraste os arquivos aqui (até 20)',
file_selected: 'Arquivo selecionado',
batch_files: 'Arquivos',
files_selected: (n) => `${n} arquivo${n === 1 ? '' : 's'} selecionado${n === 1 ? '' : 's'}`,
btn_process_n: (n) => `Processar ${n} arquivo${n === 1 ? '' : 's'}`,
err_too_many: (max) => `Arquivos demais — máximo ${max}. Os arquivos extras não foram adicionados.`,
st_uploading: 'Enviando…',
st_queued: 'Na fila',
st_processing: 'Processando…',
st_done: 'Pronto',
st_error: 'Erro',
download_all_zip: '⬇ Baixar tudo em ZIP',
another_btn_batch: 'Processar mais arquivos',
zip_preparing: 'Compactando…',
err_zip: 'Não foi possível criar o arquivo',
card_stitch: 'Tipo de encadernação',
stitch_thread: 'Fio',
stitch_ribbon: 'Fita',
stitch_corner: 'Canto',
stitch_spiral: 'Espiral',
stitch_punch: 'Furador',
stitch_none: 'Sem encadernação',
card_extra: 'Opções',
color_mode: 'Modo de cor',
color_color: 'Colorido',
color_bw: 'P/B',
quality: 'Qualidade',
quality_high: 'Alta',
quality_medium: 'Média',
quality_low: 'Baixa',
title_page: 'Página de título',
page_numbers: 'Numeração de páginas',
certified_page: 'Página de certificação',
pdfa_format: 'Formato PDF/A',
ocr_label: 'Reconhecer texto (OCR)',
ocr_hint: 'A digitalização fica pesquisável — copie e encontre texto',
cabinet_title: 'Meus documentos',
cabinet_empty: 'Ainda não há documentos processados',
cabinet_dl: '⬇ Baixar',
cabinet_expired: 'expirado',
cabinet_processing: 'processando',
folder_pick: '📁 Pasta',
auth_required: '🔐 Autorização necessária',
auth_required_desc:'Arquivos com mais de 100 páginas ou 50 MB requerem login no Telegram.',
login_tg: 'Entrar com Telegram',
btn_select_file: 'Selecionar arquivo',
btn_process: 'Processar PDF',
btn_sending: 'Enviando...',
btn_processing: 'Processando...',
btn_auth_required: 'Autorização necessária',
progress_loading: 'Fazendo upload...',
progress_uploaded: 'Arquivo enviado, aguardando processamento...',
progress_processing:'Processando...',
result_title: 'Pronto!',
result_sub: 'Seu PDF foi processado e está pronto para download.',
result_timer: '⏱ Link ativo por 30 minutos — baixe agora',
download_btn: '⬇ Baixar resultado',
another_btn: 'Processar outro arquivo',
footer_text: 'converta PDF em digitalização',
footer_tg: 'Bot do Telegram',
footer_api: 'API para empresas',
footer_donate: '🍩 Apoiar',
err_no_file: 'Nenhum arquivo selecionado',
err_task_not_found:'Tarefa não encontrada. Tente enviar o arquivo novamente.',
err_network: 'Erro de rede ao fazer upload',
err_timeout: 'Tempo limite esgotado',
err_auth_start: 'Não foi possível iniciar a autorização.',
err_auth_expired: 'Autorização expirada. Tente novamente.',
err_auth_launch: 'Erro ao iniciar a autorização.',
err_processing: 'Ocorreu um erro durante o processamento.',
info_auth_waiting: 'Abra o bot do Telegram e pressione Iniciar — a autorização ocorrerá automaticamente...',
queue_text: (pos) => `Na fila (posição ${pos})`,
upload_speed: (loaded, total, speed) => `${loaded} / ${total} MB · ${speed} MB/s`,
err_server: (code) => `Erro ${code}`,
donate_title: 'Apoie o projeto',
donate_desc: 'Se este serviço é útil para você, considere apoiá-lo com uma doação.',
donate_desc_heavy: '⚠️ Arquivos grandes sobrecarregam o servidor. Se o serviço é útil, apoie o projeto.',
donate_sbp: 'SBP / Ozon Pay',
donate_yoo: 'YooMoney',
donate_qr_hint: 'Escaneie para pagar',
pay_card_btn: '💳 Pagar com cartão',
pay_email_ph: 'email para recibo',
pay_need_email: 'Informe um email válido para o recibo',
pay_bad_amount: 'Valor inválido',
pay_creating: 'Criando pagamento…',
pay_error: 'Falha ao criar o pagamento',
pay_thanks: '✅ Obrigado pelo apoio! Pagamento recebido.',
pay_thanks_file: 'Pagamento recebido! O arquivo está sendo processado…',
pay_ok_btn: 'Ótimo',
pay_canceled: 'Pagamento cancelado',
paywall_title: 'Arquivo acima do limite grátis',
paywall_desc: 'Grátis até 50 MB e 100 páginas. Arquivos maiores — pagamento único ou assinatura.',
paywall_pay_file: 'Pagar arquivo',
paywall_pay_sub: 'Assinatura',
paywall_month: 'mês',
paywall_cancel: 'Cancelar',
paywall_need_login: 'Entre para assinar',
seo_title: 'Como funciona o pdf2scan',
seo_step1_title: 'Envie seu arquivo',
seo_step1_desc: 'PDF ou Word — arraste ou escolha do dispositivo',
seo_step2_title: 'Configure as opções',
seo_step2_desc: 'Tipo de encadernação, cor, qualidade, numeração e mais',
seo_step3_title: 'Baixe o resultado',
seo_step3_desc: 'Seu PDF digitalizado fica disponível por 30 minutos',
seo_about: 'pdf2scan é uma ferramenta online gratuita para criar PDFs rasterizados com efeito de digitalização. Suporta encadernação com fio, fita, canto, espiral e furador. Ideal para preparar documentos para arquivos, cartórios ou órgãos públicos. Sem cadastro, sem software.',
},
};
function pluralizeRu(n, f1, f2, f5) {
const n10 = n % 10, n100 = n % 100;
if (n10 === 1 && n100 !== 11) return f1;
if (n10 >= 2 && n10 <= 4 && (n100 < 10 || n100 >= 20)) return f2;
return f5;
}
// ── Language management ───────────────────────────────────────────────────
// Язык из URL (серверный data-prelang для /en/, /de/… — важно для SEO) имеет
// приоритет над сохранённым выбором, чтобы языковая страница показывалась на своём языке.
const PRELANG = document.documentElement.getAttribute('data-prelang');
let currentLang = (PRELANG && TR[PRELANG])
? PRELANG
: (localStorage.getItem('pdf2scan_lang') || detectLang());
function detectLang() {
const nav = navigator.language || navigator.userLanguage || 'ru';
const code = nav.slice(0, 2).toLowerCase();
return TR[code] ? code : 'ru';
}
function t(key) {
const tr = TR[currentLang] || TR['ru'];
return tr[key] !== undefined ? tr[key] : (TR['ru'][key] || key);
}
function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const val = t(key);
if (typeof val === 'string') el.textContent = val;
});
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
const val = t(el.getAttribute('data-i18n-ph'));
if (typeof val === 'string') el.setAttribute('placeholder', val);
});
document.title = t('page_title');
document.documentElement.lang = currentLang;
// Update flag button
const flagEl = document.getElementById('lang-flag-img');
flagEl.src = LANGS[currentLang].img;
flagEl.alt = LANGS[currentLang].alt;
// Re-render donate desc if visible
const donateCard = document.getElementById('donate-card');
if (donateCard && donateCard.style.display !== 'none') {
const isHeavy = filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10;
donateCard.querySelector('.donate-desc').textContent = t(isHeavy ? 'donate_desc_heavy' : 'donate_desc');
}
// Highlight active language in dropdown
document.querySelectorAll('.lang-option').forEach(btn => {
const lang = btn.getAttribute('onclick').match(/setLang\('(\w+)'\)/)[1];
btn.classList.toggle('active', lang === currentLang);
});
// Re-render dynamic button state
updateSubmitBtn();
}
function setLang(lang) {
if (!TR[lang]) return;
currentLang = lang;
localStorage.setItem('pdf2scan_lang', lang);
closeLangDropdown();
applyTranslations();
}
function toggleLangDropdown(e) {
e.stopPropagation();
document.getElementById('lang-dropdown').classList.toggle('open');
}
function closeLangDropdown() {
document.getElementById('lang-dropdown').classList.remove('open');
}
document.addEventListener('click', closeLangDropdown);
// ── Donate ────────────────────────────────────────────────────────────────
const DONATE_PAGE_THRESHOLD = 20;
function showDonateCard(heavy) {
const card = document.getElementById('donate-card');
const desc = card.querySelector('.donate-desc');
desc.textContent = t(heavy ? 'donate_desc_heavy' : 'donate_desc');
card.style.display = 'block';
}
function hideDonateCard() {
document.getElementById('donate-card').style.display = 'none';
}
// ── Оплата картой (ЮKassa) ────────────────────────────────────────────────
let payAmount = 300;
function setPayAmt(v, el) {
payAmount = v;
const c = document.getElementById('pay-amt-custom'); if (c) c.value = '';
document.querySelectorAll('.pay-amt').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
}
function onCustomAmt() {
const v = parseFloat(document.getElementById('pay-amt-custom').value);
if (v > 0) { payAmount = v; document.querySelectorAll('.pay-amt').forEach(b => b.classList.remove('active')); }
}
async function payDonate() {
const email = (document.getElementById('pay-email').value || '').trim();
const msg = document.getElementById('pay-msg');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = t('pay_need_email'); return; }
if (!payAmount || payAmount < 10) { msg.textContent = t('pay_bad_amount'); return; }
const btn = document.getElementById('pay-card-btn'); btn.disabled = true; msg.textContent = t('pay_creating');
try {
const r = await fetch('/api/pay/create', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: payAmount, purpose: 'donate', email })
});
const d = await r.json();
if (d.confirmation_url) {
localStorage.setItem('pdf2scan_pay_id', d.payment_id);
location.href = d.confirmation_url;
} else { msg.textContent = d.detail || t('pay_error'); btn.disabled = false; }
} catch (e) { msg.textContent = t('pay_error'); btn.disabled = false; }
}
// ── Пейволл больших файлов ────────────────────────────────────────────────
let paywallJob = null, paywallFilePrice = 99, paywallSubPrice = 299;
function showPaywall(jobId, filePrice, subPrice) {
paywallJob = jobId;
paywallFilePrice = filePrice || 99;
paywallSubPrice = subPrice || 299;
document.getElementById('paywall-file-price').textContent = paywallFilePrice;
document.getElementById('paywall-sub-price').textContent = paywallSubPrice;
const emailEl = document.getElementById('paywall-email');
if (sessionUser && sessionUser.email && !emailEl.value) emailEl.value = sessionUser.email;
document.getElementById('progress-card').style.display = 'none';
document.getElementById('result-card').style.display = 'none';
submitBtn.style.display = 'none';
document.getElementById('paywall-card').style.display = 'block';
}
async function _payViaKassa(purpose) {
const email = (document.getElementById('paywall-email').value || '').trim();
const msg = document.getElementById('paywall-msg');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = t('pay_need_email'); return; }
const body = { purpose, job_id: paywallJob, email };
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
const fb = document.getElementById('paywall-file-btn'), sb = document.getElementById('paywall-sub-btn');
fb.disabled = true; sb.disabled = true; msg.textContent = t('pay_creating');
try {
const r = await fetch('/api/pay/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
if (d.confirmation_url) {
localStorage.setItem('pdf2scan_pay_id', d.payment_id);
localStorage.setItem('pdf2scan_pay_job', paywallJob || '');
location.href = d.confirmation_url;
} else { msg.textContent = d.detail || t('pay_error'); fb.disabled = false; sb.disabled = false; }
} catch (e) { msg.textContent = t('pay_error'); fb.disabled = false; sb.disabled = false; }
}
function payForFile() { _payViaKassa('file'); }
function payForSub() {
if (!tgUser && !sessionUser) { document.getElementById('paywall-msg').textContent = t('paywall_need_login'); return; }
_payViaKassa('sub');
}
function showPaySuccess(msg) {
document.getElementById('pay-success-msg').textContent = msg;
document.getElementById('pay-success-overlay').style.display = 'flex';
}
// Возврат с ЮKassa: ?paid=... → опрашиваем статус, для file/sub запускаем обработку
(function handlePayReturn() {
const params = new URLSearchParams(location.search);
if (!params.has('paid')) return;
const paid = params.get('paid');
const pid = localStorage.getItem('pdf2scan_pay_id');
const payJob = localStorage.getItem('pdf2scan_pay_job');
try { history.replaceState({}, '', location.pathname); } catch (_) {}
if (!pid) return;
let tries = 0;
const check = async () => {
tries++;
try {
const r = await fetch('/api/pay/status/' + encodeURIComponent(pid));
const d = await r.json();
if (d.status === 'succeeded') {
localStorage.removeItem('pdf2scan_pay_id');
localStorage.removeItem('pdf2scan_pay_job');
if ((paid === 'file' || paid === 'sub') && payJob) {
const pc = document.getElementById('paywall-card'); if (pc) pc.style.display = 'none';
currentJobId = payJob; batchMode = false;
setUiState('processing'); startPolling();
showPaySuccess(t('pay_thanks_file'));
} else {
showPaySuccess(t('pay_thanks'));
}
return;
}
if (d.status === 'canceled') {
localStorage.removeItem('pdf2scan_pay_id'); localStorage.removeItem('pdf2scan_pay_job');
showError(t('pay_canceled')); return;
}
} catch (_) {}
if (tries <= 25) setTimeout(check, 2000);
};
check();
})();
// ── App state ─────────────────────────────────────────────────────────────
const TG_AUTH_KEY = 'pdf2scan_tg_auth';
let selectedFile = null;
let currentJobId = null;
let pollInterval = null;
let progressInterval = null;
let tgUser = null;
let authRequired = false;
let processingStartTime = null;
let filePages = 0;
let fileSizeMb = 0;
// ── Batch state (несколько файлов) ────────────────────────────────────────
const MAX_BATCH = 20;
const AUTH_MAX_PAGES = 100; // зеркало серверных AUTH_REQUIRED_PAGES (сервер — источник истины)
const AUTH_MAX_MB = 50; // зеркало серверных AUTH_REQUIRED_MB
const CHUNK_SIZE = 6 * 1024 * 1024; // 6 МБ — туннель глотает такие куски быстро (>~15 МБ схлопывается)
let batchMode = false;
let batchJobs = []; // [{file,name,idx,sizeMb,pages,jobId,status,error,queuePos}]
let batchPollInterval = null;
let batchTicker = null;
// ── Подсчёт страниц PDF на клиенте ───────────────────────────────────────
async function countPdfPages(file) {
try {
const buf = await file.arrayBuffer();
const text = new TextDecoder('latin1').decode(new Uint8Array(buf));
const matches = text.match(/\/Type\s*\/Page[^s]/g);
return matches ? matches.length : 0;
} catch(e) { return 0; }
}
// ── localStorage сессия ───────────────────────────────────────────────────
function loadSavedAuth() {
try {
const raw = localStorage.getItem(TG_AUTH_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
if (Date.now() / 1000 - (data.saved_at || 0) > 86400) {
localStorage.removeItem(TG_AUTH_KEY);
return null;
}
return data;
} catch(e) { return null; }
}
function saveAuth(user) {
try {
localStorage.setItem(TG_AUTH_KEY, JSON.stringify({ ...user, saved_at: Date.now() / 1000 }));
} catch(e) {}
}
function logoutTg() {
localStorage.removeItem(TG_AUTH_KEY);
tgUser = null;
renderHeaderAuth();
updateSubmitBtn();
renderAuthBanner();
const cab = document.getElementById('cabinet-card');
if (cab) cab.style.display = 'none';
}
// ── Личный кабинет: «Мои документы» ───────────────────────────────────────
async function loadHistory() {
const uid = tgUser && (tgUser.user_id || tgUser.id);
const card = document.getElementById('cabinet-card');
if (!uid || !card) return;
try {
const r = await fetch('/api/history/' + uid);
if (!r.ok) { card.style.display = 'none'; return; }
const data = await r.json();
const items = data.items || [];
const list = document.getElementById('cabinet-list');
if (!items.length) {
list.innerHTML = '<div style="padding:8px;color:var(--text-secondary);font-size:14px">' + t('cabinet_empty') + '</div>';
card.style.display = 'block';
return;
}
list.innerHTML = items.map(function(it) {
var right;
if (it.status === 'available') {
right = '<a href="/api/download/' + encodeURIComponent(it.job_id) + '" style="color:var(--blue);text-decoration:none;font-size:13px;white-space:nowrap">' + t('cabinet_dl') + '</a>';
} else if (it.status === 'processing') {
right = '<span style="color:var(--text-secondary);font-size:13px">' + t('cabinet_processing') + '</span>';
} else if (it.status === 'error') {
right = '<span style="color:#e05555;font-size:13px">' + t('st_error') + '</span>';
} else {
right = '<span style="color:var(--text-secondary);font-size:13px;opacity:.7">' + t('cabinet_expired') + '</span>';
}
return '<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border,#22314a)">' +
'<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13.5px" title="' + escapeHtml(it.name) + '">' + escapeHtml(it.name) + '</span>' +
'<span style="display:flex;gap:8px;align-items:center"><span style="color:var(--text-secondary);font-size:11px;white-space:nowrap">' + escapeHtml((it.date||'') + ' ' + (it.time||'').slice(0,5)) + '</span>' + right + '</span>' +
'</div>';
}).join('');
card.style.display = 'block';
} catch (e) { /* тихо */ }
}
let sessionUser = null; // из /api/me (вход через Яндекс/VK)
function renderHeaderAuth() {
const loginBtn = document.getElementById('tg-login-header-btn');
const userInfo = document.getElementById('tg-user-info');
const name = tgUser
? (tgUser.first_name + (tgUser.last_name ? ' ' + tgUser.last_name : ''))
: (sessionUser ? sessionUser.name : null);
if (name) {
loginBtn.style.display = 'none';
userInfo.style.display = 'flex';
document.getElementById('tg-user-name').textContent = name;
document.getElementById('tg-user-photo').style.display = 'none';
} else {
loginBtn.style.display = 'flex';
userInfo.style.display = 'none';
}
}
// ── Меню входа (Telegram / Яндекс) ────────────────────────────────────────
function toggleLoginMenu(e) {
e.stopPropagation();
const m = document.getElementById('login-menu');
m.style.display = (m.style.display === 'none' || !m.style.display) ? 'block' : 'none';
}
function closeLoginMenu() {
const m = document.getElementById('login-menu');
if (m) m.style.display = 'none';
}
document.addEventListener('click', closeLoginMenu);
async function doLogout() {
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) {}
sessionUser = null;
logoutTg(); // чистит localStorage TG + перерисовывает шапку
}
async function loadMe() {
try {
const r = await fetch('/api/me');
const d = await r.json();
sessionUser = d.logged_in ? d : null;
} catch (e) { sessionUser = null; }
renderHeaderAuth();
if (sessionUser) loadHistory();
}
// ── Вход по почте (код) ───────────────────────────────────────────────────
let _emailForCode = '';
function openEmailLogin() {
document.getElementById('email-step-1').style.display = 'block';
document.getElementById('email-step-2').style.display = 'none';
document.getElementById('email-login-msg').textContent = '';
document.getElementById('email-login-input').value = '';
document.getElementById('email-code-input').value = '';
document.getElementById('email-login-overlay').style.display = 'flex';
}
function closeEmailLogin() { document.getElementById('email-login-overlay').style.display = 'none'; }
async function emailSendCode() {
const email = (document.getElementById('email-login-input').value || '').trim();
const msg = document.getElementById('email-login-msg');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = t('pay_need_email'); return; }
const btn = document.getElementById('email-send-btn'); btn.disabled = true; msg.textContent = t('email_sending');
try {
const r = await fetch('/api/auth/email/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
const d = await r.json();
if (r.ok) {
_emailForCode = email;
document.getElementById('email-step-1').style.display = 'none';
document.getElementById('email-step-2').style.display = 'block';
msg.textContent = ''; document.getElementById('email-code-input').focus();
} else { msg.textContent = d.detail || t('err_login'); btn.disabled = false; }
} catch (e) { msg.textContent = t('err_login'); btn.disabled = false; }
}
async function emailVerifyCode() {
const code = (document.getElementById('email-code-input').value || '').trim();
const msg = document.getElementById('email-login-msg');
if (!/^\d{6}$/.test(code)) { msg.textContent = t('email_bad_code'); return; }
const btn = document.getElementById('email-verify-btn'); btn.disabled = true; msg.textContent = '';
try {
const r = await fetch('/api/auth/email/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: _emailForCode, code }) });
const d = await r.json();
if (r.ok) { closeEmailLogin(); loadMe(); }
else { msg.textContent = d.detail || t('email_bad_code'); btn.disabled = false; }
} catch (e) { msg.textContent = t('err_login'); btn.disabled = false; }
}
// ── Авторизация через бота ────────────────────────────────────────────────
let _authPollInterval = null;
let _authToken = null;
let _authWindow = null;
async function startTgAuth() {
try {
const r = await fetch('/api/auth/token', { method: 'POST' });
if (!r.ok) { showError(t('err_auth_start')); return; }
const data = await r.json();
_authToken = data.token;
_authWindow = window.open(data.url, '_blank');
showInfo(t('info_auth_waiting'));
if (_authPollInterval) clearInterval(_authPollInterval);
_authPollInterval = setInterval(_pollAuthToken, 2000);
} catch(e) {
showError(t('err_auth_launch'));
}
}
async function _pollAuthToken() {
if (!_authToken) return;
try {
const r = await fetch(`/api/auth/poll/${_authToken}`);
if (!r.ok) { _stopAuthPoll(); return; }
const data = await r.json();
if (data.status === 'verified') {
_stopAuthPoll();
tgUser = { user_id: data.user_id, first_name: data.first_name, last_name: data.last_name, username: data.username };
saveAuth(tgUser);
renderHeaderAuth();
renderAuthBanner();
updateSubmitBtn();
hideInfo();
loadHistory();
if (_authWindow) { try { _authWindow.close(); } catch(_) {} }
} else if (data.status === 'expired') {
_stopAuthPoll();
showError(t('err_auth_expired'));
}
} catch(e) { /* сетевая ошибка, попробуем снова */ }
}
function _stopAuthPoll() {
if (_authPollInterval) { clearInterval(_authPollInterval); _authPollInterval = null; }
_authToken = null;
}
// ── Инициализация ─────────────────────────────────────────────────────────
tgUser = loadSavedAuth();
renderHeaderAuth();
if (tgUser) loadHistory();
if (!tgUser) loadMe(); // проверяем cookie-сессию (вход через Яндекс/VK)
// Возврат после OAuth: чистим ?login и показываем ошибку при неудаче
(function () {
const p = new URLSearchParams(location.search);
if (p.has('login')) {
const v = p.get('login');
try { history.replaceState({}, '', location.pathname); } catch (_) {}
if (v === 'error') showError(t('err_login'));
}
})();
// ── Drop zone ─────────────────────────────────────────────────────────────
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const fileBadge = document.getElementById('file-badge');
const submitBtn = document.getElementById('submit-btn');
applyTranslations(); // после объявления submitBtn — иначе TDZ ReferenceError
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragenter', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragover', e => { e.preventDefault(); });
dropZone.addEventListener('dragleave', e => { if (!dropZone.contains(e.relatedTarget)) dropZone.classList.remove('drag-over'); });
const _ALLOWED_MIME = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
function gatherFiles(fileList) {
return Array.from(fileList || []).filter(
f => _ALLOWED_MIME.includes(f.type) || /\.(pdf|doc|docx|zip)$/i.test(f.name)
);
}
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
acceptFiles(gatherFiles(e.dataTransfer.files));
});
fileInput.addEventListener('change', () => {
acceptFiles(gatherFiles(fileInput.files));
});
// Загрузка папкой: webkitdirectory отдаёт все файлы каталога, фильтруем нужные.
const folderInput = document.getElementById('folder-input');
const folderBtn = document.getElementById('folder-btn');
if (folderBtn) folderBtn.addEventListener('click', (e) => { e.stopPropagation(); folderInput.click(); });
if (folderInput) folderInput.addEventListener('change', () => {
acceptFiles(gatherFiles(folderInput.files));
});
// Один файл → классический одиночный поток; несколько → батч со списком и ZIP.
function acceptFiles(arr) {
if (!arr || !arr.length) return;
hideError(); hideInfo(); hideDonateCard();
submitBtn.style.display = 'block'; // мог быть скрыт после прошлого батча
// ZIP-архив: распаковывается на сервере, каждый вложенный файл — отдельное задание.
const zips = arr.filter(f => /\.zip$/i.test(f.name));
if (zips.length) { handleZip(zips[0]); return; }
let files = arr;
if (files.length > MAX_BATCH) {
showError(t('err_too_many')(MAX_BATCH));
files = files.slice(0, MAX_BATCH);
}
if (files.length === 1) {
batchMode = false;
document.getElementById('batch-card').style.display = 'none';
handleFile(files[0]);
} else {
batchMode = true;
selectedFile = null;
fileBadge.style.display = 'none';
document.getElementById('result-card').style.display = 'none';
document.getElementById('progress-card').style.display = 'none';
handleFiles(files);
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
// ── Загрузка ZIP: грузим архив кусками, сервер распаковывает и ставит в очередь ─
async function uploadZipChunked(file, s, onProgress) {
const uploadId = randId();
const total = file.size;
let offset = 0;
do {
const end = Math.min(offset + CHUNK_SIZE, total);
const fd = new FormData();
fd.append('upload_id', uploadId);
fd.append('offset', offset);
fd.append('chunk', file.slice(offset, end), 'chunk');
const r = await fetchRetry('/api/upload-chunk', { method: 'POST', body: fd }, 3);
if (!r.ok) throw new Error(await _detailOf(r));
offset = end;
if (onProgress) onProgress(offset, total);
} while (offset < total);
const ff = new FormData();
ff.append('upload_id', uploadId);
ff.append('total_size', total);
ff.append('stitching', s.stitching);
ff.append('color', s.color);
ff.append('quality', s.quality);
ff.append('title', s.title);
ff.append('numeration', s.numeration);
ff.append('flip_side', s.flip_side);
ff.append('pdfa', s.pdfa);
ff.append('ocr', s.ocr || 0);
if (tgUser) ff.append('user_id', tgUser.user_id || tgUser.id || 0);
const fr = await fetchRetry('/api/submit-zip-chunked', { method: 'POST', body: ff }, 3);
if (!fr.ok) throw new Error(await _detailOf(fr));
return (await fr.json()).jobs || [];
}
async function handleZip(file) {
batchMode = true; selectedFile = null;
fileBadge.style.display = 'none';
document.getElementById('result-card').style.display = 'none';
document.getElementById('progress-card').style.display = 'none';
document.querySelector('.drop-title').textContent = file.name;
document.getElementById('batch-count').textContent = '';
document.getElementById('batch-list').innerHTML =
'<div style="padding:8px;color:var(--text-secondary)">' + t('zip_preparing') + '</div>';
document.getElementById('batch-actions').style.display = 'none';
document.getElementById('batch-card').style.display = 'block';
submitBtn.style.display = 'none';
showDonateCard(true);
try {
const settings = collectSettings();
const jobs = await uploadZipChunked(file, settings, null);
if (!jobs.length) { showError(t('err_zip')); return; }
batchJobs = jobs.map((jb, i) => ({
file: null, name: jb.name, idx: i, sizeMb: 0, pages: 0,
jobId: jb.job_id, status: 'queued', error: null, queuePos: null,
}));
document.getElementById('batch-count').textContent = '(' + batchJobs.length + ')';
renderBatchList();
startBatchPolling();
} catch (e) {
showError((e && e.message) ? e.message : t('err_zip'));
document.getElementById('batch-list').innerHTML = '';
}
}
async function handleFiles(files) {
batchJobs = files.map((f, i) => ({
file: f, name: f.name, idx: i,
sizeMb: f.size / (1024 * 1024), pages: 0,
jobId: null, status: 'pending', error: null, queuePos: null,
}));
document.querySelector('.drop-title').textContent = t('files_selected')(batchJobs.length);
document.getElementById('batch-count').textContent = '(' + batchJobs.length + ')';
renderBatchList();
document.getElementById('batch-actions').style.display = 'none';
document.getElementById('batch-card').style.display = 'block';
// Подсчёт страниц на клиенте и определение необходимости авторизации
await Promise.all(batchJobs.map(async j => { j.pages = await countPdfPages(j.file); }));
authRequired = batchJobs.some(j => j.pages > AUTH_MAX_PAGES || j.sizeMb > AUTH_MAX_MB);
renderAuthBanner();
updateSubmitBtn();
}
function renderBatchList() {
const list = document.getElementById('batch-list');
list.innerHTML = '';
batchJobs.forEach(j => {
const row = document.createElement('div');
row.className = 'batch-row';
row.innerHTML =
`<span class="batch-name" title="${escapeHtml(j.name)}">${escapeHtml(j.name)}</span>` +
`<span class="batch-status" id="batch-status-${j.idx}"></span>`;
list.appendChild(row);
updateRow(j);
});
}
// Примерный прогресс обработки (асимптотика к 90%, как в одиночном режиме)
function procPct(j) {
const started = j.procStart || Date.now();
const elapsed = (Date.now() - started) / 1000;
return Math.round(90 * (1 - Math.exp(-elapsed / 45)));
}
function updateRow(j) {
const el = document.getElementById('batch-status-' + j.idx);
if (!el) return;
if (j.status === 'done') {
el.innerHTML = `<a class="bs bs-done" href="/api/download/${encodeURIComponent(j.jobId)}" download>${t('st_done')} ⬇</a>`;
} else if (j.status === 'error') {
el.innerHTML = `<span class="bs bs-err" title="${escapeHtml(j.error || '')}">${t('st_error')}</span>`;
} else if (j.status === 'processing') {
const label = t('st_processing').replace(/[…\.]+$/, '');
el.innerHTML = `<span class="bs bs-proc">${label} ${procPct(j)}%</span>`;
} else if (j.status === 'queued') {
el.innerHTML = `<span class="bs bs-wait">${t('st_queued')}${j.queuePos ? ' · ' + j.queuePos : ''}</span>`;
} else if (j.status === 'uploading') {
el.innerHTML = `<span class="bs bs-wait">${t('st_uploading')}</span>`;
} else if (j.status === 'awaiting_payment') {
el.innerHTML = `<a class="bs bs-err" style="cursor:pointer;text-decoration:none" onclick="payBatchRow(${j.idx})">💳 ${(j.filePrice||99)} ₽</a>`;
} else {
el.innerHTML = `<span class="bs bs-wait">—</span>`;
}
}
function payBatchRow(idx) {
const j = batchJobs.find(x => x.idx === idx);
if (j && j.jobId) showPaywall(j.jobId, j.filePrice, j.subPrice);
}
async function handleFile(f) {
selectedFile = f;
fileBadge.textContent = f.name;
fileBadge.style.display = 'block';
document.querySelector('.drop-title').textContent = t('file_selected');
hideError();
hideDonateCard();
fileSizeMb = f.size / (1024 * 1024);
filePages = await countPdfPages(f);
await checkAuth(filePages, fileSizeMb);
}
async function checkAuth(pages, sizeMb) {
try {
const fd = new FormData();
fd.append('pages', pages);
fd.append('size_mb', sizeMb);
const r = await fetch('/api/check', { method: 'POST', body: fd });
const data = await r.json();
authRequired = data.requires_auth;
renderAuthBanner();
} catch (e) {
authRequired = false;
renderAuthBanner();
}
updateSubmitBtn();
}
function renderAuthBanner() {
// Старый баннер авторизации больше не используется — гейт больших файлов
// теперь на сервере (пейволл: оплата 99₽ или подписка после отправки).
const banner = document.getElementById('auth-banner');
if (banner) banner.style.display = 'none';
}
function updateSubmitBtn() {
renderAuthBanner();
const hasSelection = batchMode ? batchJobs.length > 0 : !!selectedFile;
if (!hasSelection) {
submitBtn.disabled = true;
submitBtn.textContent = t('btn_select_file');
} else {
submitBtn.disabled = false;
submitBtn.textContent = batchMode ? t('btn_process_n')(batchJobs.length) : t('btn_process');
}
}
// ── Submit ────────────────────────────────────────────────────────────────
submitBtn.addEventListener('click', () => { if (batchMode) submitBatch(); else submitJob(); });
function collectSettings() {
return {
stitching: getRadio('stitching'),
color: getRadio('color'),
quality: getRadio('quality'),
title: document.getElementById('title-check').checked ? 1 : 0,
numeration: document.getElementById('num-check').checked ? 1 : 0,
flip_side: document.getElementById('flip-check').checked ? 1 : 0,
pdfa: document.getElementById('pdfa-check').checked ? 1 : 0,
ocr: document.getElementById('ocr-check').checked ? 1 : 0,
};
}
// ── Batch submit / poll / zip ─────────────────────────────────────────────
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// ── Чанковый загрузчик (обход потолка туннеля на крупных файлах) ───────────
function randId() {
const a = new Uint8Array(16);
if (window.crypto && crypto.getRandomValues) crypto.getRandomValues(a);
else for (let i = 0; i < a.length; i++) a[i] = Math.floor(Math.random() * 256);
return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
}
async function fetchRetry(url, opts, tries = 3) {
let lastErr = t('err_network');
for (let attempt = 1; attempt <= tries; attempt++) {
try {
const r = await fetch(url, opts);
if (r.ok) return r;
if (r.status >= 400 && r.status < 500) return r; // детерминированный ответ — отдаём как есть
lastErr = t('err_server')(r.status); // 5xx — пробуем ещё
} catch (e) {
lastErr = t('err_network'); // сетевой сброс — пробуем ещё
}
if (attempt < tries) await sleep(attempt * 1500);
}
throw new Error(lastErr);
}
async function _detailOf(r) {
let msg = t('err_server')(r.status);
try { const d = await r.json(); if (typeof d.detail === 'string') msg = d.detail; } catch (_) {}
return msg;
}
// Грузит файл кусками ≤CHUNK_SIZE, затем финализирует. onProgress(uploaded,total) опц. Возвращает job_id.
async function uploadFileChunked(file, s, onProgress) {
const uploadId = randId();
const total = file.size;
let offset = 0;
do {
const end = Math.min(offset + CHUNK_SIZE, total);
const fd = new FormData();
fd.append('upload_id', uploadId);
fd.append('offset', offset);
fd.append('chunk', file.slice(offset, end), 'chunk');
const r = await fetchRetry('/api/upload-chunk', { method: 'POST', body: fd }, 3);
if (!r.ok) throw new Error(await _detailOf(r));
offset = end;
if (onProgress) onProgress(offset, total);
} while (offset < total);
const ff = new FormData();
ff.append('upload_id', uploadId);
ff.append('filename', file.name);
ff.append('total_size', total);
ff.append('stitching', s.stitching);
ff.append('color', s.color);
ff.append('quality', s.quality);
ff.append('title', s.title);
ff.append('numeration', s.numeration);
ff.append('flip_side', s.flip_side);
ff.append('pdfa', s.pdfa);
ff.append('ocr', s.ocr || 0);
if (tgUser) ff.append('user_id', tgUser.user_id || tgUser.id || 0);
const fr = await fetchRetry('/api/submit-chunked', { method: 'POST', body: ff }, 3);
if (!fr.ok) throw new Error(await _detailOf(fr));
return await fr.json(); // {job_id, requires_payment?, file_price?, sub_price?}
}
async function submitBatch() {
hideError(); hideInfo();
submitBtn.disabled = true;
submitBtn.textContent = t('btn_sending');
document.getElementById('batch-actions').style.display = 'none';
const settings = collectSettings();
batchJobs.forEach(j => { j.status = 'pending'; j.error = null; updateRow(j); });
submitBtn.textContent = t('btn_processing');
showDonateCard(true);
// Поллинг статусов идёт параллельно загрузке — ранние файлы показывают прогресс,
// пока грузятся остальные.
startBatchPolling();
// Туннель до бэкенда тонкий и не тянет несколько крупных загрузок разом —
// грузим ПОСЛЕДОВАТЕЛЬНО (по одному) c авто-ретраями на сетевые сбои.
await runPool(batchJobs.slice(), 1, j => uploadOne(j, settings));
}
async function runPool(items, limit, fn) {
const queue = items.slice();
const workers = [];
const n = Math.min(limit, queue.length);
for (let i = 0; i < n; i++) {
workers.push((async () => {
while (queue.length) { await fn(queue.shift()); }
})());
}
await Promise.all(workers);
}
async function uploadOne(j, s) {
j.status = 'uploading'; j.error = null; updateRow(j);
try {
const data = await uploadFileChunked(j.file, s, null);
j.jobId = data.job_id;
if (data.requires_payment) {
j.status = 'awaiting_payment'; j.filePrice = data.file_price; j.subPrice = data.sub_price;
} else {
j.status = 'queued';
}
j.error = null; updateRow(j);
} catch (e) {
j.status = 'error';
j.error = (e && e.message) ? e.message : t('err_network');
updateRow(j);
}
}
function startBatchPolling() {
if (batchPollInterval) clearInterval(batchPollInterval);
batchPollInterval = setInterval(pollBatch, 2000);
// тикер плавно крутит % у файла в обработке между опросами
if (batchTicker) clearInterval(batchTicker);
batchTicker = setInterval(() => {
batchJobs.forEach(j => { if (j.status === 'processing') updateRow(j); });
}, 1000);
pollBatch();
}
async function pollBatch() {
const active = batchJobs.filter(j => j.jobId && (j.status === 'queued' || j.status === 'processing'));
await Promise.all(active.map(async j => {
try {
const r = await fetch(`/api/status/${encodeURIComponent(j.jobId)}`);
if (r.status === 404) { j.status = 'error'; j.error = t('err_task_not_found'); updateRow(j); return; }
if (!r.ok) return;
const d = await r.json();
if (d.status === 'done') { j.status = 'done'; updateRow(j); }
else if (d.status === 'error') { j.status = 'error'; j.error = d.error || t('err_processing'); updateRow(j); }
else if (d.status === 'queued') { j.status = 'queued'; j.queuePos = d.queue_pos || null; updateRow(j); }
else { if (!j.procStart) j.procStart = Date.now(); j.status = 'processing'; updateRow(j); }
} catch (e) { /* сетевой сбой — повторим на следующем тике */ }
}));
if (batchJobs.every(j => j.status === 'done' || j.status === 'error')) {
if (batchPollInterval) { clearInterval(batchPollInterval); batchPollInterval = null; }
onBatchComplete();
}
}
function onBatchComplete() {
if (batchTicker) { clearInterval(batchTicker); batchTicker = null; }
const doneCount = batchJobs.filter(j => j.status === 'done').length;
const actions = document.getElementById('batch-actions');
const zipBtn = document.getElementById('zip-btn');
zipBtn.style.display = doneCount > 0 ? 'block' : 'none';
zipBtn.disabled = doneCount === 0;
actions.style.display = 'flex';
submitBtn.style.display = 'none';
showDonateCard(true);
}
document.getElementById('zip-btn').addEventListener('click', downloadZip);
async function downloadZip() {
const ids = batchJobs.filter(j => j.status === 'done').map(j => j.jobId);
if (!ids.length) return;
const zipBtn = document.getElementById('zip-btn');
const orig = zipBtn.textContent;
zipBtn.disabled = true;
zipBtn.textContent = t('zip_preparing');
try {
// 1) старт фоновой сборки
const r = await fetch('/api/zip/prepare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_ids: ids }),
});
if (!r.ok) throw new Error(await _detailOf(r));
const { zip_id, name } = await r.json();
// 2) ждём готовности (большой архив строится в фоне, без таймаута заголовков)
let ready = false;
for (let i = 0; i < 400; i++) {
await sleep(2000);
try {
const sr = await fetch('/api/zip/status/' + encodeURIComponent(zip_id));
if (!sr.ok) continue;
const s = await sr.json();
if (s.status === 'ready') { ready = true; break; }
if (s.status === 'error') throw new Error(t('err_zip'));
} catch (e) { if (e && e.message === t('err_zip')) throw e; /* сетевой блип — повторим */ }
}
if (!ready) throw new Error(t('err_zip'));
// 3) нативное скачивание готового файла (стримится на диск, не в память)
const a = document.createElement('a');
a.href = '/api/zip/file/' + encodeURIComponent(zip_id);
a.download = name || 'pdf2scan.zip';
document.body.appendChild(a); a.click(); a.remove();
} catch (e) {
showError((e && e.message) ? e.message : t('err_zip'));
} finally {
zipBtn.disabled = false;
zipBtn.textContent = orig;
}
}
function submitJob() {
if (!selectedFile) { showError(t('err_no_file')); return; }
// Крупные файлы — через чанковую загрузку (туннель не тянет большое тело за раз)
if (selectedFile.size > CHUNK_SIZE) { submitJobChunked(); return; }
hideError();
hideInfo();
submitBtn.disabled = true;
submitBtn.textContent = t('btn_sending');
document.getElementById('progress-card').style.display = 'block';
document.getElementById('result-card').style.display = 'none';
setProgressPct(0);
document.getElementById('progress-status').textContent = t('progress_loading');
document.getElementById('progress-speed').textContent = '';
const fd = new FormData();
fd.append('file', selectedFile);
fd.append('stitching', getRadio('stitching'));
fd.append('color', getRadio('color'));
fd.append('quality', getRadio('quality'));
fd.append('title', document.getElementById('title-check').checked ? 1 : 0);
fd.append('numeration', document.getElementById('num-check').checked ? 1 : 0);
fd.append('flip_side', document.getElementById('flip-check').checked ? 1 : 0);
fd.append('pdfa', document.getElementById('pdfa-check').checked ? 1 : 0);
fd.append('ocr', document.getElementById('ocr-check').checked ? 1 : 0);
if (tgUser) fd.append('user_id', tgUser.user_id || tgUser.id || 0);
const xhr = new XMLHttpRequest();
const uploadStart = Date.now();
let lastLoaded = 0, lastTime = uploadStart;
xhr.upload.onprogress = function(e) {
if (!e.lengthComputable) return;
const pct = Math.round(e.loaded / e.total * 100);
setProgressPct(pct);
const now = Date.now();
const dt = (now - lastTime) / 1000;
if (dt >= 0.3) {
const speedMb = (e.loaded - lastLoaded) / (1024 * 1024) / dt;
lastLoaded = e.loaded; lastTime = now;
const loaded = (e.loaded / (1024*1024)).toFixed(1);
const total = (e.total / (1024*1024)).toFixed(1);
const speedFn = (TR[currentLang] || TR['ru']).upload_speed;
document.getElementById('progress-speed').textContent =
speedFn(loaded, total, speedMb.toFixed(1));
}
document.getElementById('progress-status').textContent =
pct < 100 ? t('progress_loading') : t('progress_uploaded');
};
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
currentJobId = data.job_id;
document.getElementById('progress-speed').textContent = '';
if (data.requires_payment) { showPaywall(data.job_id, data.file_price, data.sub_price); return; }
setUiState('processing');
startPolling();
if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) {
showDonateCard(true);
}
} catch(e) {
onUploadError(t('err_server')('?'));
}
} else {
try {
const d = JSON.parse(xhr.responseText);
const detail = d.detail;
const msg = typeof detail === 'string' ? detail : t('err_server')(xhr.status);
onUploadError(msg);
} catch(_) { onUploadError(t('err_server')(xhr.status)); }
}
};
xhr.onerror = function() { onUploadError(t('err_network')); };
xhr.ontimeout = function() { onUploadError(t('err_timeout')); };
xhr.timeout = 300000;
xhr.open('POST', '/api/submit');
xhr.send(fd);
}
async function submitJobChunked() {
hideError(); hideInfo();
submitBtn.disabled = true;
submitBtn.textContent = t('btn_sending');
document.getElementById('progress-card').style.display = 'block';
document.getElementById('result-card').style.display = 'none';
setProgressPct(0);
document.getElementById('progress-status').textContent = t('progress_loading');
document.getElementById('progress-speed').textContent = '';
try {
const data = await uploadFileChunked(selectedFile, collectSettings(), (up, tot) => {
const pct = Math.round(up / tot * 100);
setProgressPct(pct);
document.getElementById('progress-status').textContent =
pct < 100 ? t('progress_loading') : t('progress_uploaded');
});
currentJobId = data.job_id;
document.getElementById('progress-speed').textContent = '';
if (data.requires_payment) { showPaywall(data.job_id, data.file_price, data.sub_price); return; }
setUiState('processing');
startPolling();
if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) showDonateCard(true);
} catch (e) {
onUploadError((e && e.message) ? e.message : t('err_network'));
}
}
function onUploadError(msg) {
showError('⚠ ' + msg);
document.getElementById('progress-card').style.display = 'none';
submitBtn.disabled = !selectedFile;
submitBtn.textContent = t('btn_process');
}
function getRadio(name) {
const el = document.querySelector(`input[name="${name}"]:checked`);
return el ? el.value : '';
}
// ── Прогрессбар ───────────────────────────────────────────────────────────
function startProgressSim() {
processingStartTime = Date.now();
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
const elapsed = (Date.now() - processingStartTime) / 1000;
const pct = Math.round(90 * (1 - Math.exp(-elapsed / 45)));
setProgressPct(pct);
}, 500);
}
function stopProgressSim() {
if (progressInterval) { clearInterval(progressInterval); progressInterval = null; }
}
function setProgressPct(pct) {
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-pct').textContent = pct + '%';
}
// ── Polling ───────────────────────────────────────────────────────────────
function startPolling() {
pollInterval = setInterval(poll, 2000);
poll();
}
async function poll() {
if (!currentJobId) return;
try {
const r = await fetch(`/api/status/${currentJobId}`);
if (r.status === 404) {
clearInterval(pollInterval);
stopProgressSim();
showError(t('err_task_not_found'));
setUiState('idle');
return;
}
if (!r.ok) return;
const data = await r.json();
if (data.status === 'done') {
clearInterval(pollInterval);
stopProgressSim();
setProgressPct(100);
setTimeout(() => setUiState('done'), 300);
} else if (data.status === 'awaiting_payment') {
clearInterval(pollInterval);
stopProgressSim();
showPaywall(currentJobId, data.file_price, data.sub_price);
} else if (data.status === 'error') {
clearInterval(pollInterval);
stopProgressSim();
showError(data.error || t('err_processing'));
setUiState('idle');
} else if (data.status === 'queued') {
const pos = data.queue_pos || '...';
const queueFn = (TR[currentLang] || TR['ru']).queue_text;
document.getElementById('progress-status').textContent = queueFn(pos);
} else {
document.getElementById('progress-status').textContent = t('progress_processing');
}
} catch(e) { /* network glitch */ }
}
// ── UI state ──────────────────────────────────────────────────────────────
function setUiState(state) {
const progressCard = document.getElementById('progress-card');
const resultCard = document.getElementById('result-card');
if (state === 'processing') {
submitBtn.disabled = true;
submitBtn.textContent = t('btn_processing');
progressCard.style.display = 'block';
resultCard.style.display = 'none';
document.getElementById('progress-status').textContent = t('progress_processing');
document.getElementById('progress-speed').textContent = '';
startProgressSim();
} else if (state === 'done') {
progressCard.style.display = 'none';
resultCard.style.display = 'block';
const btn = document.getElementById('download-btn');
btn.href = `/api/download/${currentJobId}`;
btn.download = (selectedFile ? selectedFile.name.replace(/\.pdf$/i,'') : 'document') + '_scan.pdf';
submitBtn.disabled = false;
submitBtn.textContent = t('btn_process');
showDonateCard(filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10);
} else {
submitBtn.disabled = !selectedFile;
submitBtn.textContent = selectedFile ? t('btn_process') : t('btn_select_file');
progressCard.style.display = 'none';
}
}
function showError(msg) {
const el = document.getElementById('error-msg');
el.textContent = msg.startsWith('⚠') ? msg : '⚠ ' + msg;
el.style.display = 'block';
}
function hideError() {
document.getElementById('error-msg').style.display = 'none';
}
function showInfo(msg) {
const el = document.getElementById('info-msg');
el.textContent = msg;
el.style.display = 'block';
}
function hideInfo() {
document.getElementById('info-msg').style.display = 'none';
}
function resetApp() {
currentJobId = null;
selectedFile = null;
authRequired = false;
filePages = 0;
fileSizeMb = 0;
fileInput.value = '';
fileBadge.style.display = 'none';
// Сброс батча
batchMode = false;
batchJobs = [];
if (batchPollInterval) { clearInterval(batchPollInterval); batchPollInterval = null; }
if (batchTicker) { clearInterval(batchTicker); batchTicker = null; }
document.getElementById('batch-card').style.display = 'none';
document.getElementById('batch-actions').style.display = 'none';
document.getElementById('batch-count').textContent = '';
document.getElementById('batch-list').innerHTML = '';
document.querySelector('.drop-title').textContent = t('drop_title');
document.getElementById('result-card').style.display = 'none';
document.getElementById('progress-card').style.display = 'none';
document.getElementById('auth-banner').style.display = 'none';
hideDonateCard();
hideError();
submitBtn.style.display = 'block';
submitBtn.disabled = true;
submitBtn.textContent = t('btn_select_file');
if (pollInterval) clearInterval(pollInterval);
stopProgressSim();
hideInfo();
document.getElementById('progress-speed').textContent = '';
}
</script>
</body>
</html>