
Поделюсь с вами необычным опытом разработки упаковщика проекта с большой анимационной сценой в один независимый HTML файл, который может воспроизводиться в любом браузере без интернета и веб-сервера.
Вводная
Несколько лет назад в моей прошлой статье на Хабре я рассказывал о создании своего собственного видео формата, который заменил в моем проекте mp4 и позволил повысить качество рендера анимации и при этом не сильно потерять в размере. С тех пор проект прилично подрос, и сейчас вся анимация весит ~150МБ в этом видео формате.

Теперь появилась амбициозная задача - дать пользователям возможность скачать весь проект целиком как анимацию, которая не будет никак зависеть ни от веб-сервера, ни от интернета в целом. Единственная необходимое требование - наличие любого современного браузера, который может открыть файл с локального диска.
Задача
Встает вопрос: как дать пользователям возможность скачать весь проект так, чтобы его можно было воспроизвести почти на любом устройстве? Простого варианта попросту нет, так как вся анимация - это не один файл, это множество различных файлов, подключающихся по мере просмотра сцены, для рендера которых нужен движок JS с поддержкой отдельных потоков (Web Workers) и ряд современных Web APIs. Конечно, можно все упаковать в исполняемые файлы со встроенным браузером, блэкджеком и… Но нет, этот путь тернист, избыточен, да и скачивать исполняемую программу слегка небезопасно от noname производителя.
Раз этот проект является веб-страницей, в которой все подгружается по http, то в конечном итоге нужно все файлы проекта встроить в один html, который может быть воспроизведен на практически любом современном устройстве. Но, в проекте на данный момент 712 различных файлов необходимых для рендера анимации, а их суммарный размер - 168МБ! При этом файлы каждую неделю добавляются новые и суммарный размер проекта постоянно растет. Встроить все это в один HTML файл и при этом не подвесить браузер при его открытии - нетривиальная задача, и далее расскажу как её решил.
Как встроить бинарные файлы в HTML
Думаю большинство читателей знакомы с схемой data-uri, которая часто применяется для встраивания различного бинарного содержимого прямо в код html. Если не знакомы, то коротко расскажу в чем её суть: вместо URL к определенному ресурсу (например, изображению) мы в поле адреса вставляем само содержимое файла, закодированное с помощью Base64 в алфавит, состоящий из безопасных для HTML символов ASCII. Таким образом браузер не делает HTTP запрос к ресурсу, а загружает его из данных, которые есть в HTML.
Т.е. способ как встроить бинарные данные в HTML давно изобретен - это т.н. Binary-To-Text кодировки, которые превращают наш файл в кусок текста, валидный для HTML парсеров. Base64 - это наиболее популярная bin-to-text кодировка в мире IT, поддерживаемая из коробки практически во всех современных языках. Однако, её эффективность составляет лишь 75%, т.е. условно для кодирования 168MБ потребуется строка из 223МБ символов разрешенных в HTML. Многовато…
Рассмотрим другие bin-to-text кодировки, менее популярные, но имеющие бОльшую эффективность.
Одна из самых интересных - это Ascii85/Base85, прекрасная и элегантная кодировка для веба с эффективностью 80%, которая строится на простой математике: для кодирования 4 байт требуется 5 байт с алфавитом состоящем из 85 символов ASCII, так как 232 < 855. Однако, 80% не сильно больше 75%, для наших 168MБ потребуется 210МБ, это все ещё очень много.
Далее рассмотрим Base122. Малоизвестная кодировка, которую сам автор не рекомендует применять в HTML, тем не менее у неё достаточно высокая эффективность - 87.5%. Суть этой кодировки в том, что она упаковывает бинарные данные в структуру UTF-8 октетов. Мне эта тема показалось очень интересной, поэтому я глубоко изучил как сам UTF-8 устроен, так и то, как эта кодировка упаковывает в него.
Для кодирования одного символа UTF-8 использует от 1 до 4 байт, зависит это от того, что за символ. Если символ входит в ASCII, то для его кодирования нужен 1 байт (первый бит всегда будет 0), а для кодирования русского текста нужны 2 байта на каждый символ.
На следующем изображении показана структура байт UTF-8 для кодирования одного символа:

