[windows] Скрипт. Быстрый экспорт фото в HTML-галерею

Этот метод создаёт один полностью автономный HTML-файл, в который вшиты все страницы или скриншоты из папки в виде плавной бесшовной ленты.

Файл получается целиком портативным: исходные картинки можно удалить или переместить, а готовый документ переслать на смартфон или открыть на любом компьютере – интернет для просмотра не требуется.

📜 Скрипт (PowerShell)

$htmlFile = Join-Path $PWD.ProviderPath "gallery_final.html"
$extensions = @('.png', '.jpg', '.jpeg', '.webp')

$files = Get-ChildItem -File | Where-Object { $extensions -contains $_.Extension.ToLower() } | 
         Sort-Object { [regex]::Replace($_.Name, '\d+', { $args[0].Value.PadLeft(20, '0') }) }

if (-not $files) {
    Write-Warning "Изображения не найдены в текущей папке."
    exit
}

$head = @"
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0, user-scalable=yes">
    <style>
        html, body { margin: 0; padding: 0; background: #111; -webkit-tap-highlight-color: transparent; }
        body { overflow-x: hidden; }
        body.can-scroll { overflow-x: auto; cursor: grab; }
        body.grabbing { cursor: grabbing; user-select: none; }
        .progress-bar { position: fixed; top: 0; left: 0; height: 3px; background: #3b82f6; width: 0%; z-index: 2000; }
        .reader-wrap { width: 100%; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; }
        img { width: 100%; height: auto; display: block; user-select: none; -webkit-user-drag: none; }
        body.night img { filter: invert(0.9) hue-rotate(180deg) contrast(1.15); }
        .toolbar { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; background: rgba(20, 20, 20, 0.85); padding: 6px 12px; border-radius: 30px; border: 1px solid rgba(255, 255, 255, 0.2); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); z-index: 1000; font-family: sans-serif; align-items: center; }
        .toolbar button, .page-badge { background: none; border: none; color: #fff; font-size: 13px; font-weight: bold; cursor: pointer; padding: 6px 10px; border-radius: 15px; }
        .toolbar button:hover { background: rgba(255, 255, 255, 0.15); }
        .page-badge { cursor: default; opacity: 0.8; font-variant-numeric: tabular-nums; }
    </style>
</head>
<body>
<div class="progress-bar" id="pBar"></div>
<div class="reader-wrap" id="wrap">
"@

$tail = @"
</div>
<div class="toolbar">
    <span class="page-badge" id="pBadge">1 / $($files.Count)</span>
    <button onclick="stepZoom()" id="zBtn">🔍 1.0×</button>
    <button onclick="toggleNight()" id="nBtn">🌙</button>
</div>

<script>
    const wrap = document.getElementById('wrap');
    const zBtn = document.getElementById('zBtn');
    const nBtn = document.getElementById('nBtn');
    const pBar = document.getElementById('pBar');
    const pBadge = document.getElementById('pBadge');
    const imgs = document.querySelectorAll('img');
    const total = imgs.length;

    const scales = [1.0, 1.3, 1.6];
    let curIdx = 0;
    let baseDist = 0;
    let initialW = 100;
    let currentPage = 1;

    function applyScale(s) {
        s = Math.min(Math.max(s, 1.0), 2.5);
        wrap.style.width = (s * 100) + '%';

        if (s > 1.0) {
            wrap.style.maxWidth = 'none';
            document.body.classList.add('can-scroll');
            const extraW = wrap.offsetWidth - window.innerWidth;
            if (extraW > 0) {
                window.scrollTo({ left: extraW / 2, top: window.scrollY });
            }
        } else {
            wrap.style.maxWidth = '1400px';
            document.body.classList.remove('can-scroll');
            window.scrollTo({ left: 0, top: window.scrollY });
        }

        zBtn.textContent = '🔍 ' + s.toFixed(1) + '×';
    }

    function stepZoom() {
        curIdx = (curIdx + 1) % scales.length;
        applyScale(scales[curIdx]);
    }

    function toggleNight(forceState) {
        const isN = typeof forceState === 'boolean' ? document.body.classList.toggle('night', forceState) : document.body.classList.toggle('night');
        nBtn.textContent = isN ? '☀️' : '🌙';
        localStorage.setItem('night_' + location.pathname, isN ? '1' : '0');
    }

    let isDown = false;
    let startX = 0;
    let scrollStart = 0;

    window.addEventListener('mousedown', (e) => {
        if (e.button !== 0 || e.target.closest('.toolbar') || wrap.offsetWidth <= window.innerWidth) return;
        isDown = true;
        document.body.classList.add('grabbing');
        startX = e.clientX;
        scrollStart = window.scrollX;
    });

    const stopDrag = () => {
        if (isDown) {
            isDown = false;
            document.body.classList.remove('grabbing');
        }
    };

    window.addEventListener('mouseup', stopDrag);
    window.addEventListener('mouseleave', stopDrag);

    window.addEventListener('mousemove', (e) => {
        if (!isDown) return;
        e.preventDefault();
        window.scrollTo(scrollStart - (e.clientX - startX), window.scrollY);
    });

    document.addEventListener('touchstart', (e) => {
        if (e.touches.length === 2) {
            baseDist = Math.hypot(e.touches[0].pageX - e.touches[1].pageX, e.touches[0].pageY - e.touches[1].pageY);
            initialW = parseFloat(wrap.style.width) || 100;
        }
    }, { passive: true });

    document.addEventListener('touchmove', (e) => {
        if (e.touches.length === 2 && baseDist > 0) {
            if (e.cancelable) e.preventDefault();
            const dist = Math.hypot(e.touches[0].pageX - e.touches[1].pageX, e.touches[0].pageY - e.touches[1].pageY);
            applyScale((initialW * (dist / baseDist)) / 100);
        }
    }, { passive: false });

    document.addEventListener('touchend', (e) => {
        if (e.touches.length < 2) baseDist = 0;
    });

    const observer = new IntersectionObserver((entries) => {
        entries.forEach(e => {
            if (e.isIntersecting) {
                const idx = Array.prototype.indexOf.call(imgs, e.target) + 1;
                currentPage = idx;
                pBadge.textContent = idx + ' / ' + total;
                localStorage.setItem('page_' + location.pathname, idx);
            }
        });
    }, { rootMargin: '-40% 0px -40% 0px', threshold: 0 });

    imgs.forEach(img => observer.observe(img));

    window.addEventListener('scroll', () => {
        const maxH = document.documentElement.scrollHeight - window.innerHeight;
        pBar.style.width = (maxH > 0 ? (window.scrollY / maxH) * 100 : 0) + '%';
    });

    window.addEventListener('DOMContentLoaded', () => {
        if (localStorage.getItem('night_' + location.pathname) === '1') {
            toggleNight(true);
        }
        const savedPage = parseInt(localStorage.getItem('page_' + location.pathname), 10);
        if (savedPage && imgs[savedPage - 1]) {
            setTimeout(() => {
                imgs[savedPage - 1].scrollIntoView();
            }, 50);
        }
    });
</script>
</body>
</html>
"@

$writer = [System.IO.StreamWriter]::new($htmlFile, $false, [System.Text.Encoding]::UTF8)
try {
    $writer.Write($head)
    foreach ($file in $files) {
        $bytes = [System.IO.File]::ReadAllBytes($file.FullName)
        $base = [System.Convert]::ToBase64String($bytes)
        $mime = switch ($file.Extension.ToLower()) {
            '.png'  { 'image/png' }
            '.webp' { 'image/webp' }
            default { 'image/jpeg' }
        }
        $writer.Write("<img src='data:$mime;base64,$base' loading='lazy'>")
    }
    $writer.Write($tail)
} finally {
    $writer.Dispose()
}

Write-Host "Готово! Собрано файлов: $($files.Count)" -ForegroundColor Green
Start-Process $htmlFile

⚙️ Разбор команд скрипта

  1. $htmlFile = Join-Path $PWD.ProviderPath "gallery_final.html" – надёжно формирует путь к файлу в текущей папке, исключая ошибки провайдера путей PowerShell.
  2. $extensions = @(...) – список расширений файлов, которые скрипт отберёт для создания ленты.
  3. Get-ChildItem -File | Where-Object ... – находит все подходящие изображения в папке, игнорируя посторонние файлы и подпапки.
  4. Sort-Object { [regex]::Replace(...) } – натуральная сортировка имён. Выравнивает числа нулями при сравнении, благодаря чему страница 2 встаёт перед 10, а 9 – перед 100.
  5. [System.IO.StreamWriter]::new(...) – открывает прямой файловый поток с кодировкой UTF-8 для непрерывной записи без перегрузки оперативной памяти.
  6. $head = @"..."@ – HTML-каркас: тёмный фон, адаптивность ленты, панель инструментов, стили индикатора чтения и ночного режима.
  7. [System.IO.File]::ReadAllBytes и ToBase64String – считывают каждый файл и кодируют его прямо в текст Base64, делая документ полностью автономным.
  8. switch ($file.Extension.ToLower()) – определяет правильный MIME-тип (image/png, image/webp или image/jpeg) для корректного отображения в браузере.
  9. loading='lazy' – активирует встроенную ленивую загрузку: браузер обрабатывает изображения только по мере прокрутки, экономя память устройства.
  10. $tail = @"..."@ – движок читалки: ступенчатый зум с поддержкой щипка двумя пальцами, ночной режим, счётчик страниц через IntersectionObserver и запоминание позиции в localStorage.
  11. $writer.Write(...) и $writer.Dispose() – последовательно записывают структуру в файл и корректно освобождают системные ресурсы.
  12. Start-Process $htmlFile – автоматически открывает готовую читалку в браузере по умолчанию

🚀 Как пользоваться

  1. Откройте папку со своими изображениями.
  2. Откройте консоль в этой папке (любым удобным способом):
    • Кликните вверху по адресной строке Проводника, введите powershell и нажмите Enter.
    • Либо кликните правой кнопкой мыши на пустом месте внутри папки ➔ «Открыть в Терминале».
  3. Вставьте скопированный скрипт в окно консоли и нажмите Enter.
  4. Браузер сразу откроет созданный файл gallery_final.html.

🧩Особенности и преимущества

  1. Полная автономность (Base64): все страницы вшиты в один HTML-файл. Документ работает без интернета и не теряет картинки при переносе.
  2. Память чтения (localStorage): браузер автоматически запоминает место остановки и восстанавливает скролл при повторном открытии.
  3. Счётчик страниц и прогресс: тонкий индикатор чтения вверху экрана и счётчик текущей страницы в реальном времени.
  4. Ночной режим (🌙): мягкая инверсия страниц для комфортного чтения сканов с белым фоном в темноте.
  5. Удобный зум: переключение ступеней масштаба (1.0×, 1.3×, 1.6×) по кнопке и свободное увеличение жестом двух пальцев.
  6. Натуральная сортировка ([regex]::Replace): номера страниц идут строго по порядку (2 встанет перед 10, а 9 перед 100).
  7. Экономия ресурсов: потоковая запись через StreamWriter без расхода лишней RAM и ленивая подгрузка кадров по мере прокрутки.