- OAuth Yandex ID: /api/auth/yandex/{start,callback}, cookie-сессии, /api/me, logout
- Меню входа Telegram/Яндекс в шапке, определение сессии
- Бэкенд пейволла (WEB_ACCOUNTS/подписка, purpose file/sub) — под флагом
PAYWALL_ENABLED (по умолчанию 0: прежний порог авторизации, ничего не ломается)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2971 lines
130 KiB
HTML
2971 lines
130 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" data-prelang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<!--SEO_HEAD-->
|
||
<title>pdf2scan — сделать скан из PDF онлайн бесплатно</title>
|
||
<meta name="description" content="Сделайте скан из PDF онлайн — бесплатно, без регистрации. Добавьте эффект сканирования, сшивку (нитка, ленточка, пружинка, уголок, дырокол), нумерацию страниц и лист «прошито-пронумеровано».">
|
||
<meta name="keywords" content="сделать скан из pdf онлайн, pdf в скан онлайн, сканирование pdf бесплатно, pdf с эффектом сканирования, прошить документ онлайн, сшивка документов онлайн, растровый pdf онлайн, pdf2scan">
|
||
<link rel="canonical" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="ru" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="en" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="de" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="fr" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="it" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="es" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="pt" href="https://pdf2scan.online/">
|
||
<link rel="alternate" hreflang="x-default" href="https://pdf2scan.online/">
|
||
<meta property="og:title" content="pdf2scan — сделать скан из PDF онлайн бесплатно">
|
||
<meta property="og:description" content="Сделайте скан из PDF онлайн — бесплатно, без регистрации. Сшивка, нумерация, эффект сканирования в один клик.">
|
||
<meta property="og:url" content="https://pdf2scan.online/">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:image" content="https://pdf2scan.online/static/logo.png">
|
||
<meta property="og:image:width" content="640">
|
||
<meta property="og:image:height" content="640">
|
||
<meta name="twitter:card" content="summary">
|
||
<meta name="twitter:title" content="pdf2scan — сделать скан из PDF онлайн бесплатно">
|
||
<meta name="twitter:description" content="Сделайте скан из PDF онлайн — бесплатно, без регистрации. Сшивка, нумерация, эффект сканирования в один клик.">
|
||
<meta name="twitter:image" content="https://pdf2scan.online/static/logo.png">
|
||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||
<link rel="shortcut icon" href="/favicon.ico">
|
||
<script type="application/ld+json">
|
||
{
|
||
"@context": "https://schema.org",
|
||
"@type": "WebApplication",
|
||
"name": "pdf2scan",
|
||
"url": "https://pdf2scan.online",
|
||
"description": "Бесплатный онлайн-инструмент для создания PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Нумерация страниц, титульный лист, PDF/A.",
|
||
"applicationCategory": "UtilitiesApplication",
|
||
"operatingSystem": "Any",
|
||
"browserRequirements": "Requires JavaScript",
|
||
"inLanguage": ["ru", "en", "de", "fr", "it", "es", "pt"],
|
||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "RUB" },
|
||
"featureList": [
|
||
"Эффект сканирования PDF онлайн",
|
||
"Сшивка нитками, ленточкой, уголком, пружинкой, дыроколом",
|
||
"Нумерация страниц",
|
||
"Титульный лист",
|
||
"Лист прошито и пронумеровано",
|
||
"Формат PDF/A",
|
||
"Цветной и чёрно-белый режим",
|
||
"Конвертация Word в PDF со сканированием"
|
||
],
|
||
"creator": { "@type": "Organization", "name": "pdf2scan", "url": "https://pdf2scan.online" }
|
||
}
|
||
</script>
|
||
<!--/SEO_HEAD-->
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--blue: #007AFF;
|
||
--blue-hover: #0066D6;
|
||
--blue-light: #E8F2FF;
|
||
--gray: #8E8E93;
|
||
--gray-light: #F2F2F7;
|
||
--border: #D1D1D6;
|
||
--text: #1C1C1E;
|
||
--text-secondary: #636366;
|
||
--radius: 14px;
|
||
--radius-sm: 10px;
|
||
--shadow: 0 4px 24px rgba(0,0,0,0.08);
|
||
--shadow-sm: 0 2px 8px rgba(0,0,0,0.06);
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Arial, sans-serif;
|
||
background: #F2F2F7;
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
/* ── Header ── */
|
||
header {
|
||
width: 100%;
|
||
background: rgba(255,255,255,0.85);
|
||
backdrop-filter: saturate(180%) blur(20px);
|
||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||
border-bottom: 1px solid var(--border);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
padding: 8px 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
/* ── Language switcher ── */
|
||
#lang-switcher {
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#lang-btn {
|
||
background: none;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 5px 8px;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
#lang-btn:hover { background: var(--gray-light); }
|
||
|
||
#lang-btn .lang-arrow {
|
||
font-size: 9px;
|
||
color: var(--gray);
|
||
line-height: 1;
|
||
}
|
||
|
||
.flag-img {
|
||
width: 20px;
|
||
height: 15px;
|
||
border-radius: 2px;
|
||
display: block;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.lang-option .flag-img {
|
||
width: 22px;
|
||
height: 16px;
|
||
}
|
||
|
||
#lang-dropdown {
|
||
display: none;
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
left: 0;
|
||
background: white;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
box-shadow: var(--shadow);
|
||
overflow: hidden;
|
||
min-width: 150px;
|
||
z-index: 200;
|
||
}
|
||
|
||
#lang-dropdown.open { display: block; }
|
||
|
||
.lang-option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 9px 14px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
transition: background 0.1s;
|
||
border: none;
|
||
background: none;
|
||
width: 100%;
|
||
text-align: left;
|
||
font-family: inherit;
|
||
color: var(--text);
|
||
}
|
||
|
||
.lang-option:hover { background: var(--gray-light); }
|
||
.lang-option.active { background: var(--blue-light); color: var(--blue); font-weight: 600; }
|
||
|
||
.lang-option .lang-flag { font-size: 18px; }
|
||
|
||
.logo-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.logo-text {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--blue);
|
||
letter-spacing: -0.3px;
|
||
}
|
||
|
||
.logo-subtitle {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
margin-top: 1px;
|
||
}
|
||
|
||
/* ── Telegram auth in header ── */
|
||
#header-auth {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#tg-user-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 14px;
|
||
color: var(--text);
|
||
}
|
||
|
||
#tg-user-info img {
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
#tg-logout-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 12px;
|
||
color: var(--gray);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
padding: 0;
|
||
}
|
||
|
||
#tg-login-header-btn {
|
||
padding: 7px 14px;
|
||
background: var(--blue);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
#tg-login-header-btn:hover { background: var(--blue-hover); }
|
||
|
||
/* ── Main card ── */
|
||
main {
|
||
width: 100%;
|
||
max-width: 640px;
|
||
padding: 14px 16px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.card {
|
||
background: white;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-sm);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 9px 20px 7px;
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--text-secondary);
|
||
letter-spacing: 0.2px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
/* ── Drop zone ── */
|
||
.drop-zone {
|
||
border: 2px dashed var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 18px 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: border-color 0.2s, background 0.2s;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 6px;
|
||
position: relative;
|
||
}
|
||
|
||
.drop-zone:hover, .drop-zone.drag-over {
|
||
border-color: var(--blue);
|
||
background: var(--blue-light);
|
||
}
|
||
|
||
.drop-zone input[type=file] {
|
||
display: none;
|
||
}
|
||
|
||
.drop-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
background: var(--blue-light);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.drop-icon svg { fill: var(--blue); }
|
||
|
||
.drop-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.drop-hint {
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* ── Batch file list ── */
|
||
#batch-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.batch-row {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||
padding: 10px 12px;
|
||
background: var(--blue-light);
|
||
border-radius: 10px;
|
||
font-size: 14px;
|
||
}
|
||
.batch-name {
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
flex: 1; min-width: 0; font-weight: 500;
|
||
}
|
||
.batch-status { flex-shrink: 0; }
|
||
.bs {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
font-size: 13px; font-weight: 600; white-space: nowrap;
|
||
padding: 3px 10px; border-radius: 8px; text-decoration: none;
|
||
}
|
||
.bs-wait { color: var(--text-secondary); background: rgba(120,120,128,0.12); }
|
||
.bs-proc { color: #FF9500; background: #FFF8EC; }
|
||
.bs-done { color: #fff; background: var(--blue); }
|
||
.bs-done:hover { filter: brightness(0.95); }
|
||
.bs-err { color: #fff; background: #FF3B30; cursor: help; }
|
||
#batch-actions { display: flex; gap: 10px; flex-wrap: wrap; padding: 0 12px 12px; }
|
||
#zip-btn, #batch-another-btn {
|
||
flex: 1; min-width: 160px; padding: 12px 18px;
|
||
border: none; border-radius: 12px; font-size: 15px; font-weight: 600;
|
||
cursor: pointer; font-family: inherit;
|
||
}
|
||
#zip-btn { background: var(--blue); color: #fff; }
|
||
#zip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
#batch-another-btn { background: rgba(120,120,128,0.14); color: var(--text); }
|
||
|
||
.file-badge {
|
||
background: var(--blue-light);
|
||
color: var(--blue);
|
||
border-radius: 8px;
|
||
padding: 6px 14px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
display: none;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ── Settings ── */
|
||
.setting-row {
|
||
padding: 9px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.setting-row:last-child { border-bottom: none; }
|
||
|
||
.setting-label {
|
||
font-size: 15px;
|
||
flex: 1;
|
||
}
|
||
|
||
/* Segmented control */
|
||
.seg {
|
||
display: flex;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.seg input[type=radio] { display: none; }
|
||
|
||
.seg label {
|
||
padding: 6px 12px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
color: var(--text-secondary);
|
||
transition: background 0.15s, color 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.seg label:not(:last-of-type) { border-right: 1px solid var(--border); }
|
||
|
||
.seg input[type=radio]:checked + label {
|
||
background: var(--blue);
|
||
color: white;
|
||
}
|
||
|
||
/* Stitch grid */
|
||
.stitch-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 6px;
|
||
padding: 8px 14px;
|
||
}
|
||
|
||
.stitch-grid input[type=radio] { display: none; }
|
||
|
||
.stitch-btn {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 2px;
|
||
padding: 6px 4px;
|
||
border: 2px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, background 0.15s;
|
||
font-size: 13px;
|
||
text-align: center;
|
||
}
|
||
|
||
.stitch-btn .icon { font-size: 20px; }
|
||
|
||
.stitch-grid input[type=radio]:checked + .stitch-btn {
|
||
border-color: var(--blue);
|
||
background: var(--blue-light);
|
||
color: var(--blue);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Toggle switch */
|
||
.toggle {
|
||
position: relative;
|
||
width: 51px;
|
||
height: 31px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toggle input { display: none; }
|
||
|
||
.toggle-track {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 100px;
|
||
background: var(--border);
|
||
transition: background 0.2s;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.toggle-track::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 27px;
|
||
height: 27px;
|
||
border-radius: 50%;
|
||
background: white;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.toggle input:checked + .toggle-track {
|
||
background: var(--blue);
|
||
}
|
||
|
||
.toggle input:checked + .toggle-track::after {
|
||
transform: translateX(20px);
|
||
}
|
||
|
||
/* ── Auth banner ── */
|
||
#auth-banner {
|
||
display: none;
|
||
background: linear-gradient(135deg, var(--blue) 0%, #5AC8FA 100%);
|
||
color: white;
|
||
border-radius: var(--radius);
|
||
padding: 24px 20px;
|
||
text-align: center;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
|
||
#auth-banner h3 { font-size: 18px; font-weight: 700; }
|
||
#auth-banner p { font-size: 14px; opacity: 0.9; line-height: 1.4; }
|
||
|
||
/* ── Submit button ── */
|
||
#submit-btn {
|
||
width: 100%;
|
||
padding: 16px;
|
||
background: var(--blue);
|
||
color: white;
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: background 0.15s, transform 0.1s;
|
||
font-family: inherit;
|
||
}
|
||
|
||
#submit-btn:hover { background: var(--blue-hover); }
|
||
#submit-btn:active { transform: scale(0.98); }
|
||
#submit-btn:disabled {
|
||
background: var(--gray);
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
/* ── Progress ── */
|
||
#progress-card { display: none; }
|
||
|
||
.progress-label {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 16px 20px 6px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.progress-label span:last-child { color: var(--blue); font-weight: 600; }
|
||
|
||
.progress-bar-track {
|
||
height: 8px;
|
||
background: var(--gray-light);
|
||
border-radius: 100px;
|
||
margin: 0 20px 6px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-bar-fill {
|
||
height: 100%;
|
||
background: var(--blue);
|
||
border-radius: 100px;
|
||
width: 0%;
|
||
transition: width 0.4s ease;
|
||
}
|
||
|
||
.progress-speed {
|
||
padding: 0 20px 14px;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
min-height: 18px;
|
||
}
|
||
|
||
/* ── Result ── */
|
||
#result-card { display: none; }
|
||
|
||
.result-content {
|
||
padding: 14px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
.success-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
background: #34C759;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.success-icon svg { fill: white; }
|
||
|
||
.result-title { font-size: 20px; font-weight: 700; }
|
||
.result-sub { font-size: 14px; color: var(--text-secondary); }
|
||
|
||
#download-btn {
|
||
padding: 14px 40px;
|
||
background: #34C759;
|
||
color: white;
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: opacity 0.15s;
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
}
|
||
|
||
#download-btn:hover { opacity: 0.85; }
|
||
|
||
#another-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--blue);
|
||
font-size: 15px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
|
||
/* ── Error ── */
|
||
.error-msg {
|
||
background: #FFF0F0;
|
||
border: 1px solid #FFD6D6;
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px 16px;
|
||
color: #C00;
|
||
font-size: 14px;
|
||
display: none;
|
||
}
|
||
|
||
/* ── Info ── */
|
||
.info-msg {
|
||
background: var(--blue-light);
|
||
border: 1px solid #B3D4FF;
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px 16px;
|
||
color: var(--blue);
|
||
font-size: 14px;
|
||
display: none;
|
||
}
|
||
|
||
/* ── Donate card ── */
|
||
#donate-card {
|
||
background: linear-gradient(135deg, #fffbf0 0%, #fff8e6 100%);
|
||
border: 1px solid #ffe0a0;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.donate-inner {
|
||
padding: 16px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18px;
|
||
}
|
||
|
||
.donate-text { flex: 1; min-width: 0; }
|
||
|
||
.donate-icon { font-size: 22px; margin-bottom: 2px; }
|
||
|
||
.donate-title {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #b45309;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.donate-desc {
|
||
font-size: 13px;
|
||
color: #92400e;
|
||
line-height: 1.4;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.donate-buttons {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.donate-btn {
|
||
padding: 7px 14px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.donate-btn:hover { opacity: 0.85; }
|
||
|
||
.donate-btn-sbp {
|
||
background: #1a56db;
|
||
color: white;
|
||
}
|
||
|
||
.donate-qr {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.donate-qr img {
|
||
width: 100px;
|
||
height: 100px;
|
||
border-radius: 8px;
|
||
border: 1px solid #ffe0a0;
|
||
background: white;
|
||
padding: 4px;
|
||
display: block;
|
||
}
|
||
|
||
.donate-qr-hint {
|
||
font-size: 10px;
|
||
color: #92400e;
|
||
text-align: center;
|
||
max-width: 100px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.pay-amt {
|
||
padding: 7px 12px;
|
||
border: 1px solid var(--border, #d0d7e2);
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
color: #92400e;
|
||
}
|
||
.pay-amt.active {
|
||
background: #f59e0b;
|
||
border-color: #f59e0b;
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
@media (max-width: 420px) {
|
||
.donate-inner { flex-direction: column; }
|
||
.donate-qr { flex-direction: row; align-items: center; gap: 12px; width: 100%; }
|
||
.donate-qr img { width: 80px; height: 80px; }
|
||
.donate-qr-hint { max-width: none; }
|
||
/* Батч-список на узких экранах */
|
||
#batch-actions { flex-direction: column; }
|
||
#zip-btn, #batch-another-btn { min-width: 0; width: 100%; }
|
||
.batch-row { padding: 9px 10px; font-size: 13px; }
|
||
.bs { font-size: 12px; padding: 3px 8px; }
|
||
}
|
||
|
||
/* ── SEO section ── */
|
||
#seo-section {
|
||
width: 100%;
|
||
max-width: 640px;
|
||
padding: 0 16px 8px;
|
||
}
|
||
|
||
.seo-heading {
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
margin-bottom: 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
.seo-steps {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.seo-step {
|
||
background: white;
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px 10px;
|
||
text-align: center;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.seo-step-num {
|
||
width: 28px;
|
||
height: 28px;
|
||
background: var(--blue);
|
||
color: white;
|
||
border-radius: 50%;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0 auto 6px;
|
||
}
|
||
|
||
.seo-step-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
margin-bottom: 3px;
|
||
}
|
||
|
||
.seo-step-desc {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.seo-about {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.55;
|
||
text-align: center;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
/* ── Footer ── */
|
||
footer {
|
||
padding: 10px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<!-- Language switcher -->
|
||
<div id="lang-switcher">
|
||
<button id="lang-btn" onclick="toggleLangDropdown(event)" aria-label="Select language">
|
||
<img id="lang-flag-img" class="flag-img" src="/static/flags/ru.png" alt="RU">
|
||
<span class="lang-arrow">▼</span>
|
||
</button>
|
||
<div id="lang-dropdown">
|
||
<button class="lang-option" onclick="setLang('ru')"><img class="flag-img" src="/static/flags/ru.png" alt="RU"> Русский</button>
|
||
<button class="lang-option" onclick="setLang('en')"><img class="flag-img" src="/static/flags/gb.png" alt="GB"> English</button>
|
||
<button class="lang-option" onclick="setLang('de')"><img class="flag-img" src="/static/flags/de.png" alt="DE"> Deutsch</button>
|
||
<button class="lang-option" onclick="setLang('fr')"><img class="flag-img" src="/static/flags/fr.png" alt="FR"> Français</button>
|
||
<button class="lang-option" onclick="setLang('it')"><img class="flag-img" src="/static/flags/it.png" alt="IT"> Italiano</button>
|
||
<button class="lang-option" onclick="setLang('es')"><img class="flag-img" src="/static/flags/es.png" alt="ES"> Español</button>
|
||
<button class="lang-option" onclick="setLang('pt')"><img class="flag-img" src="/static/flags/pt.png" alt="PT"> Português</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="logo-icon">
|
||
<img src="/static/logo.png" width="40" height="40" alt="pdf2scan logo" style="display:block;">
|
||
</div>
|
||
<div>
|
||
<div class="logo-text">pdf2scan</div>
|
||
<div class="logo-subtitle" data-i18n="logo_subtitle">Создание растровых pdf с эффектом сканирования из электронных pdf документов</div>
|
||
</div>
|
||
|
||
<div id="header-auth" style="position:relative;">
|
||
<button id="tg-login-header-btn" onclick="toggleLoginMenu(event)" style="display:none;">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L8.32 13.617l-2.96-.924c-.643-.204-.657-.643.136-.953l11.57-4.461c.537-.194 1.006.131.828.942z"/></svg>
|
||
<span data-i18n="login_btn">Войти</span>
|
||
</button>
|
||
<div id="login-menu" style="display:none;position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid var(--border,#d0d7e2);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.12);padding:6px;z-index:50;min-width:190px;">
|
||
<button onclick="startTgAuth();closeLoginMenu();" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#2AABEE"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L8.32 13.617l-2.96-.924c-.643-.204-.657-.643.136-.953l11.57-4.461c.537-.194 1.006.131.828.942z"/></svg>
|
||
<span data-i18n="login_via_tg">Через Telegram</span>
|
||
</button>
|
||
<button onclick="location.href='/api/auth/yandex/start';" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
|
||
<span style="display:inline-flex;width:18px;height:18px;background:#FC3F1D;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:12px;">Я</span>
|
||
<span data-i18n="login_via_yandex">Через Яндекс</span>
|
||
</button>
|
||
</div>
|
||
<div id="tg-user-info" style="display:none;">
|
||
<img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'">
|
||
<span id="tg-user-name"></span>
|
||
<button id="tg-logout-btn" onclick="doLogout()" data-i18n="logout_btn">Выйти</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
|
||
<!-- Description -->
|
||
<p data-i18n="page_description" style="font-size:13px; line-height:1.45; color:var(--text-secondary); padding: 0 4px;">
|
||
🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.
|
||
</p>
|
||
|
||
<!-- Upload -->
|
||
<div class="card">
|
||
<div class="card-title" data-i18n="card_file">Файл</div>
|
||
<div style="padding: 12px;">
|
||
<div class="drop-zone" id="drop-zone">
|
||
<input type="file" id="file-input" accept=".pdf,.doc,.docx,.zip" multiple style="display:none" />
|
||
<input type="file" id="folder-input" webkitdirectory directory multiple style="display:none" />
|
||
<div class="drop-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
||
</svg>
|
||
</div>
|
||
<div class="drop-title" data-i18n="drop_title">Выберите PDF или Word</div>
|
||
<div class="drop-hint" data-i18n="drop_hint">или перетащите файлы сюда (до 20)</div>
|
||
<div class="file-badge" id="file-badge">—</div>
|
||
</div>
|
||
<div style="text-align:center;margin-top:8px">
|
||
<button type="button" id="folder-btn" style="background:none;border:1px solid var(--border,#2b3a52);color:var(--text-secondary);border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer;font-family:inherit" data-i18n="folder_pick">📁 Папкой</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Batch list (несколько файлов) -->
|
||
<div class="card" id="batch-card" style="display:none">
|
||
<div class="card-title"><span data-i18n="batch_files">Файлы</span> <span id="batch-count"></span></div>
|
||
<div style="padding: 12px;">
|
||
<div id="batch-list"></div>
|
||
</div>
|
||
<div id="batch-actions" style="display:none">
|
||
<button id="zip-btn" type="button" data-i18n="download_all_zip">⬇ Скачать всё архивом</button>
|
||
<button id="batch-another-btn" type="button" onclick="resetApp()" data-i18n="another_btn_batch">Обработать ещё файлы</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stitching -->
|
||
<div class="card">
|
||
<div class="card-title" data-i18n="card_stitch">Тип сшивки</div>
|
||
<div class="stitch-grid">
|
||
|
||
<input type="radio" name="stitching" id="s-thread" value="нитка">
|
||
<label class="stitch-btn" for="s-thread"><span class="icon">🧵</span><span data-i18n="stitch_thread">Нитка</span></label>
|
||
|
||
<input type="radio" name="stitching" id="s-ribbon" value="ленточка">
|
||
<label class="stitch-btn" for="s-ribbon"><span class="icon">🎀</span><span data-i18n="stitch_ribbon">Ленточка</span></label>
|
||
|
||
<input type="radio" name="stitching" id="s-corner" value="уголок">
|
||
<label class="stitch-btn" for="s-corner"><span class="icon">📐</span><span data-i18n="stitch_corner">Уголок</span></label>
|
||
|
||
<input type="radio" name="stitching" id="s-spiral" value="пружинка">
|
||
<label class="stitch-btn" for="s-spiral"><span class="icon">🌀</span><span data-i18n="stitch_spiral">Пружинка</span></label>
|
||
|
||
<input type="radio" name="stitching" id="s-punch" value="дырокол">
|
||
<label class="stitch-btn" for="s-punch"><span class="icon">⭕</span><span data-i18n="stitch_punch">Дырокол</span></label>
|
||
|
||
<input type="radio" name="stitching" id="s-none" value="без сшивки" checked>
|
||
<label class="stitch-btn" for="s-none"><span class="icon">📄</span><span data-i18n="stitch_none">Без сшивки</span></label>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Extra settings -->
|
||
<div class="card">
|
||
<div class="card-title" data-i18n="card_extra">Дополнительно</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="color_mode">Режим цвета</span>
|
||
<div class="seg">
|
||
<input type="radio" name="color" id="c-color" value="color">
|
||
<label for="c-color" data-i18n="color_color">Цветной</label>
|
||
<input type="radio" name="color" id="c-bw" value="bw" checked>
|
||
<label for="c-bw" data-i18n="color_bw">Ч/Б</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="quality">Качество</span>
|
||
<div class="seg">
|
||
<input type="radio" name="quality" id="q-high" value="high">
|
||
<label for="q-high" data-i18n="quality_high">Высокое</label>
|
||
<input type="radio" name="quality" id="q-medium" value="medium" checked>
|
||
<label for="q-medium" data-i18n="quality_medium">Среднее</label>
|
||
<input type="radio" name="quality" id="q-low" value="low">
|
||
<label for="q-low" data-i18n="quality_low">Низкое</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="title_page">Титульный лист</span>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="title-check">
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="page_numbers">Нумерация страниц</span>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="num-check">
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="certified_page">Лист «прошито-пронумеровано»</span>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="flip-check">
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label" data-i18n="pdfa_format">Формат PDF/A</span>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="pdfa-check" checked>
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="setting-row">
|
||
<span class="setting-label">
|
||
<span data-i18n="ocr_label">Распознать текст (OCR)</span>
|
||
<span style="display:block;font-size:11px;color:var(--text-secondary);margin-top:2px" data-i18n="ocr_hint">Скан станет искомым: текст можно копировать и находить поиском</span>
|
||
</span>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="ocr-check">
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Auth banner -->
|
||
<div id="auth-banner">
|
||
<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>
|
||
|
||
<!-- Info -->
|
||
<div class="info-msg" id="info-msg"></div>
|
||
|
||
<!-- Error -->
|
||
<div class="error-msg" id="error-msg"></div>
|
||
|
||
<!-- Submit -->
|
||
<button id="submit-btn" disabled data-i18n="btn_select_file">Выберите файл</button>
|
||
|
||
<!-- Progress -->
|
||
<div class="card" id="progress-card">
|
||
<div class="progress-label">
|
||
<span id="progress-status" data-i18n="progress_processing">Обработка...</span>
|
||
<span id="progress-pct"></span>
|
||
</div>
|
||
<div class="progress-bar-track">
|
||
<div class="progress-bar-fill" id="progress-fill"></div>
|
||
</div>
|
||
<div class="progress-speed" id="progress-speed"></div>
|
||
</div>
|
||
|
||
<!-- Result -->
|
||
<div class="card" id="result-card">
|
||
<div class="result-content">
|
||
<div class="success-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
|
||
<polyline points="20 6 9 17 4 12" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||
</svg>
|
||
</div>
|
||
<div class="result-title" data-i18n="result_title">Готово!</div>
|
||
<div class="result-sub" id="result-sub" data-i18n="result_sub">Ваш PDF обработан и готов к скачиванию.</div>
|
||
<div style="font-size:13px;color:#FF9500;background:#FFF8EC;border-radius:8px;padding:8px 14px;" data-i18n="result_timer">
|
||
⏱ Ссылка активна 30 минут — скачайте файл сейчас
|
||
</div>
|
||
<a id="download-btn" href="#" download data-i18n="download_btn">⬇ Скачать результат</a>
|
||
<button id="another-btn" onclick="resetApp()" data-i18n="another_btn">Обработать ещё один файл</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Donate -->
|
||
<div id="donate-card">
|
||
<div class="donate-inner">
|
||
<div class="donate-text">
|
||
<div class="donate-icon">🍩</div>
|
||
<div class="donate-title" data-i18n="donate_title">Поддержите проект</div>
|
||
<div class="donate-desc" data-i18n="donate_desc">Если сервис полезен — поддержите его работу донатом. Это помогает оплачивать сервер и развивать проект.</div>
|
||
<div class="donate-buttons">
|
||
<a class="donate-btn donate-btn-sbp" href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019cbe28-6b54-7eb8-8a52-189633e608ea" target="_blank" rel="noopener" data-i18n="donate_sbp">СБП / Ozon Pay</a>
|
||
</div>
|
||
<!-- Оплата картой через ЮKassa -->
|
||
<div class="donate-pay" style="margin-top:14px">
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
|
||
<button type="button" class="pay-amt" onclick="setPayAmt(100,this)">100 ₽</button>
|
||
<button type="button" class="pay-amt active" onclick="setPayAmt(300,this)">300 ₽</button>
|
||
<button type="button" class="pay-amt" onclick="setPayAmt(500,this)">500 ₽</button>
|
||
<input type="number" id="pay-amt-custom" min="10" max="100000" placeholder="₽" oninput="onCustomAmt()"
|
||
style="width:76px;padding:7px 8px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-family:inherit;font-size:14px">
|
||
</div>
|
||
<input type="email" id="pay-email" data-i18n-ph="pay_email_ph" placeholder="email для чека"
|
||
style="width:100%;max-width:260px;padding:8px 10px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-family:inherit;font-size:14px;margin-bottom:8px">
|
||
<div>
|
||
<button type="button" id="pay-card-btn" onclick="payDonate()" class="donate-btn" data-i18n="pay_card_btn">💳 Оплатить картой</button>
|
||
</div>
|
||
<div id="pay-msg" style="font-size:12px;color:var(--text-secondary);margin-top:6px"></div>
|
||
</div>
|
||
</div>
|
||
<div class="donate-qr">
|
||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https%3A%2F%2Ffinance.ozon.ru%2Fapps%2Fsbp%2Fozonbankpay%2F019cbe28-6b54-7eb8-8a52-189633e608ea" alt="QR donate" loading="lazy">
|
||
<div class="donate-qr-hint" data-i18n="donate_qr_hint">Сканируйте для оплаты</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Личный кабинет: Мои документы (видно после входа) -->
|
||
<div class="card" id="cabinet-card" style="display:none">
|
||
<div class="card-title" data-i18n="cabinet_title">Мои документы</div>
|
||
<div style="padding: 12px;">
|
||
<div id="cabinet-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<section id="seo-section">
|
||
<h2 class="seo-heading" data-i18n="seo_title">Как работает pdf2scan</h2>
|
||
<div class="seo-steps">
|
||
<div class="seo-step">
|
||
<div class="seo-step-num">1</div>
|
||
<div class="seo-step-title" data-i18n="seo_step1_title">Загрузите файл</div>
|
||
<div class="seo-step-desc" data-i18n="seo_step1_desc">PDF или Word — перетащите или выберите с устройства</div>
|
||
</div>
|
||
<div class="seo-step">
|
||
<div class="seo-step-num">2</div>
|
||
<div class="seo-step-title" data-i18n="seo_step2_title">Настройте параметры</div>
|
||
<div class="seo-step-desc" data-i18n="seo_step2_desc">Тип сшивки, цвет, качество, нумерация и другие опции</div>
|
||
</div>
|
||
<div class="seo-step">
|
||
<div class="seo-step-num">3</div>
|
||
<div class="seo-step-title" data-i18n="seo_step3_title">Скачайте результат</div>
|
||
<div class="seo-step-desc" data-i18n="seo_step3_desc">Готовый PDF со сканом доступен для скачивания 30 минут</div>
|
||
</div>
|
||
</div>
|
||
<p class="seo-about" data-i18n="seo_about">pdf2scan — бесплатный онлайн-инструмент для создания растрового PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Идеально для подготовки документов к сдаче в архив, нотариусу или государственные органы. Без регистрации, без установки программ.</p>
|
||
</section>
|
||
|
||
<footer>
|
||
pdf2scan — <span data-i18n="footer_text">превращаем PDF в скан</span> | <a href="https://t.me/pdf2scan_bot" style="color:var(--blue); text-decoration:none;" data-i18n="footer_tg">Telegram-бот</a> | <a href="/developers" style="color:var(--blue); text-decoration:none;" data-i18n="footer_api">API для бизнеса</a>
|
||
</footer>
|
||
|
||
<script>
|
||
// ── Translations ──────────────────────────────────────────────────────────
|
||
const LANGS = {
|
||
ru: { img: '/static/flags/ru.png', alt: 'RU', name: 'Русский' },
|
||
en: { img: '/static/flags/gb.png', alt: 'GB', name: 'English' },
|
||
de: { img: '/static/flags/de.png', alt: 'DE', name: 'Deutsch' },
|
||
fr: { img: '/static/flags/fr.png', alt: 'FR', name: 'Français' },
|
||
it: { img: '/static/flags/it.png', alt: 'IT', name: 'Italiano' },
|
||
es: { img: '/static/flags/es.png', alt: 'ES', name: 'Español' },
|
||
pt: { img: '/static/flags/pt.png', alt: 'PT', name: 'Português' },
|
||
};
|
||
|
||
const TR = {
|
||
ru: {
|
||
page_title: 'pdf2scan — онлайн сканирование PDF',
|
||
logo_subtitle: 'Создание растровых pdf с эффектом сканирования из электронных pdf документов',
|
||
login_btn: 'Войти',
|
||
login_via_tg: 'Через Telegram',
|
||
login_via_yandex: 'Через Яндекс',
|
||
err_login: 'Не удалось войти. Попробуйте снова.',
|
||
logout_btn: 'Выйти',
|
||
page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.',
|
||
card_file: 'Файл',
|
||
drop_title: 'Выберите PDF или Word',
|
||
drop_hint: 'или перетащите файлы сюда (до 20)',
|
||
file_selected: 'Файл выбран',
|
||
batch_files: 'Файлы',
|
||
files_selected: (n) => `Выбрано ${n} ${pluralizeRu(n, 'файл', 'файла', 'файлов')}`,
|
||
btn_process_n: (n) => `Обработать ${n} ${pluralizeRu(n, 'файл', 'файла', 'файлов')}`,
|
||
err_too_many: (max) => `Слишком много файлов — максимум ${max}. Лишние не добавлены.`,
|
||
st_uploading: 'Загрузка…',
|
||
st_queued: 'В очереди',
|
||
st_processing: 'Обработка…',
|
||
st_done: 'Готово',
|
||
st_error: 'Ошибка',
|
||
download_all_zip: '⬇ Скачать всё архивом',
|
||
another_btn_batch: 'Обработать ещё файлы',
|
||
zip_preparing: 'Архивируем…',
|
||
err_zip: 'Не удалось создать архив',
|
||
card_stitch: 'Тип сшивки',
|
||
stitch_thread: 'Нитка',
|
||
stitch_ribbon: 'Ленточка',
|
||
stitch_corner: 'Уголок',
|
||
stitch_spiral: 'Пружинка',
|
||
stitch_punch: 'Дырокол',
|
||
stitch_none: 'Без сшивки',
|
||
card_extra: 'Дополнительно',
|
||
color_mode: 'Режим цвета',
|
||
color_color: 'Цветной',
|
||
color_bw: 'Ч/Б',
|
||
quality: 'Качество',
|
||
quality_high: 'Высокое',
|
||
quality_medium: 'Среднее',
|
||
quality_low: 'Низкое',
|
||
title_page: 'Титульный лист',
|
||
page_numbers: 'Нумерация страниц',
|
||
certified_page: 'Лист «прошито-пронумеровано»',
|
||
pdfa_format: 'Формат PDF/A',
|
||
ocr_label: 'Распознать текст (OCR)',
|
||
ocr_hint: 'Скан станет искомым: текст можно копировать и находить поиском',
|
||
cabinet_title: 'Мои документы',
|
||
cabinet_empty: 'Пока нет обработанных документов',
|
||
cabinet_dl: '⬇ Скачать',
|
||
cabinet_expired: 'истёк',
|
||
cabinet_processing: 'обработка',
|
||
folder_pick: '📁 Папкой',
|
||
auth_required: '🔐 Требуется авторизация',
|
||
auth_required_desc:'Для файлов больше 100 страниц или 50 МБ необходим вход через Telegram.',
|
||
login_tg: 'Войти через Telegram',
|
||
btn_select_file: 'Выберите файл',
|
||
btn_process: 'Обработать PDF',
|
||
btn_sending: 'Отправка...',
|
||
btn_processing: 'Обработка...',
|
||
btn_auth_required: 'Необходима авторизация',
|
||
progress_loading: 'Загрузка файла...',
|
||
progress_uploaded: 'Файл загружен, ожидание обработки...',
|
||
progress_processing:'Обработка...',
|
||
result_title: 'Готово!',
|
||
result_sub: 'Ваш PDF обработан и готов к скачиванию.',
|
||
result_timer: '⏱ Ссылка активна 30 минут — скачайте файл сейчас',
|
||
download_btn: '⬇ Скачать результат',
|
||
another_btn: 'Обработать ещё один файл',
|
||
footer_text: 'превращаем PDF в скан',
|
||
footer_tg: 'Telegram-бот',
|
||
footer_api: 'API для бизнеса',
|
||
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_canceled: 'Платёж отменён',
|
||
seo_title: 'Как работает pdf2scan',
|
||
seo_step1_title: 'Загрузите файл',
|
||
seo_step1_desc: 'PDF или Word — перетащите или выберите с устройства',
|
||
seo_step2_title: 'Настройте параметры',
|
||
seo_step2_desc: 'Тип сшивки, цвет, качество, нумерация и другие опции',
|
||
seo_step3_title: 'Скачайте результат',
|
||
seo_step3_desc: 'Готовый PDF со сканом доступен для скачивания 30 минут',
|
||
seo_about: 'pdf2scan — бесплатный онлайн-инструмент для создания растрового PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Идеально для подготовки документов к сдаче в архив, нотариусу или государственные органы. Без регистрации, без установки программ.',
|
||
},
|
||
en: {
|
||
page_title: 'pdf2scan — online PDF scan',
|
||
logo_subtitle: 'Convert PDF to scanned document online',
|
||
login_btn: 'Login',
|
||
login_via_tg: 'Via Telegram',
|
||
login_via_yandex: 'Via Yandex',
|
||
err_login: 'Login failed. Please try again.',
|
||
logout_btn: 'Logout',
|
||
page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.',
|
||
card_file: 'File',
|
||
drop_title: 'Select PDF or Word',
|
||
drop_hint: 'or drag and drop files here (up to 20)',
|
||
file_selected: 'File selected',
|
||
batch_files: 'Files',
|
||
files_selected: (n) => `${n} ${n === 1 ? 'file' : 'files'} selected`,
|
||
btn_process_n: (n) => `Process ${n} ${n === 1 ? 'file' : 'files'}`,
|
||
err_too_many: (max) => `Too many files — maximum ${max}. Extra files were not added.`,
|
||
st_uploading: 'Uploading…',
|
||
st_queued: 'Queued',
|
||
st_processing: 'Processing…',
|
||
st_done: 'Ready',
|
||
st_error: 'Error',
|
||
download_all_zip: '⬇ Download all as ZIP',
|
||
another_btn_batch: 'Process more files',
|
||
zip_preparing: 'Zipping…',
|
||
err_zip: 'Failed to create archive',
|
||
card_stitch: 'Binding type',
|
||
stitch_thread: 'Thread',
|
||
stitch_ribbon: 'Ribbon',
|
||
stitch_corner: 'Corner',
|
||
stitch_spiral: 'Spiral',
|
||
stitch_punch: 'Hole punch',
|
||
stitch_none: 'No binding',
|
||
card_extra: 'Options',
|
||
color_mode: 'Color mode',
|
||
color_color: 'Color',
|
||
color_bw: 'B&W',
|
||
quality: 'Quality',
|
||
quality_high: 'High',
|
||
quality_medium: 'Medium',
|
||
quality_low: 'Low',
|
||
title_page: 'Title page',
|
||
page_numbers: 'Page numbers',
|
||
certified_page: 'Certification page',
|
||
pdfa_format: 'PDF/A format',
|
||
ocr_label: 'Recognize text (OCR)',
|
||
ocr_hint: 'The scan becomes searchable — copy and find text',
|
||
cabinet_title: 'My documents',
|
||
cabinet_empty: 'No processed documents yet',
|
||
cabinet_dl: '⬇ Download',
|
||
cabinet_expired: 'expired',
|
||
cabinet_processing: 'processing',
|
||
folder_pick: '📁 Folder',
|
||
auth_required: '🔐 Authorization required',
|
||
auth_required_desc:'Files with more than 100 pages or 50 MB require Telegram login.',
|
||
login_tg: 'Login with Telegram',
|
||
btn_select_file: 'Select file',
|
||
btn_process: 'Process PDF',
|
||
btn_sending: 'Uploading...',
|
||
btn_processing: 'Processing...',
|
||
btn_auth_required: 'Authorization required',
|
||
progress_loading: 'Uploading file...',
|
||
progress_uploaded: 'File uploaded, waiting for processing...',
|
||
progress_processing:'Processing...',
|
||
result_title: 'Done!',
|
||
result_sub: 'Your PDF has been processed and is ready to download.',
|
||
result_timer: '⏱ Link active for 30 minutes — download now',
|
||
download_btn: '⬇ Download result',
|
||
another_btn: 'Process another file',
|
||
footer_text: 'convert PDF to scan',
|
||
footer_tg: 'Telegram bot',
|
||
footer_api: 'API for business',
|
||
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_canceled: 'Payment canceled',
|
||
seo_title: 'How pdf2scan works',
|
||
seo_step1_title: 'Upload your file',
|
||
seo_step1_desc: 'PDF or Word — drag & drop or choose from device',
|
||
seo_step2_title: 'Configure options',
|
||
seo_step2_desc: 'Binding type, color, quality, page numbers and more',
|
||
seo_step3_title: 'Download the result',
|
||
seo_step3_desc: 'Your scanned PDF is ready to download for 30 minutes',
|
||
seo_about: 'pdf2scan is a free online tool to create raster PDFs with a scan effect. Supports thread, ribbon, corner, spiral and hole-punch binding. Perfect for preparing documents for archives, notaries or government agencies. No registration, no software required.',
|
||
},
|
||
de: {
|
||
page_title: 'pdf2scan — Online-PDF-Scan',
|
||
logo_subtitle: 'PDF online in gescannte Dokumente umwandeln',
|
||
login_btn: 'Anmelden',
|
||
login_via_tg: 'Über Telegram',
|
||
login_via_yandex: 'Über Yandex',
|
||
err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
|
||
logout_btn: 'Abmelden',
|
||
page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.',
|
||
card_file: 'Datei',
|
||
drop_title: 'PDF oder Word auswählen',
|
||
drop_hint: 'oder Dateien hier ablegen (bis zu 20)',
|
||
file_selected: 'Datei ausgewählt',
|
||
batch_files: 'Dateien',
|
||
files_selected: (n) => `${n} ${n === 1 ? 'Datei' : 'Dateien'} ausgewählt`,
|
||
btn_process_n: (n) => `${n} ${n === 1 ? 'Datei' : 'Dateien'} verarbeiten`,
|
||
err_too_many: (max) => `Zu viele Dateien — maximal ${max}. Überzählige wurden nicht hinzugefügt.`,
|
||
st_uploading: 'Hochladen…',
|
||
st_queued: 'In Warteschlange',
|
||
st_processing: 'Verarbeitung…',
|
||
st_done: 'Fertig',
|
||
st_error: 'Fehler',
|
||
download_all_zip: '⬇ Alle als ZIP herunterladen',
|
||
another_btn_batch: 'Weitere Dateien verarbeiten',
|
||
zip_preparing: 'Archivierung…',
|
||
err_zip: 'Archiv konnte nicht erstellt werden',
|
||
card_stitch: 'Bindungstyp',
|
||
stitch_thread: 'Faden',
|
||
stitch_ribbon: 'Band',
|
||
stitch_corner: 'Ecke',
|
||
stitch_spiral: 'Spirale',
|
||
stitch_punch: 'Lochstanzer',
|
||
stitch_none: 'Ohne Bindung',
|
||
card_extra: 'Optionen',
|
||
color_mode: 'Farbmodus',
|
||
color_color: 'Farbe',
|
||
color_bw: 'S/W',
|
||
quality: 'Qualität',
|
||
quality_high: 'Hoch',
|
||
quality_medium: 'Mittel',
|
||
quality_low: 'Niedrig',
|
||
title_page: 'Titelseite',
|
||
page_numbers: 'Seitennummerierung',
|
||
certified_page: 'Zertifizierungsseite',
|
||
pdfa_format: 'PDF/A-Format',
|
||
ocr_label: 'Text erkennen (OCR)',
|
||
ocr_hint: 'Der Scan wird durchsuchbar — Text kopieren und finden',
|
||
cabinet_title: 'Meine Dokumente',
|
||
cabinet_empty: 'Noch keine verarbeiteten Dokumente',
|
||
cabinet_dl: '⬇ Herunterladen',
|
||
cabinet_expired: 'abgelaufen',
|
||
cabinet_processing: 'Verarbeitung',
|
||
folder_pick: '📁 Ordner',
|
||
auth_required: '🔐 Anmeldung erforderlich',
|
||
auth_required_desc:'Dateien mit mehr als 100 Seiten oder 50 MB erfordern eine Telegram-Anmeldung.',
|
||
login_tg: 'Mit Telegram anmelden',
|
||
btn_select_file: 'Datei auswählen',
|
||
btn_process: 'PDF verarbeiten',
|
||
btn_sending: 'Hochladen...',
|
||
btn_processing: 'Verarbeitung...',
|
||
btn_auth_required: 'Anmeldung erforderlich',
|
||
progress_loading: 'Datei wird hochgeladen...',
|
||
progress_uploaded: 'Datei hochgeladen, warte auf Verarbeitung...',
|
||
progress_processing:'Verarbeitung...',
|
||
result_title: 'Fertig!',
|
||
result_sub: 'Ihr PDF wurde verarbeitet und ist zum Download bereit.',
|
||
result_timer: '⏱ Link 30 Minuten aktiv — jetzt herunterladen',
|
||
download_btn: '⬇ Ergebnis herunterladen',
|
||
another_btn: 'Eine weitere Datei verarbeiten',
|
||
footer_text: 'PDF in Scan umwandeln',
|
||
footer_tg: 'Telegram-Bot',
|
||
footer_api: 'API für Unternehmen',
|
||
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_canceled: 'Zahlung storniert',
|
||
seo_title: 'So funktioniert pdf2scan',
|
||
seo_step1_title: 'Datei hochladen',
|
||
seo_step1_desc: 'PDF oder Word — per Drag & Drop oder vom Gerät',
|
||
seo_step2_title: 'Optionen einstellen',
|
||
seo_step2_desc: 'Bindungstyp, Farbe, Qualität, Seitennummern und mehr',
|
||
seo_step3_title: 'Ergebnis herunterladen',
|
||
seo_step3_desc: 'Ihr gescanntes PDF steht 30 Minuten zum Download bereit',
|
||
seo_about: 'pdf2scan ist ein kostenloses Online-Tool zur Erstellung von Raster-PDFs mit Scan-Effekt. Unterstützt Faden-, Band-, Ecken-, Spiral- und Lochbindung. Perfekt für die Vorbereitung von Dokumenten für Archive, Notare oder Behörden. Keine Registrierung, keine Software erforderlich.',
|
||
},
|
||
fr: {
|
||
page_title: 'pdf2scan — scan PDF en ligne',
|
||
logo_subtitle: 'Convertir des PDF en documents scannés en ligne',
|
||
login_btn: 'Connexion',
|
||
login_via_tg: 'Via Telegram',
|
||
login_via_yandex: 'Via Yandex',
|
||
err_login: 'Échec de connexion. Réessayez.',
|
||
logout_btn: 'Déconnexion',
|
||
page_description: '🧷 Sélectionnez le type de reliure et les paramètres optionnels, puis téléchargez votre fichier — obtenez un scan prêt à l\'emploi.',
|
||
card_file: 'Fichier',
|
||
drop_title: 'Sélectionner PDF ou Word',
|
||
drop_hint: 'ou déposez les fichiers ici (max. 20)',
|
||
file_selected: 'Fichier sélectionné',
|
||
batch_files: 'Fichiers',
|
||
files_selected: (n) => `${n} fichier${n === 1 ? '' : 's'} sélectionné${n === 1 ? '' : 's'}`,
|
||
btn_process_n: (n) => `Traiter ${n} fichier${n === 1 ? '' : 's'}`,
|
||
err_too_many: (max) => `Trop de fichiers — maximum ${max}. Les fichiers en trop n’ont 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 d’autres fichiers',
|
||
zip_preparing: 'Archivage…',
|
||
err_zip: 'Échec de création de l’archive',
|
||
card_stitch: 'Type de reliure',
|
||
stitch_thread: 'Fil',
|
||
stitch_ribbon: 'Ruban',
|
||
stitch_corner: 'Coin',
|
||
stitch_spiral: 'Spirale',
|
||
stitch_punch: 'Perforateur',
|
||
stitch_none: 'Sans reliure',
|
||
card_extra: 'Options',
|
||
color_mode: 'Mode couleur',
|
||
color_color: 'Couleur',
|
||
color_bw: 'N/B',
|
||
quality: 'Qualité',
|
||
quality_high: 'Haute',
|
||
quality_medium: 'Moyenne',
|
||
quality_low: 'Basse',
|
||
title_page: 'Page de titre',
|
||
page_numbers: 'Numérotation des pages',
|
||
certified_page: 'Page de certification',
|
||
pdfa_format: 'Format PDF/A',
|
||
ocr_label: 'Reconnaître le texte (OCR)',
|
||
ocr_hint: 'Le scan devient consultable — copier et rechercher le texte',
|
||
cabinet_title: 'Mes documents',
|
||
cabinet_empty: 'Aucun document traité',
|
||
cabinet_dl: '⬇ Télécharger',
|
||
cabinet_expired: 'expiré',
|
||
cabinet_processing: 'traitement',
|
||
folder_pick: '📁 Dossier',
|
||
auth_required: '🔐 Autorisation requise',
|
||
auth_required_desc:'Les fichiers de plus de 100 pages ou 50 Mo nécessitent une connexion Telegram.',
|
||
login_tg: 'Se connecter via Telegram',
|
||
btn_select_file: 'Sélectionner un fichier',
|
||
btn_process: 'Traiter le PDF',
|
||
btn_sending: 'Envoi...',
|
||
btn_processing: 'Traitement...',
|
||
btn_auth_required: 'Autorisation requise',
|
||
progress_loading: 'Téléchargement du fichier...',
|
||
progress_uploaded: 'Fichier téléchargé, en attente de traitement...',
|
||
progress_processing:'Traitement...',
|
||
result_title: 'Terminé!',
|
||
result_sub: 'Votre PDF a été traité et est prêt à être téléchargé.',
|
||
result_timer: '⏱ Lien actif pendant 30 minutes — téléchargez maintenant',
|
||
download_btn: '⬇ Télécharger le résultat',
|
||
another_btn: 'Traiter un autre fichier',
|
||
footer_text: 'convertir PDF en scan',
|
||
footer_tg: 'Bot Telegram',
|
||
footer_api: 'API pour entreprises',
|
||
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_canceled: 'Paiement annulé',
|
||
seo_title: 'Comment fonctionne pdf2scan',
|
||
seo_step1_title: 'Téléchargez votre fichier',
|
||
seo_step1_desc: 'PDF ou Word — glisser-déposer ou choisir depuis l\'appareil',
|
||
seo_step2_title: 'Configurez les options',
|
||
seo_step2_desc: 'Type de reliure, couleur, qualité, numérotation et plus',
|
||
seo_step3_title: 'Téléchargez le résultat',
|
||
seo_step3_desc: 'Votre PDF scanné est disponible 30 minutes au téléchargement',
|
||
seo_about: 'pdf2scan est un outil en ligne gratuit pour créer des PDF raster avec effet de scan. Supporte la reliure par fil, ruban, coin, spirale et perforateur. Idéal pour préparer des documents pour les archives, notaires ou administrations. Sans inscription, sans logiciel.',
|
||
},
|
||
it: {
|
||
page_title: 'pdf2scan — scansione PDF online',
|
||
logo_subtitle: 'Converti PDF in documenti scansionati online',
|
||
login_btn: 'Accedi',
|
||
login_via_tg: 'Via Telegram',
|
||
login_via_yandex: 'Via Yandex',
|
||
err_login: 'Accesso non riuscito. Riprova.',
|
||
logout_btn: 'Esci',
|
||
page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.',
|
||
card_file: 'File',
|
||
drop_title: 'Seleziona PDF o Word',
|
||
drop_hint: 'o trascina i file qui (fino a 20)',
|
||
file_selected: 'File selezionato',
|
||
batch_files: 'File',
|
||
files_selected: (n) => `${n} file selezionat${n === 1 ? 'o' : 'i'}`,
|
||
btn_process_n: (n) => `Elabora ${n} file`,
|
||
err_too_many: (max) => `Troppi file — massimo ${max}. I file in eccesso non sono stati aggiunti.`,
|
||
st_uploading: 'Caricamento…',
|
||
st_queued: 'In coda',
|
||
st_processing: 'Elaborazione…',
|
||
st_done: 'Pronto',
|
||
st_error: 'Errore',
|
||
download_all_zip: '⬇ Scarica tutto in ZIP',
|
||
another_btn_batch: 'Elabora altri file',
|
||
zip_preparing: 'Compressione…',
|
||
err_zip: 'Impossibile creare l’archivio',
|
||
card_stitch: 'Tipo di rilegatura',
|
||
stitch_thread: 'Filo',
|
||
stitch_ribbon: 'Nastro',
|
||
stitch_corner: 'Angolo',
|
||
stitch_spiral: 'Spirale',
|
||
stitch_punch: 'Perforatore',
|
||
stitch_none: 'Senza rilegatura',
|
||
card_extra: 'Opzioni',
|
||
color_mode: 'Modalità colore',
|
||
color_color: 'Colore',
|
||
color_bw: 'B/N',
|
||
quality: 'Qualità',
|
||
quality_high: 'Alta',
|
||
quality_medium: 'Media',
|
||
quality_low: 'Bassa',
|
||
title_page: 'Pagina del titolo',
|
||
page_numbers: 'Numerazione pagine',
|
||
certified_page: 'Pagina di certificazione',
|
||
pdfa_format: 'Formato PDF/A',
|
||
ocr_label: 'Riconosci testo (OCR)',
|
||
ocr_hint: 'La scansione diventa ricercabile — copia e cerca il testo',
|
||
cabinet_title: 'I miei documenti',
|
||
cabinet_empty: 'Nessun documento elaborato',
|
||
cabinet_dl: '⬇ Scarica',
|
||
cabinet_expired: 'scaduto',
|
||
cabinet_processing: 'elaborazione',
|
||
folder_pick: '📁 Cartella',
|
||
auth_required: '🔐 Autorizzazione richiesta',
|
||
auth_required_desc:'I file con più di 100 pagine o 50 MB richiedono il login Telegram.',
|
||
login_tg: 'Accedi con Telegram',
|
||
btn_select_file: 'Seleziona file',
|
||
btn_process: 'Elabora PDF',
|
||
btn_sending: 'Caricamento...',
|
||
btn_processing: 'Elaborazione...',
|
||
btn_auth_required: 'Autorizzazione richiesta',
|
||
progress_loading: 'Caricamento file...',
|
||
progress_uploaded: 'File caricato, in attesa di elaborazione...',
|
||
progress_processing:'Elaborazione...',
|
||
result_title: 'Pronto!',
|
||
result_sub: 'Il tuo PDF è stato elaborato ed è pronto per il download.',
|
||
result_timer: '⏱ Link attivo per 30 minuti — scarica ora',
|
||
download_btn: '⬇ Scarica il risultato',
|
||
another_btn: 'Elabora un altro file',
|
||
footer_text: 'converti PDF in scansione',
|
||
footer_tg: 'Bot Telegram',
|
||
footer_api: 'API per aziende',
|
||
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_canceled: 'Pagamento annullato',
|
||
seo_title: 'Come funziona pdf2scan',
|
||
seo_step1_title: 'Carica il file',
|
||
seo_step1_desc: 'PDF o Word — trascina o scegli dal dispositivo',
|
||
seo_step2_title: 'Configura le opzioni',
|
||
seo_step2_desc: 'Tipo di rilegatura, colore, qualità, numerazione e altro',
|
||
seo_step3_title: 'Scarica il risultato',
|
||
seo_step3_desc: 'Il tuo PDF scansionato è disponibile per 30 minuti',
|
||
seo_about: 'pdf2scan è uno strumento online gratuito per creare PDF raster con effetto scansione. Supporta rilegatura con filo, nastro, angolo, spirale e perforatore. Ideale per preparare documenti per archivi, notai o enti pubblici. Senza registrazione, senza software.',
|
||
},
|
||
es: {
|
||
page_title: 'pdf2scan — escaneo PDF en línea',
|
||
logo_subtitle: 'Convierte PDF en documentos escaneados en línea',
|
||
login_btn: 'Iniciar sesión',
|
||
login_via_tg: 'Con Telegram',
|
||
login_via_yandex: 'Con Yandex',
|
||
err_login: 'Error de inicio de sesión. Inténtalo de nuevo.',
|
||
logout_btn: 'Cerrar sesión',
|
||
page_description: '🧷 Selecciona el tipo de encuadernación y configuraciones opcionales, luego sube tu archivo — obtén un escaneo listo.',
|
||
card_file: 'Archivo',
|
||
drop_title: 'Seleccionar PDF o Word',
|
||
drop_hint: 'o arrastra los archivos aquí (hasta 20)',
|
||
file_selected: 'Archivo seleccionado',
|
||
batch_files: 'Archivos',
|
||
files_selected: (n) => `${n} archivo${n === 1 ? '' : 's'} seleccionado${n === 1 ? '' : 's'}`,
|
||
btn_process_n: (n) => `Procesar ${n} archivo${n === 1 ? '' : 's'}`,
|
||
err_too_many: (max) => `Demasiados archivos — máximo ${max}. Los archivos sobrantes no se añadieron.`,
|
||
st_uploading: 'Subiendo…',
|
||
st_queued: 'En cola',
|
||
st_processing: 'Procesando…',
|
||
st_done: 'Listo',
|
||
st_error: 'Error',
|
||
download_all_zip: '⬇ Descargar todo en ZIP',
|
||
another_btn_batch: 'Procesar más archivos',
|
||
zip_preparing: 'Comprimiendo…',
|
||
err_zip: 'No se pudo crear el archivo',
|
||
card_stitch: 'Tipo de encuadernación',
|
||
stitch_thread: 'Hilo',
|
||
stitch_ribbon: 'Cinta',
|
||
stitch_corner: 'Esquina',
|
||
stitch_spiral: 'Espiral',
|
||
stitch_punch: 'Perforadora',
|
||
stitch_none: 'Sin encuadernación',
|
||
card_extra: 'Opciones',
|
||
color_mode: 'Modo de color',
|
||
color_color: 'Color',
|
||
color_bw: 'B/N',
|
||
quality: 'Calidad',
|
||
quality_high: 'Alta',
|
||
quality_medium: 'Media',
|
||
quality_low: 'Baja',
|
||
title_page: 'Página de título',
|
||
page_numbers: 'Numeración de páginas',
|
||
certified_page: 'Página de certificación',
|
||
pdfa_format: 'Formato PDF/A',
|
||
ocr_label: 'Reconocer texto (OCR)',
|
||
ocr_hint: 'El escaneo se vuelve buscable — copia y busca texto',
|
||
cabinet_title: 'Mis documentos',
|
||
cabinet_empty: 'Aún no hay documentos procesados',
|
||
cabinet_dl: '⬇ Descargar',
|
||
cabinet_expired: 'expirado',
|
||
cabinet_processing: 'procesando',
|
||
folder_pick: '📁 Carpeta',
|
||
auth_required: '🔐 Autorización requerida',
|
||
auth_required_desc:'Los archivos con más de 100 páginas o 50 MB requieren inicio de sesión en Telegram.',
|
||
login_tg: 'Iniciar sesión con Telegram',
|
||
btn_select_file: 'Seleccionar archivo',
|
||
btn_process: 'Procesar PDF',
|
||
btn_sending: 'Enviando...',
|
||
btn_processing: 'Procesando...',
|
||
btn_auth_required: 'Autorización requerida',
|
||
progress_loading: 'Subiendo archivo...',
|
||
progress_uploaded: 'Archivo subido, esperando procesamiento...',
|
||
progress_processing:'Procesando...',
|
||
result_title: '¡Listo!',
|
||
result_sub: 'Tu PDF ha sido procesado y está listo para descargar.',
|
||
result_timer: '⏱ Enlace activo por 30 minutos — descarga ahora',
|
||
download_btn: '⬇ Descargar resultado',
|
||
another_btn: 'Procesar otro archivo',
|
||
footer_text: 'convierte PDF en escaneo',
|
||
footer_tg: 'Bot de Telegram',
|
||
footer_api: 'API para empresas',
|
||
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_canceled: 'Pago cancelado',
|
||
seo_title: 'Cómo funciona pdf2scan',
|
||
seo_step1_title: 'Sube tu archivo',
|
||
seo_step1_desc: 'PDF o Word — arrastra o elige desde el dispositivo',
|
||
seo_step2_title: 'Configura las opciones',
|
||
seo_step2_desc: 'Tipo de encuadernación, color, calidad, numeración y más',
|
||
seo_step3_title: 'Descarga el resultado',
|
||
seo_step3_desc: 'Tu PDF escaneado está disponible 30 minutos para descargar',
|
||
seo_about: 'pdf2scan es una herramienta online gratuita para crear PDFs rasterizados con efecto de escáner. Soporta encuadernación con hilo, cinta, esquina, espiral y perforadora. Ideal para preparar documentos para archivos, notarios o administraciones. Sin registro, sin software.',
|
||
},
|
||
pt: {
|
||
page_title: 'pdf2scan — digitalização PDF online',
|
||
logo_subtitle: 'Converta PDF em documentos digitalizados online',
|
||
login_btn: 'Entrar',
|
||
login_via_tg: 'Via Telegram',
|
||
login_via_yandex: 'Via Yandex',
|
||
err_login: 'Falha no login. Tente novamente.',
|
||
logout_btn: 'Sair',
|
||
page_description: '🧷 Selecione o tipo de encadernação e configurações opcionais, depois faça upload do arquivo — obtenha uma digitalização pronta.',
|
||
card_file: 'Arquivo',
|
||
drop_title: 'Selecionar PDF ou Word',
|
||
drop_hint: 'ou arraste os arquivos aqui (até 20)',
|
||
file_selected: 'Arquivo selecionado',
|
||
batch_files: 'Arquivos',
|
||
files_selected: (n) => `${n} arquivo${n === 1 ? '' : 's'} selecionado${n === 1 ? '' : 's'}`,
|
||
btn_process_n: (n) => `Processar ${n} arquivo${n === 1 ? '' : 's'}`,
|
||
err_too_many: (max) => `Arquivos demais — máximo ${max}. Os arquivos extras não foram adicionados.`,
|
||
st_uploading: 'Enviando…',
|
||
st_queued: 'Na fila',
|
||
st_processing: 'Processando…',
|
||
st_done: 'Pronto',
|
||
st_error: 'Erro',
|
||
download_all_zip: '⬇ Baixar tudo em ZIP',
|
||
another_btn_batch: 'Processar mais arquivos',
|
||
zip_preparing: 'Compactando…',
|
||
err_zip: 'Não foi possível criar o arquivo',
|
||
card_stitch: 'Tipo de encadernação',
|
||
stitch_thread: 'Fio',
|
||
stitch_ribbon: 'Fita',
|
||
stitch_corner: 'Canto',
|
||
stitch_spiral: 'Espiral',
|
||
stitch_punch: 'Furador',
|
||
stitch_none: 'Sem encadernação',
|
||
card_extra: 'Opções',
|
||
color_mode: 'Modo de cor',
|
||
color_color: 'Colorido',
|
||
color_bw: 'P/B',
|
||
quality: 'Qualidade',
|
||
quality_high: 'Alta',
|
||
quality_medium: 'Média',
|
||
quality_low: 'Baixa',
|
||
title_page: 'Página de título',
|
||
page_numbers: 'Numeração de páginas',
|
||
certified_page: 'Página de certificação',
|
||
pdfa_format: 'Formato PDF/A',
|
||
ocr_label: 'Reconhecer texto (OCR)',
|
||
ocr_hint: 'A digitalização fica pesquisável — copie e encontre texto',
|
||
cabinet_title: 'Meus documentos',
|
||
cabinet_empty: 'Ainda não há documentos processados',
|
||
cabinet_dl: '⬇ Baixar',
|
||
cabinet_expired: 'expirado',
|
||
cabinet_processing: 'processando',
|
||
folder_pick: '📁 Pasta',
|
||
auth_required: '🔐 Autorização necessária',
|
||
auth_required_desc:'Arquivos com mais de 100 páginas ou 50 MB requerem login no Telegram.',
|
||
login_tg: 'Entrar com Telegram',
|
||
btn_select_file: 'Selecionar arquivo',
|
||
btn_process: 'Processar PDF',
|
||
btn_sending: 'Enviando...',
|
||
btn_processing: 'Processando...',
|
||
btn_auth_required: 'Autorização necessária',
|
||
progress_loading: 'Fazendo upload...',
|
||
progress_uploaded: 'Arquivo enviado, aguardando processamento...',
|
||
progress_processing:'Processando...',
|
||
result_title: 'Pronto!',
|
||
result_sub: 'Seu PDF foi processado e está pronto para download.',
|
||
result_timer: '⏱ Link ativo por 30 minutos — baixe agora',
|
||
download_btn: '⬇ Baixar resultado',
|
||
another_btn: 'Processar outro arquivo',
|
||
footer_text: 'converta PDF em digitalização',
|
||
footer_tg: 'Bot do Telegram',
|
||
footer_api: 'API para empresas',
|
||
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_canceled: 'Pagamento cancelado',
|
||
seo_title: 'Como funciona o pdf2scan',
|
||
seo_step1_title: 'Envie seu arquivo',
|
||
seo_step1_desc: 'PDF ou Word — arraste ou escolha do dispositivo',
|
||
seo_step2_title: 'Configure as opções',
|
||
seo_step2_desc: 'Tipo de encadernação, cor, qualidade, numeração e mais',
|
||
seo_step3_title: 'Baixe o resultado',
|
||
seo_step3_desc: 'Seu PDF digitalizado fica disponível por 30 minutos',
|
||
seo_about: 'pdf2scan é uma ferramenta online gratuita para criar PDFs rasterizados com efeito de digitalização. Suporta encadernação com fio, fita, canto, espiral e furador. Ideal para preparar documentos para arquivos, cartórios ou órgãos públicos. Sem cadastro, sem software.',
|
||
},
|
||
};
|
||
|
||
function pluralizeRu(n, f1, f2, f5) {
|
||
const n10 = n % 10, n100 = n % 100;
|
||
if (n10 === 1 && n100 !== 11) return f1;
|
||
if (n10 >= 2 && n10 <= 4 && (n100 < 10 || n100 >= 20)) return f2;
|
||
return f5;
|
||
}
|
||
|
||
// ── Language management ───────────────────────────────────────────────────
|
||
// Язык из URL (серверный data-prelang для /en/, /de/… — важно для SEO) имеет
|
||
// приоритет над сохранённым выбором, чтобы языковая страница показывалась на своём языке.
|
||
const PRELANG = document.documentElement.getAttribute('data-prelang');
|
||
let currentLang = (PRELANG && TR[PRELANG])
|
||
? PRELANG
|
||
: (localStorage.getItem('pdf2scan_lang') || detectLang());
|
||
|
||
function detectLang() {
|
||
const nav = navigator.language || navigator.userLanguage || 'ru';
|
||
const code = nav.slice(0, 2).toLowerCase();
|
||
return TR[code] ? code : 'ru';
|
||
}
|
||
|
||
function t(key) {
|
||
const tr = TR[currentLang] || TR['ru'];
|
||
return tr[key] !== undefined ? tr[key] : (TR['ru'][key] || key);
|
||
}
|
||
|
||
function applyTranslations() {
|
||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n');
|
||
const val = t(key);
|
||
if (typeof val === 'string') el.textContent = val;
|
||
});
|
||
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
||
const val = t(el.getAttribute('data-i18n-ph'));
|
||
if (typeof val === 'string') el.setAttribute('placeholder', val);
|
||
});
|
||
document.title = t('page_title');
|
||
document.documentElement.lang = currentLang;
|
||
// Update flag button
|
||
const flagEl = document.getElementById('lang-flag-img');
|
||
flagEl.src = LANGS[currentLang].img;
|
||
flagEl.alt = LANGS[currentLang].alt;
|
||
// Re-render donate desc if visible
|
||
const donateCard = document.getElementById('donate-card');
|
||
if (donateCard && donateCard.style.display !== 'none') {
|
||
const isHeavy = filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10;
|
||
donateCard.querySelector('.donate-desc').textContent = t(isHeavy ? 'donate_desc_heavy' : 'donate_desc');
|
||
}
|
||
// Highlight active language in dropdown
|
||
document.querySelectorAll('.lang-option').forEach(btn => {
|
||
const lang = btn.getAttribute('onclick').match(/setLang\('(\w+)'\)/)[1];
|
||
btn.classList.toggle('active', lang === currentLang);
|
||
});
|
||
// Re-render dynamic button state
|
||
updateSubmitBtn();
|
||
}
|
||
|
||
function setLang(lang) {
|
||
if (!TR[lang]) return;
|
||
currentLang = lang;
|
||
localStorage.setItem('pdf2scan_lang', lang);
|
||
closeLangDropdown();
|
||
applyTranslations();
|
||
}
|
||
|
||
function toggleLangDropdown(e) {
|
||
e.stopPropagation();
|
||
document.getElementById('lang-dropdown').classList.toggle('open');
|
||
}
|
||
|
||
function closeLangDropdown() {
|
||
document.getElementById('lang-dropdown').classList.remove('open');
|
||
}
|
||
|
||
document.addEventListener('click', closeLangDropdown);
|
||
|
||
// ── Donate ────────────────────────────────────────────────────────────────
|
||
const DONATE_PAGE_THRESHOLD = 20;
|
||
|
||
function showDonateCard(heavy) {
|
||
const card = document.getElementById('donate-card');
|
||
const desc = card.querySelector('.donate-desc');
|
||
desc.textContent = t(heavy ? 'donate_desc_heavy' : 'donate_desc');
|
||
card.style.display = 'block';
|
||
}
|
||
|
||
function hideDonateCard() {
|
||
// Блок поддержки/оплаты теперь всегда виден — просто возвращаем обычный текст.
|
||
showDonateCard(false);
|
||
}
|
||
|
||
// ── Оплата картой (Ю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; }
|
||
}
|
||
|
||
// Возврат с ЮKassa: ?paid=... → опрашиваем статус и показываем результат
|
||
(function handlePayReturn() {
|
||
const params = new URLSearchParams(location.search);
|
||
if (!params.has('paid')) return;
|
||
const pid = localStorage.getItem('pdf2scan_pay_id');
|
||
// чистим URL, чтобы не срабатывало при обновлении
|
||
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');
|
||
showInfo(t('pay_thanks')); return;
|
||
}
|
||
if (d.status === 'canceled') { localStorage.removeItem('pdf2scan_pay_id'); showError(t('pay_canceled')); return; }
|
||
} catch (_) {}
|
||
if (tries <= 20) setTimeout(check, 2000);
|
||
};
|
||
check();
|
||
})();
|
||
|
||
// ── App state ─────────────────────────────────────────────────────────────
|
||
const TG_AUTH_KEY = 'pdf2scan_tg_auth';
|
||
|
||
let selectedFile = null;
|
||
let currentJobId = null;
|
||
let pollInterval = null;
|
||
let progressInterval = null;
|
||
let tgUser = null;
|
||
let authRequired = false;
|
||
let processingStartTime = null;
|
||
let filePages = 0;
|
||
let fileSizeMb = 0;
|
||
|
||
// ── Batch state (несколько файлов) ────────────────────────────────────────
|
||
const MAX_BATCH = 20;
|
||
const AUTH_MAX_PAGES = 100; // зеркало серверных AUTH_REQUIRED_PAGES (сервер — источник истины)
|
||
const AUTH_MAX_MB = 50; // зеркало серверных AUTH_REQUIRED_MB
|
||
const CHUNK_SIZE = 6 * 1024 * 1024; // 6 МБ — туннель глотает такие куски быстро (>~15 МБ схлопывается)
|
||
let batchMode = false;
|
||
let batchJobs = []; // [{file,name,idx,sizeMb,pages,jobId,status,error,queuePos}]
|
||
let batchPollInterval = null;
|
||
let batchTicker = null;
|
||
|
||
// ── Подсчёт страниц PDF на клиенте ───────────────────────────────────────
|
||
async function countPdfPages(file) {
|
||
try {
|
||
const buf = await file.arrayBuffer();
|
||
const text = new TextDecoder('latin1').decode(new Uint8Array(buf));
|
||
const matches = text.match(/\/Type\s*\/Page[^s]/g);
|
||
return matches ? matches.length : 0;
|
||
} catch(e) { return 0; }
|
||
}
|
||
|
||
// ── localStorage сессия ───────────────────────────────────────────────────
|
||
function loadSavedAuth() {
|
||
try {
|
||
const raw = localStorage.getItem(TG_AUTH_KEY);
|
||
if (!raw) return null;
|
||
const data = JSON.parse(raw);
|
||
if (Date.now() / 1000 - (data.saved_at || 0) > 86400) {
|
||
localStorage.removeItem(TG_AUTH_KEY);
|
||
return null;
|
||
}
|
||
return data;
|
||
} catch(e) { return null; }
|
||
}
|
||
|
||
function saveAuth(user) {
|
||
try {
|
||
localStorage.setItem(TG_AUTH_KEY, JSON.stringify({ ...user, saved_at: Date.now() / 1000 }));
|
||
} catch(e) {}
|
||
}
|
||
|
||
function logoutTg() {
|
||
localStorage.removeItem(TG_AUTH_KEY);
|
||
tgUser = null;
|
||
renderHeaderAuth();
|
||
updateSubmitBtn();
|
||
renderAuthBanner();
|
||
const cab = document.getElementById('cabinet-card');
|
||
if (cab) cab.style.display = 'none';
|
||
}
|
||
|
||
// ── Личный кабинет: «Мои документы» ───────────────────────────────────────
|
||
async function loadHistory() {
|
||
const uid = tgUser && (tgUser.user_id || tgUser.id);
|
||
const card = document.getElementById('cabinet-card');
|
||
if (!uid || !card) return;
|
||
try {
|
||
const r = await fetch('/api/history/' + uid);
|
||
if (!r.ok) { card.style.display = 'none'; return; }
|
||
const data = await r.json();
|
||
const items = data.items || [];
|
||
const list = document.getElementById('cabinet-list');
|
||
if (!items.length) {
|
||
list.innerHTML = '<div style="padding:8px;color:var(--text-secondary);font-size:14px">' + t('cabinet_empty') + '</div>';
|
||
card.style.display = 'block';
|
||
return;
|
||
}
|
||
list.innerHTML = items.map(function(it) {
|
||
var right;
|
||
if (it.status === 'available') {
|
||
right = '<a href="/api/download/' + encodeURIComponent(it.job_id) + '" style="color:var(--blue);text-decoration:none;font-size:13px;white-space:nowrap">' + t('cabinet_dl') + '</a>';
|
||
} else if (it.status === 'processing') {
|
||
right = '<span style="color:var(--text-secondary);font-size:13px">' + t('cabinet_processing') + '</span>';
|
||
} else if (it.status === 'error') {
|
||
right = '<span style="color:#e05555;font-size:13px">' + t('st_error') + '</span>';
|
||
} else {
|
||
right = '<span style="color:var(--text-secondary);font-size:13px;opacity:.7">' + t('cabinet_expired') + '</span>';
|
||
}
|
||
return '<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border,#22314a)">' +
|
||
'<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13.5px" title="' + escapeHtml(it.name) + '">' + escapeHtml(it.name) + '</span>' +
|
||
'<span style="display:flex;gap:8px;align-items:center"><span style="color:var(--text-secondary);font-size:11px;white-space:nowrap">' + escapeHtml((it.date||'') + ' ' + (it.time||'').slice(0,5)) + '</span>' + right + '</span>' +
|
||
'</div>';
|
||
}).join('');
|
||
card.style.display = 'block';
|
||
} catch (e) { /* тихо */ }
|
||
}
|
||
|
||
let sessionUser = null; // из /api/me (вход через Яндекс/VK)
|
||
|
||
function renderHeaderAuth() {
|
||
const loginBtn = document.getElementById('tg-login-header-btn');
|
||
const userInfo = document.getElementById('tg-user-info');
|
||
const name = tgUser
|
||
? (tgUser.first_name + (tgUser.last_name ? ' ' + tgUser.last_name : ''))
|
||
: (sessionUser ? sessionUser.name : null);
|
||
if (name) {
|
||
loginBtn.style.display = 'none';
|
||
userInfo.style.display = 'flex';
|
||
document.getElementById('tg-user-name').textContent = name;
|
||
document.getElementById('tg-user-photo').style.display = 'none';
|
||
} else {
|
||
loginBtn.style.display = 'flex';
|
||
userInfo.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ── Меню входа (Telegram / Яндекс) ────────────────────────────────────────
|
||
function toggleLoginMenu(e) {
|
||
e.stopPropagation();
|
||
const m = document.getElementById('login-menu');
|
||
m.style.display = (m.style.display === 'none' || !m.style.display) ? 'block' : 'none';
|
||
}
|
||
function closeLoginMenu() {
|
||
const m = document.getElementById('login-menu');
|
||
if (m) m.style.display = 'none';
|
||
}
|
||
document.addEventListener('click', closeLoginMenu);
|
||
|
||
async function doLogout() {
|
||
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) {}
|
||
sessionUser = null;
|
||
logoutTg(); // чистит localStorage TG + перерисовывает шапку
|
||
}
|
||
|
||
async function loadMe() {
|
||
try {
|
||
const r = await fetch('/api/me');
|
||
const d = await r.json();
|
||
sessionUser = d.logged_in ? d : null;
|
||
} catch (e) { sessionUser = null; }
|
||
renderHeaderAuth();
|
||
if (sessionUser) loadHistory();
|
||
}
|
||
|
||
// ── Авторизация через бота ────────────────────────────────────────────────
|
||
let _authPollInterval = null;
|
||
let _authToken = null;
|
||
let _authWindow = null;
|
||
|
||
async function startTgAuth() {
|
||
try {
|
||
const r = await fetch('/api/auth/token', { method: 'POST' });
|
||
if (!r.ok) { showError(t('err_auth_start')); return; }
|
||
const data = await r.json();
|
||
_authToken = data.token;
|
||
_authWindow = window.open(data.url, '_blank');
|
||
showInfo(t('info_auth_waiting'));
|
||
if (_authPollInterval) clearInterval(_authPollInterval);
|
||
_authPollInterval = setInterval(_pollAuthToken, 2000);
|
||
} catch(e) {
|
||
showError(t('err_auth_launch'));
|
||
}
|
||
}
|
||
|
||
async function _pollAuthToken() {
|
||
if (!_authToken) return;
|
||
try {
|
||
const r = await fetch(`/api/auth/poll/${_authToken}`);
|
||
if (!r.ok) { _stopAuthPoll(); return; }
|
||
const data = await r.json();
|
||
if (data.status === 'verified') {
|
||
_stopAuthPoll();
|
||
tgUser = { user_id: data.user_id, first_name: data.first_name, last_name: data.last_name, username: data.username };
|
||
saveAuth(tgUser);
|
||
renderHeaderAuth();
|
||
renderAuthBanner();
|
||
updateSubmitBtn();
|
||
hideInfo();
|
||
loadHistory();
|
||
if (_authWindow) { try { _authWindow.close(); } catch(_) {} }
|
||
} else if (data.status === 'expired') {
|
||
_stopAuthPoll();
|
||
showError(t('err_auth_expired'));
|
||
}
|
||
} catch(e) { /* сетевая ошибка, попробуем снова */ }
|
||
}
|
||
|
||
function _stopAuthPoll() {
|
||
if (_authPollInterval) { clearInterval(_authPollInterval); _authPollInterval = null; }
|
||
_authToken = null;
|
||
}
|
||
|
||
// ── Инициализация ─────────────────────────────────────────────────────────
|
||
tgUser = loadSavedAuth();
|
||
renderHeaderAuth();
|
||
if (tgUser) loadHistory();
|
||
if (!tgUser) loadMe(); // проверяем cookie-сессию (вход через Яндекс/VK)
|
||
// Возврат после OAuth: чистим ?login и показываем ошибку при неудаче
|
||
(function () {
|
||
const p = new URLSearchParams(location.search);
|
||
if (p.has('login')) {
|
||
const v = p.get('login');
|
||
try { history.replaceState({}, '', location.pathname); } catch (_) {}
|
||
if (v === 'error') showError(t('err_login'));
|
||
}
|
||
})();
|
||
|
||
// ── Drop zone ─────────────────────────────────────────────────────────────
|
||
const dropZone = document.getElementById('drop-zone');
|
||
const fileInput = document.getElementById('file-input');
|
||
const fileBadge = document.getElementById('file-badge');
|
||
const submitBtn = document.getElementById('submit-btn');
|
||
|
||
applyTranslations(); // после объявления submitBtn — иначе TDZ ReferenceError
|
||
|
||
dropZone.addEventListener('click', () => fileInput.click());
|
||
dropZone.addEventListener('dragenter', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
||
dropZone.addEventListener('dragover', e => { e.preventDefault(); });
|
||
dropZone.addEventListener('dragleave', e => { if (!dropZone.contains(e.relatedTarget)) dropZone.classList.remove('drag-over'); });
|
||
const _ALLOWED_MIME = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
|
||
function gatherFiles(fileList) {
|
||
return Array.from(fileList || []).filter(
|
||
f => _ALLOWED_MIME.includes(f.type) || /\.(pdf|doc|docx|zip)$/i.test(f.name)
|
||
);
|
||
}
|
||
|
||
dropZone.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
dropZone.classList.remove('drag-over');
|
||
acceptFiles(gatherFiles(e.dataTransfer.files));
|
||
});
|
||
|
||
fileInput.addEventListener('change', () => {
|
||
acceptFiles(gatherFiles(fileInput.files));
|
||
});
|
||
|
||
// Загрузка папкой: webkitdirectory отдаёт все файлы каталога, фильтруем нужные.
|
||
const folderInput = document.getElementById('folder-input');
|
||
const folderBtn = document.getElementById('folder-btn');
|
||
if (folderBtn) folderBtn.addEventListener('click', (e) => { e.stopPropagation(); folderInput.click(); });
|
||
if (folderInput) folderInput.addEventListener('change', () => {
|
||
acceptFiles(gatherFiles(folderInput.files));
|
||
});
|
||
|
||
// Один файл → классический одиночный поток; несколько → батч со списком и ZIP.
|
||
function acceptFiles(arr) {
|
||
if (!arr || !arr.length) return;
|
||
hideError(); hideInfo(); hideDonateCard();
|
||
submitBtn.style.display = 'block'; // мог быть скрыт после прошлого батча
|
||
// ZIP-архив: распаковывается на сервере, каждый вложенный файл — отдельное задание.
|
||
const zips = arr.filter(f => /\.zip$/i.test(f.name));
|
||
if (zips.length) { handleZip(zips[0]); return; }
|
||
let files = arr;
|
||
if (files.length > MAX_BATCH) {
|
||
showError(t('err_too_many')(MAX_BATCH));
|
||
files = files.slice(0, MAX_BATCH);
|
||
}
|
||
if (files.length === 1) {
|
||
batchMode = false;
|
||
document.getElementById('batch-card').style.display = 'none';
|
||
handleFile(files[0]);
|
||
} else {
|
||
batchMode = true;
|
||
selectedFile = null;
|
||
fileBadge.style.display = 'none';
|
||
document.getElementById('result-card').style.display = 'none';
|
||
document.getElementById('progress-card').style.display = 'none';
|
||
handleFiles(files);
|
||
}
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, c => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 {
|
||
el.innerHTML = `<span class="bs bs-wait">—</span>`;
|
||
}
|
||
}
|
||
|
||
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() {
|
||
const banner = document.getElementById('auth-banner');
|
||
if (authRequired && !tgUser) {
|
||
banner.style.display = 'flex';
|
||
} else {
|
||
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 if (authRequired && !tgUser) {
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = t('btn_auth_required');
|
||
} 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));
|
||
const data = await fr.json();
|
||
return data.job_id;
|
||
}
|
||
|
||
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 {
|
||
j.jobId = await uploadFileChunked(j.file, s, null);
|
||
j.status = 'queued'; j.error = null; updateRow(j);
|
||
} catch (e) {
|
||
j.status = 'error';
|
||
j.error = (e && e.message) ? e.message : t('err_network');
|
||
updateRow(j);
|
||
}
|
||
}
|
||
|
||
function startBatchPolling() {
|
||
if (batchPollInterval) clearInterval(batchPollInterval);
|
||
batchPollInterval = setInterval(pollBatch, 2000);
|
||
// тикер плавно крутит % у файла в обработке между опросами
|
||
if (batchTicker) clearInterval(batchTicker);
|
||
batchTicker = setInterval(() => {
|
||
batchJobs.forEach(j => { if (j.status === 'processing') updateRow(j); });
|
||
}, 1000);
|
||
pollBatch();
|
||
}
|
||
|
||
async function pollBatch() {
|
||
const active = batchJobs.filter(j => j.jobId && (j.status === 'queued' || j.status === 'processing'));
|
||
await Promise.all(active.map(async j => {
|
||
try {
|
||
const r = await fetch(`/api/status/${encodeURIComponent(j.jobId)}`);
|
||
if (r.status === 404) { j.status = 'error'; j.error = t('err_task_not_found'); updateRow(j); return; }
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
if (d.status === 'done') { j.status = 'done'; updateRow(j); }
|
||
else if (d.status === 'error') { j.status = 'error'; j.error = d.error || t('err_processing'); updateRow(j); }
|
||
else if (d.status === 'queued') { j.status = 'queued'; j.queuePos = d.queue_pos || null; updateRow(j); }
|
||
else { if (!j.procStart) j.procStart = Date.now(); j.status = 'processing'; updateRow(j); }
|
||
} catch (e) { /* сетевой сбой — повторим на следующем тике */ }
|
||
}));
|
||
if (batchJobs.every(j => j.status === 'done' || j.status === 'error')) {
|
||
if (batchPollInterval) { clearInterval(batchPollInterval); batchPollInterval = null; }
|
||
onBatchComplete();
|
||
}
|
||
}
|
||
|
||
function onBatchComplete() {
|
||
if (batchTicker) { clearInterval(batchTicker); batchTicker = null; }
|
||
const doneCount = batchJobs.filter(j => j.status === 'done').length;
|
||
const actions = document.getElementById('batch-actions');
|
||
const zipBtn = document.getElementById('zip-btn');
|
||
zipBtn.style.display = doneCount > 0 ? 'block' : 'none';
|
||
zipBtn.disabled = doneCount === 0;
|
||
actions.style.display = 'flex';
|
||
submitBtn.style.display = 'none';
|
||
showDonateCard(true);
|
||
}
|
||
|
||
document.getElementById('zip-btn').addEventListener('click', downloadZip);
|
||
|
||
async function downloadZip() {
|
||
const ids = batchJobs.filter(j => j.status === 'done').map(j => j.jobId);
|
||
if (!ids.length) return;
|
||
const zipBtn = document.getElementById('zip-btn');
|
||
const orig = zipBtn.textContent;
|
||
zipBtn.disabled = true;
|
||
zipBtn.textContent = t('zip_preparing');
|
||
try {
|
||
// 1) старт фоновой сборки
|
||
const r = await fetch('/api/zip/prepare', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ job_ids: ids }),
|
||
});
|
||
if (!r.ok) throw new Error(await _detailOf(r));
|
||
const { zip_id, name } = await r.json();
|
||
// 2) ждём готовности (большой архив строится в фоне, без таймаута заголовков)
|
||
let ready = false;
|
||
for (let i = 0; i < 400; i++) {
|
||
await sleep(2000);
|
||
try {
|
||
const sr = await fetch('/api/zip/status/' + encodeURIComponent(zip_id));
|
||
if (!sr.ok) continue;
|
||
const s = await sr.json();
|
||
if (s.status === 'ready') { ready = true; break; }
|
||
if (s.status === 'error') throw new Error(t('err_zip'));
|
||
} catch (e) { if (e && e.message === t('err_zip')) throw e; /* сетевой блип — повторим */ }
|
||
}
|
||
if (!ready) throw new Error(t('err_zip'));
|
||
// 3) нативное скачивание готового файла (стримится на диск, не в память)
|
||
const a = document.createElement('a');
|
||
a.href = '/api/zip/file/' + encodeURIComponent(zip_id);
|
||
a.download = name || 'pdf2scan.zip';
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
} catch (e) {
|
||
showError((e && e.message) ? e.message : t('err_zip'));
|
||
} finally {
|
||
zipBtn.disabled = false;
|
||
zipBtn.textContent = orig;
|
||
}
|
||
}
|
||
|
||
function submitJob() {
|
||
if (!selectedFile) { showError(t('err_no_file')); return; }
|
||
// Крупные файлы — через чанковую загрузку (туннель не тянет большое тело за раз)
|
||
if (selectedFile.size > CHUNK_SIZE) { submitJobChunked(); return; }
|
||
hideError();
|
||
hideInfo();
|
||
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = t('btn_sending');
|
||
|
||
document.getElementById('progress-card').style.display = 'block';
|
||
document.getElementById('result-card').style.display = 'none';
|
||
setProgressPct(0);
|
||
document.getElementById('progress-status').textContent = t('progress_loading');
|
||
document.getElementById('progress-speed').textContent = '';
|
||
|
||
const fd = new FormData();
|
||
fd.append('file', selectedFile);
|
||
fd.append('stitching', getRadio('stitching'));
|
||
fd.append('color', getRadio('color'));
|
||
fd.append('quality', getRadio('quality'));
|
||
fd.append('title', document.getElementById('title-check').checked ? 1 : 0);
|
||
fd.append('numeration', document.getElementById('num-check').checked ? 1 : 0);
|
||
fd.append('flip_side', document.getElementById('flip-check').checked ? 1 : 0);
|
||
fd.append('pdfa', document.getElementById('pdfa-check').checked ? 1 : 0);
|
||
fd.append('ocr', document.getElementById('ocr-check').checked ? 1 : 0);
|
||
if (tgUser) fd.append('user_id', tgUser.user_id || tgUser.id || 0);
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
const uploadStart = Date.now();
|
||
let lastLoaded = 0, lastTime = uploadStart;
|
||
|
||
xhr.upload.onprogress = function(e) {
|
||
if (!e.lengthComputable) return;
|
||
const pct = Math.round(e.loaded / e.total * 100);
|
||
setProgressPct(pct);
|
||
|
||
const now = Date.now();
|
||
const dt = (now - lastTime) / 1000;
|
||
if (dt >= 0.3) {
|
||
const speedMb = (e.loaded - lastLoaded) / (1024 * 1024) / dt;
|
||
lastLoaded = e.loaded; lastTime = now;
|
||
const loaded = (e.loaded / (1024*1024)).toFixed(1);
|
||
const total = (e.total / (1024*1024)).toFixed(1);
|
||
const speedFn = (TR[currentLang] || TR['ru']).upload_speed;
|
||
document.getElementById('progress-speed').textContent =
|
||
speedFn(loaded, total, speedMb.toFixed(1));
|
||
}
|
||
document.getElementById('progress-status').textContent =
|
||
pct < 100 ? t('progress_loading') : t('progress_uploaded');
|
||
};
|
||
|
||
xhr.onload = function() {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
try {
|
||
const data = JSON.parse(xhr.responseText);
|
||
currentJobId = data.job_id;
|
||
document.getElementById('progress-speed').textContent = '';
|
||
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 {
|
||
currentJobId = 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');
|
||
});
|
||
document.getElementById('progress-speed').textContent = '';
|
||
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 || (authRequired && !tgUser);
|
||
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 === '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 || (authRequired && !tgUser);
|
||
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>
|