Суть bin-2-text кодирования через UTF-8 - это использовать свободные биты (зеленые), в которые мы сможем перенести биты из исходных данных. Однако, эмпирическим путем обнаружил, что нельзя просто так использовать все зеленые биты, так как в UTF-8 есть диапазоны, которые запрещено использовать и текст будет невалидным, если в нем появятся символы из этих диапазонов.
В итоге оказалось, что для более-менее безопасного кодирования бинарных данных в UTF-8, когда читалка HTML с высокой вероятностью сможет валидно все распознать, эффективность этой кодировки становится около 80-83%, а в некоторых тестах и ниже (все зависит от самих данных). Поэтому Base122 и её вариации не подходят из-за нестабильной эффективности и риска получить невалидный для парсера текст.
Более эффективных кодировок я не нашел, поэтому решил попробовать отойти от общепринятых правил и придумать что-то свое.
Разработка своей Binary-to-Text кодировки
Основная проблема всех bin-to-text кодировок - это размер алфавита: чем он больше, тем выше эффективность кодирования. В ASCII, который состоит из 128 символов, безопасно в HTML можно использовать только 96 символов, а остальное - это различные контрольные символы и символы самого HTML. Т.е. максимальный алфавит для безопасного кодирования - 96. Ближайшее оптимальное соотношение количества бит на вход и количество на выход при таком алфавите будет следующим: на 46 бит (5.75 байт) входных бинарных данных нужно 56 бит (ровно 7 байт) кодированных. Т.е. эффективность 82.1%. Это немного лучше, чем у Base85, но усложняется код, так как нужно работать дробным числом байт. Тем не менее результат есть, назовем эту кодировку Base96 в рамках этой статьи.
Вариантов не остается, нужно как-то увеличивать размер алфавита. Но как это сделать, ведь HTML и JS у нас на юникоде, а в нем только 96 символов можно! И тут на помощь приходит старый друг, за упоминание которого многие меня закидают помидорами - кодировка Windows-1251! Да-да, та самая русская кодировка, которая когда-то использовалась как в DOS, так и Windows, так и в вебе. К слову, её до сих пор используют в вебе крупные русские площадки, к примеру vk.com и pikabu.ru.
HTML5 настоятельно рекомендует использовать только UTF-8 кодировку. Это полностью правильная рекомендация и я её поддерживаю. Тем не менее, в спецификации HTML5 черным по белому написано, что браузеры должны поддерживать Windows-1251, поэтому хоть это и не рекомендуется, но использовать эту кодировку в HTML документах никем не запрещается.
В моем проекте используется только русский текст и английский, так что Windows-1251 подходит на все 100%. Все специальные символы юникода, которые кое-где могут использоваться в проекте, без проблем выводятся, так как их вывод делает JS, а он независимо от кодировки документа работает с юникодом (браузер автоматически конвертирует кодировку документа html в кодировку, необходимую для работы js).
Итак, что нам дает эта кодировка? Так как она однобайтовая, то мы уже не привязаны с ASCII из 128 символов, в которых только 96 валидные. Теперь мы можем использовать ANSI из 256 символов, в которой 223 валидных для HTML символа!

