Files
pdf2scan_web/app/static/index.html
berkio_admin_gitea cd33b0b391 Ссылка на бота: верный username (@pdf2scan_pybot) + открытие приложения через tg://
- футер сайта вёл на несуществующий @pdf2scan_bot (t.me не резолвил) → @pdf2scan_pybot
- клик по ссылке открывает приложение (tg://resolve) с откатом на веб t.me
- логин на /developers тоже открывает приложение (d.url = tg://), а не браузер

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 07:51:20 +00:00

3696 lines
176 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;
white-space: nowrap;
}
#tg-login-header-btn:hover { background: var(--blue-hover); }
/* Короткий вариант метки кнопки входа — только на узких экранах */
.login-mini { display: none; }
/* ── 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; }
}
/* ── Компактная шапка на телефонах ── */
@media (max-width: 500px) {
header { padding: 8px 12px; gap: 8px; flex-wrap: wrap; }
.logo-subtitle { display: none; } /* длинный подзаголовок распирает шапку — прячем на телефоне */
.logo-text { font-size: 18px; }
#header-auth { gap: 8px; }
#tg-login-header-btn { padding: 7px 12px; font-size: 12.5px; }
.login-full { display: none; }
.login-mini { display: inline; }
#tg-user-info { font-size: 13px; gap: 6px; }
#tg-user-name { display: inline-block; max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
#sub-badge { padding: 4px 8px; }
#sub-badge-text { display: none; } /* дата — только в карточке ниже; в шапке оставляем ★ */
}
/* ── SEO section ── */
#seo-section {
width: 100%;
max-width: 640px;
padding: 0 16px 8px;
}
/* FAQ (server-rendered, SEO) */
#faq-section { width: 100%; max-width: 640px; padding: 4px 16px 8px; }
.faq-h { font-size: 17px; font-weight: 700; color: var(--text); margin: 8px 0 12px; text-align: center; }
.faq-item { background: #fff; border-radius: var(--radius-sm, 10px); box-shadow: var(--shadow-sm); margin-bottom: 8px; padding: 12px 14px; }
.faq-item > summary { cursor: pointer; font-weight: 600; font-size: 14px; color: var(--text); list-style: none; position: relative; padding-right: 22px; }
.faq-item > summary::-webkit-details-marker { display: none; }
.faq-item > summary::after { content: '+'; position: absolute; right: 2px; top: -1px; font-size: 18px; color: var(--text-secondary); }
.faq-item[open] > summary::after { content: ''; }
.faq-a { font-size: 13.5px; line-height: 1.5; color: var(--text-secondary); margin-top: 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="15" height="15" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
<span class="login-full" data-i18n="login_btn">Войти / Регистрация</span>
<span class="login-mini" data-i18n="login_btn_mini">Войти</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>
<a id="sub-badge" href="javascript:void(0)" onclick="scrollToSubCard()" title=""
style="display:none;align-items:center;gap:5px;background:rgba(52,199,89,.16);color:#1f9d3f;font-size:12.5px;font-weight:700;padding:4px 10px;border-radius:20px;text-decoration:none;white-space:nowrap;">
<span style="font-size:13px;line-height:1"></span><span id="sub-badge-text"></span>
</a>
<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 6px" data-i18n="email_code_sent">Код отправлен на почту. Введите его:</p>
<p style="font-size:12px;color:var(--text-secondary);margin:0 0 10px;opacity:.85">💡 <span data-i18n="email_check_spam">Письма нет? Проверьте папку «Спам»</span></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>
<!-- Статус активной подписки -->
<div class="card" id="sub-card" style="display:none;border:1px solid rgba(52,199,89,.4);background:linear-gradient(180deg,rgba(52,199,89,.09),rgba(52,199,89,.02));">
<div style="padding:16px;display:flex;align-items:center;gap:14px;">
<div style="width:46px;height:46px;flex:none;border-radius:50%;background:rgba(52,199,89,.18);display:flex;align-items:center;justify-content:center;font-size:23px;color:#1f9d3f;"></div>
<div style="flex:1;min-width:0">
<div style="font-size:16px;font-weight:700;color:var(--text-primary,#1c2b45)" data-i18n="sub_active_title">Подписка активна</div>
<div id="sub-card-until" style="font-size:13.5px;color:var(--text-secondary)"></div>
</div>
<button id="sub-renew-btn" type="button" onclick="renewSub()" style="flex:none;background:#34c759;color:#fff;border:none;border-radius:10px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="sub_renew">Продлить</button>
</div>
<div style="padding:0 16px 14px;display:flex;flex-wrap:wrap;gap:8px 18px;font-size:13px;color:var(--text-secondary)">
<span><span data-i18n="sub_perk_files">Большие файлы без лимита</span></span>
<span><span data-i18n="sub_perk_batch">Пакетная обработка</span></span>
<span><span data-i18n="sub_perk_priority">Приоритетная очередь</span></span>
</div>
<div id="sub-autorenew" style="padding:10px 16px;border-top:0.5px solid rgba(52,199,89,.25);font-size:12.5px;color:var(--text-secondary);display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span id="sub-autorenew-label"></span>
<a id="sub-autorenew-toggle" href="javascript:void(0)" onclick="toggleAutorenew()" style="color:#007AFF;text-decoration:underline;cursor:pointer;display:none"></a>
</div>
</div>
<!-- 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 style="text-align:center;margin-top:10px;font-size:12px;color:var(--text-secondary);line-height:1.4" data-i18n="free_limit_note">Бесплатно до 50 МБ и 100 страниц. Больше — 99 ₽ за файл или подписка 299 ₽/мес.</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>
<label id="paywall-autorenew-row" style="display:flex;align-items:center;justify-content:center;gap:7px;margin-top:12px;font-size:12.5px;color:var(--text-secondary);cursor:pointer">
<input type="checkbox" id="paywall-autorenew" checked style="width:16px;height:16px;cursor:pointer">
<span data-i18n="paywall_autorenew">Продлевать автоматически каждый месяц</span>
</label>
<div style="font-size:11.5px;color:var(--text-secondary);margin-top:8px"><span data-i18n="paywall_offer">Оплачивая, вы принимаете условия</span> <a href="/offer" target="_blank" style="color:var(--blue)" data-i18n="paywall_offer_link">публичной оферты</a></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">
⏱ Ссылка активна 6 часов — скачайте файл сейчас
</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 со сканом доступен для скачивания 6 часов</div>
</div>
</div>
<p class="seo-about" data-i18n="seo_about">pdf2scan — бесплатный онлайн-инструмент для создания растрового PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Идеально для подготовки документов к сдаче в архив, нотариусу или государственные органы. Без регистрации, без установки программ.</p>
</section>
<!--SEO_FAQ-->
<footer>
pdf2scan &mdash; <span data-i18n="footer_text">превращаем PDF в скан</span> &nbsp;|&nbsp; <a href="https://t.me/pdf2scan_pybot" onclick="return openBot(event)" 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> &nbsp;|&nbsp; <a href="/offer" style="color:var(--blue); text-decoration:none;" data-i18n="footer_offer">Оферта</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_btn_mini: 'Войти',
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_check_spam: 'Письма нет? Проверьте папку «Спам»',
email_login_btn: 'Войти',
email_sending: 'Отправляем код…',
email_bad_code: 'Неверный код',
err_login: 'Не удалось войти. Попробуйте снова.',
logout_btn: 'Выйти',
page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.',
card_file: 'Файл',
drop_title: 'Выберите PDF или Word',
drop_hint: 'или перетащите файлы сюда (до 20)',
free_limit_note: 'Бесплатно до 50 МБ и 100 страниц. Больше — 99 ₽ за файл или подписка 299 ₽/мес.',
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: '⏱ Ссылка активна 6 часов — скачайте файл сейчас',
download_btn: '⬇ Скачать результат',
another_btn: 'Обработать ещё один файл',
footer_text: 'превращаем PDF в скан',
footer_tg: 'Telegram-бот',
footer_api: 'API для бизнеса',
footer_donate: '🍩 Поддержать',
footer_offer: 'Оферта',
paywall_offer: 'Оплачивая, вы принимаете условия',
paywall_offer_link: 'публичной оферты',
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_batch_title: 'Пакетная обработка — по подписке',
paywall_batch_desc: 'Обработка нескольких файлов сразу — по подписке. Оформите подписку, затем загрузите файлы.',
pay_uploading: 'Загружаем файл…',
paywall_pay_file: 'Оплатить файл',
paywall_pay_sub: 'Подписка',
paywall_month: 'мес',
paywall_renew_title: 'Продлить подписку',
paywall_renew_desc: 'Продлите доступ к большим файлам и пакетной обработке.',
sub_active_title: 'Подписка активна',
sub_until_label: 'до',
sub_renew: 'Продлить',
sub_perk_files: 'Большие файлы без лимита',
sub_perk_batch: 'Пакетная обработка',
sub_perk_priority: 'Приоритетная очередь',
paywall_autorenew: 'Продлевать автоматически каждый месяц',
sub_autorenew_on: 'Автопродление включено',
sub_autorenew_off: 'Автопродление выключено',
sub_autorenew_disable: 'Отключить',
sub_autorenew_enable: 'Включить',
bot_file_released: 'Файл освобождён — обработка началась. Результат придёт в бот.',
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 со сканом доступен для скачивания 6 часов',
seo_about: 'pdf2scan — онлайн-инструмент для создания растрового PDF с эффектом сканирования. Файлы до 50 МБ и 100 страниц — бесплатно, без регистрации. Для больших файлов — 99 ₽ за файл или подписка 299 ₽/мес. Сшивка нитками, ленточкой, уголком, пружинкой и дыроколом; нумерация, титульный лист, PDF/A. Идеально для сдачи в архив, нотариусу или госорганы.',
},
en: {
page_title: 'pdf2scan — online PDF scan',
logo_subtitle: 'Convert PDF to scanned document online',
login_btn: 'Log in / Sign up',
login_btn_mini: 'Log in',
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_check_spam: 'No email? Check your Spam folder',
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)',
free_limit_note: 'Free up to 50 MB and 100 pages. Larger — 99 ₽ per file or 299 ₽/mo subscription.',
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 6 hours — 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',
footer_offer: 'Terms',
paywall_offer: 'By paying you accept the',
paywall_offer_link: 'public offer terms',
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_batch_title: 'Batch processing — subscription',
paywall_batch_desc: 'Processing multiple files at once requires a subscription. Subscribe, then upload your files.',
pay_uploading: 'Uploading file…',
paywall_pay_file: 'Pay for file',
paywall_pay_sub: 'Subscription',
paywall_month: 'mo',
paywall_renew_title: 'Renew subscription',
paywall_renew_desc: 'Extend access to large files and batch processing.',
sub_active_title: 'Subscription active',
sub_until_label: 'until',
sub_renew: 'Renew',
sub_perk_files: 'Large files, no limit',
sub_perk_batch: 'Batch processing',
sub_perk_priority: 'Priority queue',
paywall_autorenew: 'Renew automatically every month',
sub_autorenew_on: 'Auto-renewal on',
sub_autorenew_off: 'Auto-renewal off',
sub_autorenew_disable: 'Turn off',
sub_autorenew_enable: 'Turn on',
bot_file_released: 'File released — processing started. The result will arrive in the bot.',
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 6 hours',
seo_about: 'pdf2scan is an online tool to create raster PDFs with a scan effect. Files up to 50 MB and 100 pages are free, no signup. Larger files: 99 ₽ per file or a 299 ₽/mo subscription. Thread, ribbon, corner, spiral and hole-punch binding; page numbering, title page, PDF/A.',
},
de: {
page_title: 'pdf2scan — Online-PDF-Scan',
logo_subtitle: 'PDF online in gescannte Dokumente umwandeln',
login_btn: 'Anmelden / Registrieren',
login_btn_mini: '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_check_spam: 'Keine E-Mail? Prüfen Sie den Spam-Ordner',
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)',
free_limit_note: 'Kostenlos bis 50 MB und 100 Seiten. Größer — 99 ₽ pro Datei oder Abo 299 ₽/Mon.',
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 6 Stunden 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',
footer_offer: 'AGB',
paywall_offer: 'Mit der Zahlung akzeptieren Sie die',
paywall_offer_link: 'Angebotsbedingungen',
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_batch_title: 'Stapelverarbeitung — im Abo',
paywall_batch_desc: 'Mehrere Dateien gleichzeitig erfordern ein Abo. Abonnieren Sie und laden Sie dann Ihre Dateien hoch.',
pay_uploading: 'Datei wird hochgeladen…',
paywall_pay_file: 'Datei bezahlen',
paywall_pay_sub: 'Abo',
paywall_month: 'Mon',
paywall_renew_title: 'Abo verlängern',
paywall_renew_desc: 'Verlängern Sie den Zugriff auf große Dateien und Stapelverarbeitung.',
sub_active_title: 'Abo aktiv',
sub_until_label: 'bis',
sub_renew: 'Verlängern',
sub_perk_files: 'Große Dateien ohne Limit',
sub_perk_batch: 'Stapelverarbeitung',
sub_perk_priority: 'Prioritäts-Warteschlange',
paywall_autorenew: 'Automatisch monatlich verlängern',
sub_autorenew_on: 'Automatische Verlängerung an',
sub_autorenew_off: 'Automatische Verlängerung aus',
sub_autorenew_disable: 'Ausschalten',
sub_autorenew_enable: 'Einschalten',
bot_file_released: 'Datei freigegeben — Verarbeitung gestartet. Das Ergebnis kommt in den Bot.',
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 6 Stunden zum Download bereit',
seo_about: 'pdf2scan ist ein Online-Tool für Raster-PDFs mit Scan-Effekt. Dateien bis 50 MB und 100 Seiten sind kostenlos, ohne Anmeldung. Größere Dateien: 99 ₽ pro Datei oder Abo 299 ₽/Mon. Faden-, Band-, Ecken-, Spiral- und Lochbindung; Nummerierung, Titelseite, PDF/A.',
},
fr: {
page_title: 'pdf2scan — scan PDF en ligne',
logo_subtitle: 'Convertir des PDF en documents scannés en ligne',
login_btn: 'Connexion / Inscription',
login_btn_mini: '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_check_spam: 'Pas de message ? Vérifiez le dossier Spam',
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)',
free_limit_note: 'Gratuit : 50 Mo et 100 pages max. Plus grand — 99 ₽ par fichier ou abonnement 299 ₽/mois.',
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 6 heures — 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',
footer_offer: 'Conditions',
paywall_offer: 'En payant vous acceptez les',
paywall_offer_link: 'conditions générales',
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_batch_title: 'Traitement par lots — sur abonnement',
paywall_batch_desc: 'Traiter plusieurs fichiers à la fois nécessite un abonnement. Abonnez-vous, puis chargez vos fichiers.',
pay_uploading: 'Téléversement du fichier…',
paywall_pay_file: 'Payer le fichier',
paywall_pay_sub: 'Abonnement',
paywall_month: 'mois',
paywall_renew_title: 'Renouveler votre abonnement',
paywall_renew_desc: 'Prolongez votre accès aux gros fichiers et au traitement par lots.',
sub_active_title: 'Abonnement actif',
sub_until_label: 'expire le',
sub_renew: 'Renouveler',
sub_perk_files: 'Gros fichiers sans limite',
sub_perk_batch: 'Traitement par lots',
sub_perk_priority: 'File prioritaire',
paywall_autorenew: 'Renouveler automatiquement chaque mois',
sub_autorenew_on: 'Renouvellement automatique activé',
sub_autorenew_off: 'Renouvellement automatique désactivé',
sub_autorenew_disable: 'Désactiver',
sub_autorenew_enable: 'Activer',
bot_file_released: 'Fichier débloqué — traitement lancé. Le résultat arrivera dans le bot.',
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 6 heures au téléchargement',
seo_about: 'pdf2scan est un outil en ligne pour PDF raster avec effet scan. Fichiers de 50 Mo et 100 pages max : gratuits, sans inscription. Fichiers plus grands : 99 ₽ par fichier ou abonnement 299 ₽/mois. Reliure fil, ruban, coin, spirale, perforateur ; numérotation, page de titre, PDF/A.',
},
it: {
page_title: 'pdf2scan — scansione PDF online',
logo_subtitle: 'Converti PDF in documenti scansionati online',
login_btn: 'Accedi / Registrati',
login_btn_mini: '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_check_spam: 'Nessuna email? Controlla la cartella Spam',
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)',
free_limit_note: 'Gratis fino a 50 MB e 100 pagine. Oltre — 99 ₽ a file o abbonamento 299 ₽/mese.',
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 6 ore — 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',
footer_offer: 'Termini',
paywall_offer: 'Pagando accetti i',
paywall_offer_link: 'termini di offerta',
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_batch_title: 'Elaborazione multipla — in abbonamento',
paywall_batch_desc: 'Elaborare più file insieme richiede un abbonamento. Abbonati e poi carica i file.',
pay_uploading: 'Caricamento file…',
paywall_pay_file: 'Paga file',
paywall_pay_sub: 'Abbonamento',
paywall_month: 'mese',
paywall_renew_title: 'Rinnova abbonamento',
paywall_renew_desc: 'Estendi accesso ai file grandi e alla elaborazione multipla.',
sub_active_title: 'Abbonamento attivo',
sub_until_label: 'fino al',
sub_renew: 'Rinnova',
sub_perk_files: 'File grandi senza limiti',
sub_perk_batch: 'Elaborazione multipla',
sub_perk_priority: 'Coda prioritaria',
paywall_autorenew: 'Rinnova automaticamente ogni mese',
sub_autorenew_on: 'Rinnovo automatico attivo',
sub_autorenew_off: 'Rinnovo automatico disattivato',
sub_autorenew_disable: 'Disattiva',
sub_autorenew_enable: 'Attiva',
bot_file_released: 'File sbloccato — elaborazione avviata. Il risultato arriverà nel bot.',
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 6 ore',
seo_about: 'pdf2scan è uno strumento online per PDF raster con effetto scansione. File fino a 50 MB e 100 pagine gratis, senza registrazione. File più grandi: 99 ₽ a file o abbonamento 299 ₽/mese. Rilegatura filo, nastro, angolo, spirale, perforatore; numerazione, pagina del titolo, PDF/A.',
},
es: {
page_title: 'pdf2scan — escaneo PDF en línea',
logo_subtitle: 'Convierte PDF en documentos escaneados en línea',
login_btn: 'Entrar / Registro',
login_btn_mini: 'Entrar',
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_check_spam: 'Sin correo? Revisa la carpeta de spam',
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)',
free_limit_note: 'Gratis hasta 50 MB y 100 páginas. Más — 99 ₽ por archivo o suscripción 299 ₽/mes.',
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 6 horas — 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',
footer_offer: 'Términos',
paywall_offer: 'Al pagar aceptas los',
paywall_offer_link: 'términos de la oferta',
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_batch_title: 'Procesamiento por lotes — con suscripción',
paywall_batch_desc: 'Procesar varios archivos a la vez requiere suscripción. Suscríbete y luego sube tus archivos.',
pay_uploading: 'Subiendo archivo…',
paywall_pay_file: 'Pagar archivo',
paywall_pay_sub: 'Suscripción',
paywall_month: 'mes',
paywall_renew_title: 'Renovar suscripción',
paywall_renew_desc: 'Amplía el acceso a archivos grandes y al procesamiento por lotes.',
sub_active_title: 'Suscripción activa',
sub_until_label: 'hasta el',
sub_renew: 'Renovar',
sub_perk_files: 'Archivos grandes sin límite',
sub_perk_batch: 'Procesamiento por lotes',
sub_perk_priority: 'Cola prioritaria',
paywall_autorenew: 'Renovar automáticamente cada mes',
sub_autorenew_on: 'Renovación automática activada',
sub_autorenew_off: 'Renovación automática desactivada',
sub_autorenew_disable: 'Desactivar',
sub_autorenew_enable: 'Activar',
bot_file_released: 'Archivo liberado — procesamiento iniciado. El resultado llegará al bot.',
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 6 horas para descargar',
seo_about: 'pdf2scan es una herramienta online para PDF rasterizados con efecto de escaneo. Archivos de hasta 50 MB y 100 páginas gratis, sin registro. Archivos mayores: 99 ₽ por archivo o suscripción 299 ₽/mes. Encuadernación hilo, cinta, esquina, espiral, perforadora; numeración, página de título, PDF/A.',
},
pt: {
page_title: 'pdf2scan — digitalização PDF online',
logo_subtitle: 'Converta PDF em documentos digitalizados online',
login_btn: 'Entrar / Registrar',
login_btn_mini: '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_check_spam: 'Sem e-mail? Verifique a pasta de spam',
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)',
free_limit_note: 'Grátis até 50 MB e 100 páginas. Maior — 99 ₽ por arquivo ou assinatura 299 ₽/mês.',
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 6 horas — 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',
footer_offer: 'Termos',
paywall_offer: 'Ao pagar você aceita os',
paywall_offer_link: 'termos da oferta',
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_batch_title: 'Processamento em lote — por assinatura',
paywall_batch_desc: 'Processar vários arquivos de uma vez requer assinatura. Assine e depois envie seus arquivos.',
pay_uploading: 'Enviando arquivo…',
paywall_pay_file: 'Pagar arquivo',
paywall_pay_sub: 'Assinatura',
paywall_month: 'mês',
paywall_renew_title: 'Renovar assinatura',
paywall_renew_desc: 'Amplie o acesso a arquivos grandes e ao processamento em lote.',
sub_active_title: 'Assinatura ativa',
sub_until_label: 'até',
sub_renew: 'Renovar',
sub_perk_files: 'Arquivos grandes sem limite',
sub_perk_batch: 'Processamento em lote',
sub_perk_priority: 'Fila prioritária',
paywall_autorenew: 'Renovar automaticamente todo mês',
sub_autorenew_on: 'Renovação automática ativada',
sub_autorenew_off: 'Renovação automática desativada',
sub_autorenew_disable: 'Desativar',
sub_autorenew_enable: 'Ativar',
bot_file_released: 'Arquivo liberado — processamento iniciado. O resultado chegará no bot.',
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 6 horas',
seo_about: 'pdf2scan é uma ferramenta online para PDF rasterizados com efeito de digitalização. Arquivos até 50 MB e 100 páginas grátis, sem cadastro. Arquivos maiores: 99 ₽ por arquivo ou assinatura 299 ₽/mês. Encadernação fio, fita, canto, espiral, furador; numeração, página de título, PDF/A.',
},
};
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();
// Re-render subscription badge/card in the new language
if (subUntilTs) renderSubStatus({ active: subUntilTs > (Date.now() / 1000), until: subUntilTs });
}
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, paywallMode = 'file';
// Предпроверка на сервере БЕЗ загрузки файла
async function checkPaywall(sizeMb, pages) {
try {
const body = { size_mb: sizeMb, pages: pages };
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
const r = await fetch('/api/paywall/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return await r.json();
} catch (e) { return { requires_payment: false, sub_active: false, paywall_enabled: false, file_price: 99, sub_price: 299 }; }
}
function track(event) {
try { fetch('/api/track', { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: event }) }).catch(function(){}); } catch (_) {}
}
function showPaywall(jobId, filePrice, subPrice, mode) {
track('paywall_shown');
paywallMode = mode || 'file';
paywallJob = jobId;
paywallFilePrice = filePrice || 99;
paywallSubPrice = subPrice || 299;
const subOnly = (paywallMode === 'batch' || paywallMode === 'renew');
document.getElementById('paywall-file-price').textContent = paywallFilePrice;
document.getElementById('paywall-sub-price').textContent = paywallSubPrice;
document.getElementById('paywall-file-btn').style.display = subOnly ? 'none' : '';
const titleEl = document.querySelector('#paywall-card [data-i18n="paywall_title"]');
const descEl = document.querySelector('#paywall-card [data-i18n="paywall_desc"]');
const tKey = paywallMode === 'renew' ? 'paywall_renew_title' : (paywallMode === 'batch' ? 'paywall_batch_title' : 'paywall_title');
const dKey = paywallMode === 'renew' ? 'paywall_renew_desc' : (paywallMode === 'batch' ? 'paywall_batch_desc' : 'paywall_desc');
if (titleEl) titleEl.textContent = t(tKey);
if (descEl) descEl.textContent = t(dKey);
const emailEl = document.getElementById('paywall-email');
if (sessionUser && sessionUser.email && !emailEl.value) emailEl.value = sessionUser.email;
document.getElementById('paywall-file-btn').disabled = false;
document.getElementById('paywall-sub-btn').disabled = false;
document.getElementById('paywall-msg').textContent = '';
document.getElementById('progress-card').style.display = 'none';
document.getElementById('result-card').style.display = 'none';
document.getElementById('batch-card').style.display = 'none';
submitBtn.style.display = 'none';
document.getElementById('paywall-card').style.display = 'block';
document.getElementById('paywall-card').scrollIntoView({ behavior: 'smooth', block: 'center' });
}
async function _payViaKassa(purpose) {
track('pay_click');
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 fb = document.getElementById('paywall-file-btn'), sb = document.getElementById('paywall-sub-btn');
fb.disabled = true; sb.disabled = true;
// Если файл ещё не загружен (пейволл показан ДО загрузки) — грузим сейчас
if (!paywallJob && paywallMode === 'file' && selectedFile) {
msg.textContent = t('pay_uploading');
try {
const data = await uploadFileChunked(selectedFile, collectSettings(), null);
paywallJob = data.job_id;
} catch (e) { msg.textContent = (e && e.message) ? e.message : t('err_network'); fb.disabled = false; sb.disabled = false; return; }
}
const body = { purpose, email };
if (purpose === 'sub') { const ar = document.getElementById('paywall-autorenew'); body.autorenew = ar ? ar.checked : false; }
if (paywallJob) body.job_id = paywallJob;
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
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';
}
loadSubscription(); // обновляем бейдж/карточку подписки под текущий вход
}
// ── Статус подписки (бейдж в шапке + карточка на главной) ─────────────────
let subUntilTs = 0, subAutorenew = false, subHasMethod = false;
const _DATE_LOC = { ru: 'ru-RU', en: 'en-US', de: 'de-DE', fr: 'fr-FR', it: 'it-IT', es: 'es-ES', pt: 'pt-PT' };
function _subDate(ts, opts) {
try { return new Date(ts * 1000).toLocaleDateString(_DATE_LOC[currentLang] || undefined, opts); }
catch (_) { return new Date(ts * 1000).toLocaleDateString(); }
}
async function loadSubscription() {
if (!tgUser && !sessionUser) { renderSubStatus({ active: false }); return; }
try {
const uid = tgUser ? (tgUser.user_id || tgUser.id || 0) : 0;
const r = await fetch('/api/subscription' + (uid ? ('?user_id=' + encodeURIComponent(uid)) : ''));
const d = await r.json();
renderSubStatus(d);
// Пришли по ссылке подписки из бота — открываем оплату (если ещё не подписан)
if (window._openSubAfterLoad) {
window._openSubAfterLoad = false;
if (!d.active) showPaywall(null, paywallFilePrice, (d.price || paywallSubPrice), 'renew');
else scrollToSubCard();
}
// Пришли по ссылке оплаты файла из бота — открываем пейволл для этого задания
if (window._payJobAfterLoad) {
const job = window._payJobAfterLoad;
window._payJobAfterLoad = null;
if (d.active) {
// уже есть подписка — освобождаем файл бесплатно, без повторной оплаты
const body = { job_id: job };
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
try {
await fetch('/api/held/release', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
showPaySuccess(t('bot_file_released'));
} catch (_) {}
} else {
showPaywall(job, paywallFilePrice, (d.price || paywallSubPrice), 'file');
}
}
} catch (_) { renderSubStatus({ active: false }); }
}
function renderSubStatus(d) {
const badge = document.getElementById('sub-badge');
const card = document.getElementById('sub-card');
subUntilTs = (d && d.active) ? (d.until || 0) : 0;
if (d && d.price) paywallSubPrice = d.price;
if (d && d.active && d.until) {
const shortD = _subDate(d.until, { day: '2-digit', month: '2-digit', year: 'numeric' });
const longD = _subDate(d.until, { day: '2-digit', month: 'long', year: 'numeric' });
if (badge) {
document.getElementById('sub-badge-text').textContent = t('sub_until_label') + ' ' + shortD;
badge.title = t('sub_active_title') + ' — ' + t('sub_until_label') + ' ' + longD;
badge.style.display = 'inline-flex';
}
if (card) {
document.getElementById('sub-card-until').textContent = t('sub_until_label') + ' ' + longD;
card.style.display = 'block';
}
// автопродление
subAutorenew = !!(d && d.autorenew);
subHasMethod = !!(d && d.has_method);
const arLabel = document.getElementById('sub-autorenew-label');
const arToggle = document.getElementById('sub-autorenew-toggle');
if (arLabel && arToggle) {
if (subAutorenew) {
arLabel.textContent = t('sub_autorenew_on');
arToggle.textContent = t('sub_autorenew_disable');
arToggle.style.display = 'inline';
} else {
arLabel.textContent = t('sub_autorenew_off');
if (subHasMethod) { arToggle.textContent = t('sub_autorenew_enable'); arToggle.style.display = 'inline'; }
else { arToggle.style.display = 'none'; }
}
}
} else {
if (badge) badge.style.display = 'none';
if (card) card.style.display = 'none';
}
}
async function toggleAutorenew() {
if (!tgUser && !sessionUser) return;
const body = { enable: !subAutorenew };
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
const arToggle = document.getElementById('sub-autorenew-toggle');
if (arToggle) arToggle.style.pointerEvents = 'none';
try {
const r = await fetch('/api/subscription/autorenew', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
subAutorenew = !!d.autorenew;
} catch (e) {}
if (arToggle) arToggle.style.pointerEvents = '';
loadSubscription();
}
function scrollToSubCard() {
const card = document.getElementById('sub-card');
if (card && card.style.display !== 'none') card.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function renewSub() {
if (!tgUser && !sessionUser) return;
showPaywall(null, paywallFilePrice, paywallSubPrice, 'renew');
}
// ── Меню входа (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;
}
// ── Открыть Telegram-бота ─────────────────────────────────────────────────
// Пытаемся открыть приложение напрямую (tg://), а если оно не перехватило
// переход (не установлено) — через ~1.1 c откатываемся на веб-версию t.me.
function openBot(e) {
try { if (e) e.preventDefault(); } catch (_) {}
var appUrl = 'tg://resolve?domain=pdf2scan_pybot';
var webUrl = 'https://t.me/pdf2scan_pybot';
var opened = false;
function markOpened() { if (document.hidden) opened = true; } // приложение открылось → вкладка спряталась
function onBlur() { opened = true; }
document.addEventListener('visibilitychange', markOpened);
window.addEventListener('blur', onBlur);
window.addEventListener('pagehide', onBlur);
try { window.location.href = appUrl; } catch (_) {}
setTimeout(function () {
document.removeEventListener('visibilitychange', markOpened);
window.removeEventListener('blur', onBlur);
window.removeEventListener('pagehide', onBlur);
if (!opened) window.location.href = webUrl; // приложения нет — открываем веб
}, 1100);
return false;
}
// ── Инициализация ─────────────────────────────────────────────────────────
tgUser = loadSavedAuth();
renderHeaderAuth();
if (tgUser) loadHistory();
if (!tgUser) loadMe(); // проверяем cookie-сессию (вход через Яндекс/VK)
// Маячок посещения (раз в сессию браузера) — считаем реальные заходы для админки
try {
if (!sessionStorage.getItem('p2s_hit')) {
sessionStorage.setItem('p2s_hit', '1');
fetch('/api/hit', { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ref: document.referrer || '', url: location.href || '' }) }).catch(function () {});
}
} catch (_) {}
// Возврат после 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'));
}
// Приход по ссылке подписки из Telegram-бота (/subscribe → /?subscribe=1):
// после загрузки сессии авто-открываем окно оплаты подписки.
if (p.has('subscribe')) {
window._openSubAfterLoad = true;
try { history.replaceState({}, '', location.pathname); } catch (_) {}
}
// Приход по ссылке оплаты файла из бота (/pay → /?pay_job=<id>):
// после загрузки сессии открываем пейволл для этого задания.
if (p.has('pay_job')) {
window._payJobAfterLoad = p.get('pay_job');
try { history.replaceState({}, '', location.pathname); } catch (_) {}
}
})();
// ── 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));
});
// Пакетная обработка (2+ файла / папка / ZIP) — только по подписке.
function requireSubForBatch(proceed) {
checkPaywall(0, 0).then(chk => {
if (chk.paywall_enabled && !chk.sub_active) {
showPaywall(null, chk.file_price, chk.sub_price, 'batch');
} else { proceed(); }
});
}
// Один файл → классический одиночный поток; несколько → батч со списком и 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) { requireSubForBatch(() => 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 {
// Пакет из нескольких файлов — гейт по подписке
requireSubForBatch(() => {
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;
}
}
async function submitJob() {
if (!selectedFile) { showError(t('err_no_file')); return; }
// Пейволл БЕЗ загрузки: только для потенциально больших файлов (мелкие не дёргают сервер)
if (fileSizeMb > AUTH_MAX_MB || filePages > AUTH_MAX_PAGES) {
submitBtn.disabled = true;
const chk = await checkPaywall(fileSizeMb, filePages);
submitBtn.disabled = false;
if (chk.requires_payment) { showPaywall(null, chk.file_price, chk.sub_price, '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>