Ближайшее хорошее соотношение бит на вход и выход будут следующими: на 39 бит оригинальных данных требуется 40 бит кодировки. Т.е. теряем всего 1 бит из 5 байт! Эффективность становится рекордные 97.5%! С такой эффективностью 168МБ бинарных данных могут быть безопасно добавлены в HTML как 172МБ, т.е. потеря примерно 2.3%!
Назовем это кодировку Base223* в рамках статьи. Звездочка в названии как бы указывает на то, что для кодировки нужны особые условия, и в частности это кодировка самого документа HTML.
Алгоритм кодирования в Base223* следующий:
Читаем следующие 5 байт из входного потока;
Если прочитали меньше 5 байт, то добавляем справа недостающее число байт с кодом 0x00. Запоминаем сколько добавили байт в переменную P;
Из прочитанных 5 байт (40 бит) извлекаем левые 39 бит.
Представляем эти 39 бит как беззнаковый int64.
-
Поэтапно делим число на 223n с округлением вниз, где n меняется от 4 до 0. В каждом последующем этапе отнимаем от числа накопленные вычисления предыдущих этапов. В итоге получаем 5 чисел от 0 до 223. Пример вычисления, где x - это исходное число из п.4:
c0 = ⌊x / 2234⌋; acc = c0 * 2234
c1 = ⌊(x - acc) / 2233⌋; acc = acc + c1 * 2233
c2 = ⌊(x - acc) / 2232⌋; acc = acc + c2 * 2232
c3 = ⌊(x - acc) / 2231⌋; acc = acc + c3 * 2231
c4 = ⌊(x - acc) / 2230⌋;
Полученные 5 чисел переводим в нужные символы по алфавиту (алфавит - это заранее подготовленный массив из 223 символов).
Остаточный левый бит из прочитанных исходных 5 байт заносим в буфер.
Если в буфере накоплено 39 бит или это конец входного потока данных, то представляем буфер как число из 39 бит кодируем его в текст как описано в п.5.
Если достигнут конец входного потока, то добавляем в конец кодированного текста 1 символ из алфавита, порядковый номер которого записан в переменной P (значение сколько мы добавили вспомогательных байт, чтобы прочитать сегмент длиной 5 байт).
Для лучшего понимания как работает описанный выше алгоритм на следующей анимированной диаграмме показан процесс кодирования небольшой строки:

Пример реализации алгоритма можно глянуть в следующем JS коде.
В реализации на JS столкнулся с проблемой производительности. Так как кодировка требуе�� работы с 64-битными числами, то в JS для этого потребовалось использовать BigInt. К моему удивлению, банально любая операция с BigInt в 10, а то и больше раз медленнее работает, чем такая же операция с 32-битными числами. Поэтому пришлось в алгоритме декодирования максимально убирать все операции с BigIng и стараться все вычислять на обычном 32-битном Number. Операцию кодирования не трогал, так как я её реализовал на JS чисто для теста.
На следующем графике показана зависимость эффективности кодирования от длины входных данных. Эффективность 97% достигается уже на 550 байтах, 97.45% на 2000 байт, 97.49 на 9750 байтах.

Временная сложность алгоритма - линейная O(n), т.е. время на кодирование одного байта одинаково для любой длины данных.
Сведем в одну таблицу все результаты исследования Binary-To-Text кодировок.
Кодировка |
Эффективность |
Плюсы |
Минусы |
Base64 |
75% |
Очень быстрая за счет нативных функций практически во всех языках. Безопасная для HTML. |
Низкая эффективность |
Ascii85/Base85 |
80% |
Относительно быстрая, так как работает с 32-разрядными числами, и не требует обработки дробных байт |
Относительно низкая эффективность |
Base96 |
~82.1% |
Относительно хорошая эффективность |
Сложность в реализации, особенно на JS из-за обильной работы с BigInt (int64) |
Base122 |
80-87.5% |
Высокая эффективность |
Плавающая эффективность и небезопасная для HTML. Высокий риск словить неожиданные проблемы с невозможностью декодирования данных |
Base223 |
~97.5% |
Наивысшая эффективность |
Требует использование Windows-1251 для закодированных данных |
Строим огромный HTML документ
С тем, как перенести бинарные файлы в HTML, разобрались. Теперь вопрос как организовать структуру документа, где и как будут располагаться файлы, в каком типе узла дерева документа (элементе, комментарии, текстовом узле и тд), в какой момент их извлекать и где хранить извлеченные данные, в памяти или где-то ещё?
Вопросов много, разберем их по порядку.
Хранение файла в структуре документа
Как уже выше упоминалось, в структуре документа необходимо хранить около 712 файлов, суммарный размер которых в уже закодированном виде составляет примерно 172МБ. Объем данных достаточно большой и важно, чтобы браузер не пытался эти данные использовать для отображения на странице, т.е. эти данные не должны участвовать в reflow, repaint и composite операциях браузера. И для этого есть 2 подходящих узла дерева документа (DOM):
Комментарий (
<!-- …. -->
)Тег скрипта с указанием типа application/octet-stream (
<script>
)
Изначально я думал все делать на комментариях, но отказался по причине достаточно строгих правил их наполнения. Для соблюдения этих правил потребовалось бы в алгоритме кодирования и декодирования добавлять логику исключения определенных последовательностей символов, либо сокращать размер алфавита на 2 символа >
и -
.
Поэтому выбор остановился на теге <script>
. Согласно HTML спецификации он подходит для задачи, так как его можно применять для хранения различных данных, не только javascript. Внутри тега script действуют особые правила парсинга, которые допускают использовать практически любое текстовое наполнение без экранирования спецсимволов. Единственно, что нельзя допускать - это наличие в данных последовательности символов тега закрытия скрипта - </script>
.
В алфавите кодирования Base223* уже исключен спецсимвол <
, поэтому уменьшать алфавит не нужно, и менять логику кодировщика тоже не нужно. Идеально!
В data-атрибуты тега добавим дополнительную информацию: путь файла относительно корня сайта, и алгоритм сжатия (gzip или без сжатия).
Пример тега script, в котором упакован текстовый файл с единственной строкой “Привет мир!” в UTF-8 кодировке:
<script type="application/octet-stream"
data-file="/misc/hello-world.txt"
data-compression="raw">ЦDжО?БЋ_ЅDЦс0FЇЦWТсІe‰оcY </script>
Чтение файлов из структуры документа
Очень важно сделать загрузку документа быстрой, и при этом стараться не держать весь объем декодированных файлов в оперативной памяти.
Распарсить 170+ МБ HTML данных не простая задача даже для быстрых браузеров, но все сильно усложняется, если помимо работы парсера браузера нужно ещё и декодировать Base223*.
Также оставлять в DOM множество script элементов с большим содержимым опасно, это может где-то, да сказаться на скорости работы DOM или CSSOM.
Учитывая все вышеизложенные опасения пришел к следующей схеме процесса загрузки файлов:
После того, как весь HTML был распаршен запускается поиск всех script элементов, содержащих закодированные файлы.
Содержимое каждого такого скрипта без декодирования переносится в IndexedDb - мощное хранилище, как раз идеально подходящее для хранения файлов.
Тег скрипта удаляется из DOM.
-
При запросе файла его данные выгружаются из БД и выполняется проверка:
Если данные в виде строки, то она декодируются, если необходимо, то распаковывается (gzip) в массив байт Uint8Array и затем обратно записываются в БД уже в виде массива распакованных байт;
Если данные в виде массива байт, то никакой дополнительной обработки не делается.

Таким образом после загрузки документа все файлы перемещаются в сыром виде в IndexedDB. По мере необходимость файл зачитывается из БД и если он ещё не дешифрован/распакован, то выполняется дешифрация (+распаковка gzip) и обратно записывается в БД.
Как результат DOM очищен от ненужных элементов, файлы хранятся не в памяти JS, и декодирование делается ленивым способом.
Boot-скрипт и прогресс загрузки
Теперь у нас есть встроенные файлы в HTML, мы знаем как и когда их читать из DOM, осталось обработать этап загрузки самого HTML, так как браузеру нужно какое-то время на чтение данных с диска и парсинг всего в DOM. Т.е. нужно показать пользователю этап загрузки документа в виде прогресс-бара. Также нам нужно удостовериться, что документ открывается в верной кодировке, иначе ничего работать не будет, и выполнить проверки наличия у браузера необходимых API для работы рендера анимации и самого интерфейса.
Для выполнения перечисленных требований появляется необходимость добавить boot-сектор в документ, который будет содержать незакодированный легковесный JavaScript код, выполняющий все необходимые проверки и подготовки документа.
Загрузочный js будет располагаться сразу после тега <meta>
, где указывается кодировка документа. У браузеров очень сложный механизм определения в какой кодировке читать документ. В этом механизме учитываются и заголовки ответа сервера, и наличие BOM-маркера в начале документа, и наличие различных meta тегов, указывающих кодировку, и ряд других проверок. У них у всех разный приоритет влияния и порядок выполнения. Увы, в некоторых случаях браузер будет игнорировать наличие мета тега <meta charset="windows-1251">
в шапке документа. Например, если этот html файл попытаться отдать с сервера, который отправит заголовок Content-Type: text/html; charset=utf-8
. Поэтому для обработки такой проблемы наш boot-скрипт будет пытаться расшифровать CSS файл, который также находится в head блоке. Если декодирование провалилось, то загрузка всего документа игнорируется и на экране выводит следующее сообщение.

Далее boot-скрипт проверяет, что браузер пользователя поддерживает все необходимые для работы проекта API и технологии. Для этого выполняется небольшой JS, в котором тестируются различные современные селекторы и проверяется наличие в JS определенных классов. Пример кода такой проверки:
function testBrowser() {
try {
res = CSS.supports('color: color-mix(in srgb, #ff00ff, transparent 50%)')
&& CSS.supports('selector(& .foo)')
&& CSS.supports('selector(a:is(.b, .c))')
&& CSS.supports('selector(a:has(.b))')
&& CSS.supports('mix-blend-mode: hard-light')
&& CSS.supports('background-clip: padding-box')
&& ('DecompressionStream' in window)
&& ('attachInternals' in document.documentElement)
&& ('replaceAll' in (new String('')))
&& ('endsWith' in (new String('')))
&& eval('/<template((?!<template).)+?<\\/template>/ms') instanceof RegExp;
} catch (e) {
return false;
}
return res;
}
Если проверка будет провалена, то пользователь увидит соответствующее сообщение о невозможности открыть страницу в этой версии браузера.
Далее boot-скрипт вставляет в блок <head>
ранее распакованный основной CSS проекта и дешифрует + распаковывает основной JS проекта (около 2.2МБ в распакованном виде). Этот JS будет добавлен в документ в самом конце, когда браузер сообщит, что он закончил парсить html.
Финальный этап работы boot-сектора - регистрация функции, которая будет обновлять значение в прогресс баре. Эта функция будет вызываться небольшими script элементами, которые расположены после каждого файла в структуре HTML.
<script type="application/octet-stream" data-file="/fin/1709142125-1-0.f796.br"
data-compression="raw">....</script>
<script>embeddedProg.updateLoader("/fin/1709142125-1-0.f796.br", 553265);</script>
<script type="application/octet-stream" data-file="/fin/1669618724-1-1.f796.br"
data-compression="raw">....</script>
<script>embeddedProg.updateLoader("/fin/1669618724-1-1.f796.br", 1073930);</script>
<script type="application/octet-stream" data-file="/interactive/chuck/punch.mp3"
data-compression="raw">....</script>
<script>embeddedProg.updateLoader("/interactive/chuck/punch.mp3", 23636);</script>
Таким образом по мере парсинга HTML браузер будет периодически прерываться на короткое время, чтобы выполнить js, обновляющий значение прогресc-бара.
Процесс загрузки документа с обновление прогресс бара показан на следующей гифке:

Заключение
Упаковать большой сайт в один файл html можно почти не потеряв в размере, если правильно подойти к вопросу выбора Binary-To-Text кодировки. В каких-то случаях, если сайт небольшой, достаточно будет и base64 или ascii85, но если сайт имеет большие файлы, то стоит рассмотреть нестандартные кодировки, которые могут значительно увеличить размер алфавита для шифрования. В моей случае хорошо подошла Windows-1251. А вот пытаться использовать UTF-8 для кодирования бинарных данных не стоит, так как либо вы получите надежный, но неэффективный алгоритм, либо эффективный, но ненадежный, как Base122.
Комментарии (10)
radtie
09.10.2025 07:20А вы не рассматривали хранение контента после загрузки документа в ObjectUrl вместо IndexDB?
ArrayBuffer > Blob > URL.createObjectURL
На первый взгляд это выглядит разумным, но может столкнулись с какими то ограничениями?horpia Автор
09.10.2025 07:20У меня гибридно. В IndexedDB я первоначально сохраняю, а далее в завимости от того, куда нужны данные выгружаю их в один из следующий форматов:
в ArrayBuffer, если данные нужны в бинарной форме. В частности сама анимация рисуется через JS, поэтому мне нужен массив байт для работы рендера.
в string, если это текстовые данные (css, js, json и тд);
в ObjectUrl через createObjectURL , если мне просто нужна ссылка на файл, чтобы отобразить в img теге или загрузить в Audio объект.
vybo
09.10.2025 07:20А где тут 96 ASCII-символов, 95 же только получается?
horpia Автор
09.10.2025 07:20В варианте кодировки с 96 символами я использовал алфавит, где был либо символ
<
, либо символ переноса строки. Оба эти символа допустимо использовать в блоке script. Если их оба использовать, то алфавит будет аж 97! Но все же для защиты от случайного закрытия тега скрипта, символ<
нужно убрать.
Zara6502
09.10.2025 07:20не добрался до дома чтобы проверить, что же станет с вашим файлом после применения расширения SingleFileZ :)
IgnatF
Столкнулся недавно с таким моментом, что хром в 11 версии не захотел эту кодировку применять. А так может лучше было бы ваш проект на старых добрых фреймах сделать. И грузить там по необходимости html документы.
horpia Автор
Спасибо за идею) С windows-1251 я много работаю на своей работе, пока что проблем с ней не ловил в браузерах. А по поводу iframe: все же цель сделать 1 файл для скачивания, который можно запустить просто кликнув по нему, а любая механика с iframe предполагает, что я буду загружать рядом лежащие файлы через него. Не уверен будет ли это вообще работать без веб-сервера, особенно доступ js к подгруженному в iframe контенту, ведь браузеры сильно ограничивают возможности подгрузки чего-либо, когда html запускается не по http(s).
here-we-go-again
А MHTML не подходит? Сто лет не видел, но он же вроде для того и сделан. Или наверное есть какая-то свежая его реинкарнация.
Еще можно гонять html в пдф файле. Там наверное проще тоже будет ресурсы внутри хранить.
horpia Автор
C MHTML много вопросов поддержки. Я его не использовал никогда, поэтому опыта 0 и нужно исследовать тщательно какие браузеры его смогут открывать локально, а какие нет. В любом случае, если не ошибаюсь, MHTML предполагает упаковки всего контента вверху документа, так, как это происходит при отправке писем. Тут сразу 2 проблемы:
размер, так как там используется base64, а он всего 75% эффективность имеет
не получится сделать прелодер.
horpia Автор
С PDF вероятно не получится никак. У меня используется достаточно современные API для рендера, которые поддерживаются только в браузерах. Не проверял, но очень сомневаюсь, что читалки PDF под капотом имеют встроенный браузер со всеми наворотами, типа Web Workers, IndexedDB, Canvas и прочее. Да и вроде лишь некоторые читалки вообще способны динамику делать в PDF, все остальные - тупо рендер статический, без динамических скриптов.