Книга из переписки без сервера: canvas, jsPDF и брошюра для печати прямо в браузере

Мы делаем пет‑проект «Чатограмма», который считает статистику переписки: кто пишет первым, кто быстрее отвечает, в какие часы вы на связи. С первого дня у него одно жёсткое правило: файл с перепиской не уходит на сервер. Всё считается в браузере пользователя, а сервер не видит ни одного сообщения — не потому, что обещает не смотреть, а потому, что сообщения до него физически не доходят.
Потом понадобилась подарочная книга: PDF формата A5, где самые яркие моменты переписки собраны по главам — с чего всё началось, первое «мы с тобой», лучшие моменты каждого года, общий словарь, квиз «Кто это написал?». Плюс вариант для печати дома брошюрой: A4, сложить пополам, скрепить степлером.
И здесь правило «ничего на сервер» перестаёт быть бесплатным. Книгу обычно верстают на сервере: headless Chrome, LaTeX, что угодно с нормальными шрифтами и без ограничений по памяти. У нас всё это должно работать на телефоне, в том числе на iPhone, в том числе во встроенном браузере мессенджера. Ниже — как это устроено и на какие грабли мы наступили.
Конвейер: от файла до материала книги
На входе два формата: JSON‑экспорт Telegram (до 150 МБ) и архив данных ВКонтакте — zip, в котором переписки лежат HTML‑страницами в кодировке windows-1251.
Разбор идёт в одноразовом Web Worker: файл уходит туда через postMessage, обратно возвращается готовый результат, после чего воркер завершается. Так память с полным текстом переписки освобождается целиком, одним terminate(), и не висит в основном потоке, пока человек листает мастерскую.
const worker = new Worker(new URL('./analyze.worker.ts', import.meta.url), { type: 'module', name: 'analyze' });
worker.postMessage({ op: 'analyze', source, timeZone });ZIP без библиотек
Архив ВКонтакте бывает на десятки мегабайт, а нужен из него обычно один диалог. Читать весь zip в память на телефоне — плохая идея, поэтому распаковка своя, без библиотек.
Через
blob.slice()читаем хвост файла, находим End of Central Directory (с поддержкой ZIP64), затем центральный каталог. Это оглавление архива: имена файлов и смещения.Нужные записи читаем точечно, тем же
blob.slice(offset, end).arrayBuffer(). Соседние файлы склеиваем в куски до 8 МБ и читаем по четыре параллельно.Распаковываем встроенным
DecompressionStream:
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
return new Uint8Array(await new Response(stream).arrayBuffer());Оглавление архива на 85 МБ читается за 82 мс. HTML‑страницы декодируются new TextDecoder('windows-1251').
В книгу попадает не всё
Книга — это не распечатка чата. Из всей переписки ядро вырезает «материал книги»: не больше 32 фрагментов, в каждом до 8 сообщений, каждое сообщение — до 200 символов, всего не больше 24 000 знаков. На реальной переписке в 51 тысячу сообщений материал весит около 17 КБ JSON.
Часть сообщений не попадает в материал никогда: пересланные, содержащие телефон, почту, номер карты или мат. Telegram облегчает задачу — он сам размечает в экспорте text_entities с типами phone, email, bank_card. У ВКонтакте в архиве голый текст, поэтому там работают свои регулярки. Мат ищется по корням с приставками, а не по списку словоформ, с исключениями вроде «себестоимости» и «мандарина».
Самое полезное решение здесь — типовое. Любой текст сообщения, который может попасть наружу, имеет брендированный тип:
declare const privateBrand: unique symbol;
/** Текст из переписки. Получить можно только через makeExcerpt; в ChatReport такого типа быть не должно. */
export type PrivateText = string & { readonly [privateBrand]: true };Получить такую строку можно только через функцию, которая применяет все фильтры. А дальше работает проверка на уровне типов: рекурсивный ContainsPrivate<T> ищет PrivateText в любом вложенном поле.
// Проверка на этапе typecheck: добавите в отчёт поле с PrivateText — сборка упадёт здесь.
const reportHasNoPrivateText: Exactly<ContainsPrivate<ChatReport>, false> = true;
// Материал подарочной книги — тоже только PrivateText.
const bookHasPrivateText: Exactly<ContainsPrivate<BookMaterial>, true> = true;Отчёт со статистикой может уйти в синхронизацию или в карточку для сторис, поэтому текстов сообщений в нём быть не должно. Если кто‑то случайно добавит туда поле с текстом, упадёт не тест в рантайме, а tsc.
Как находятся «моменты»
Никакого ИИ: всё — эвристики по самой переписке. Несколько примеров, где пришлось повозиться.
Первое «люблю». \b в JavaScript не работает с кириллицей, поэтому границы слова приходится задавать через Unicode‑lookbehind. Заодно нужно отсечь «не люблю»:
const NOT_NEGATED = '(?<!(?<!\\p{L})не\\s{1,3})';
`${NOT_NEGATED}(?<!\\p{L})(?:люблю|любим)\\s+(?:\\p{L}+\\s+)?${YOU}(?!\\p{L})`,
`${NOT_NEGATED}(?<!\\p{L})люблю(?!\\p{L})[\\s!.,)…]*(?:${HEARTS}|$)`,Если ответ прозвучал не сразу, ищем первое ответное сообщение собеседника в течение суток и ставим между ними плашку «… ещё N сообщений …».
Что считать одним разговором. Разговор — это цепочка сообщений без паузы длиннее N часов. Сначала мы взяли 6 часов, и «самый длинный разговор» на реальной переписке склеился в 62 часа — фактически в три дня жизни. С паузой в 3 часа он стал 19-часовым, и это похоже на правду.
«Ночь, когда мы не могли наговориться». Название главы нельзя знать заранее: на реальных чатах самый длинный разговор часто шёл днём, с 9 до 19. Поэтому «ночь» — только если на промежуток 23:00–06:00 пришлось не меньше 30% сообщений, иначе глава называется «День, когда…».
Лучший момент года. Во ВКонтакте реакций нет, поэтому у сообщения своя оценка: реакции, ответы, смех в ответ, «тёплые» слова, длина. Равные оценки разводятся цепочкой признаков, последним идёт хеш — так порядок всегда однозначен:
return b.score - a.score || Number(b.warm) - Number(a.warm) ||
Math.abs(a.length - 60) - Math.abs(b.length - 60) ||
Number(b.quickReply) - Number(a.quickReply) || a.hash - b.hash; // FNV-1a от времени сообщенияId фрагмента — ${ts}:${authorKey}, а не порядковый номер: человек отметил моменты, через месяц выгрузил переписку заново — выбор сохранился.
Квиз, который не подсказывает ответ. «Кто это написал?» — самая весёлая глава, и её легче всего испортить. Отбрасываем сообщения с именем или прозвищем любого из двоих в любом падеже. Отбрасываем списки покупок. А если в паре люди разного пола, отбрасываем и глаголы прошедшего времени — «я пришла» выдаёт автора быстрее любой подсказки:
return GENDERED_ADJ.test(word) || (word.length >= 4 && /(ла|лась|лся|ал|ял|ел|ил|ыл|ул|ол)$/.test(word));Если на последнюю страницу квиза попадают один‑два вопроса, мы их выкидываем, чтобы не было почти пустой страницы. Вопросы «кто написал» убираем только парами — чтобы про каждого из двоих их оставалось поровну.
Вёрстка на canvas
Страница — это холст 1240×1754 px, то есть около 213 dpi на формате 148×210 мм. Рисуем в основном потоке на обычном CanvasRenderingContext2D, один холст переиспользуется для всех страниц.
Главное архитектурное решение — один измеритель на план и на рисунок. Пагинация (plan.ts) и раскладка (layout.ts) — чистые функции, ширину текста им передают снаружи:
const measure: Measure = (text, font) => { probe.font = font; return kit.measure(probe, text); };Благодаря этому номера страниц в оглавлении совпадают с тем, что нарисовано. Звучит очевидно, но с двумя разными способами измерения текста — для плана и для рисования — оглавление начинает врать на одну‑две страницы, как только в книгу попадает длинное слово с эмодзи.
Перенос и пагинация
Переносим по словам. Слово шире строки — ссылку, «ахахахаха…» на полстроки, цепочку эмодзи — режем по графемам через
Intl.Segmenter('ru', { granularity: 'grapheme' }), чтобы не разорвать эмодзи из нескольких кодовых точек или букву с ударением.Фрагмент переписки рвётся только между пузырями. Целиком на следующую страницу он переносится, если помещается на чистую страницу, а на текущей осталось меньше 40% места.
Разорванный разговор уносит на новую страницу не меньше трёх реплик — одна висящая реплика читается как ошибка вёрстки. На новой странице ставим пометку «… · продолжение».
Заголовок фрагмента не остаётся внизу страницы один: вместе с ним должен поместиться хотя бы первый пузырь.
Эмодзи и шрифты
Эмодзи рисуем картинками Twemoji, а не системным шрифтом. На Windows 10 новые символы вроде 🫶 системный шрифт рисует пустым квадратом, а книга должна выглядеть одинаково у всех. 4009 SVG лежат на том же домене (про причины — ниже, в разделе про CSP). Текст режем на куски через Intl.Segmenter и \p{Extended_Pictographic}, нужные картинки подгружаем заранее, эмодзи занимает ширину 1,1 кегля.
Шрифтовая мелочь, стоившая вечера. document.fonts.load() без образца текста грузит только то подмножество шрифта, где есть пробел, то есть латиницу. Кириллица подгружается позже, уже после того, как canvas нарисовал страницу запасным шрифтом:
// С кириллицей в образце: без текста браузер грузит только подмножество с пробелом (латиницу).
document.fonts?.load('700 80px Unbounded', 'Аа'),
document.fonts?.load('400 34px "Golos Text"', 'Аа'),Рукописный шрифт для заметок на полях и письма (Caveat) грузится через FontFace только при открытии мастерской книги: скачивать его всем посетителям сайта незачем.
PDF: jsPDF, картинки и кликабельное оглавление
jsPDF подгружается динамически, только в момент сборки. Каждая страница уходит в PDF картинкой JPEG с качеством 0,88. Книга из 28 страниц весит около 3 МБ.
const pdf = new jsPDF({ unit: 'mm', format: 'a5', orientation: 'portrait', compress: true });
book.pages.forEach((bytes, i) => {
if (i > 0) pdf.addPage();
pdf.addImage(bytes, 'JPEG', 0, 0, 148, 210, undefined, 'FAST');
});При этом оглавление в PDF кликабельное и есть закладки по главам. Функция, рисующая страницу, возвращает не только картинку, но и прямоугольники ссылок в пикселях холста. При сборке PDF они пересчитываются в миллиметры и превращаются в link‑аннотации — на страницу книги или наружу, как у QR‑кодов песен:
const k = 148 / 1240; // мм на пиксель холста
for (const { onPage, links } of book.links) {
pdf.setPage(onPage);
for (const l of links) pdf.link(l.box.x * k, l.box.y * k, l.box.w * k, l.box.h * k, l.url ? { url: l.url } : { pageNumber: l.page });
}
const root = pdf.outline.add(null, title, { pageNumber: 1 });
for (const chapter of book.plan.chapters) pdf.outline.add(root, chapter.title, { pageNumber: chapter.page });Честный минус такого подхода: текст в PDF нельзя выделить и найти поиском. Для подарочной книги мы сочли это приемлемым. Векторный текст потребовал бы встраивать в PDF шрифты с кириллицей, рукописный Caveat и свою систему эмодзи — это заметно больше кода и мегабайты в бандле.
33 секунды, которые стали одной
Первая версия собирала книгу из 32 страниц за 33 секунды — замер на реальной переписке, когда вкладка ушла в фон. Причин оказалось две, и обе про фоновую вкладку.
canvas.toBlob(). В фоновой вкладке Chrome откладывает его колбэк примерно на секунду. Человек нажал «Скачать», переключился в соседнюю вкладку — и каждая страница стоит секунду. Синхронный toDataURL() занимает около 40 мс на страницу.
Уступка потоку между страницами. Между страницами нужно отдавать управление, иначе интерфейс замирает и не показывает прогресс. setTimeout(0) в фоновой вкладке растягивается до секунды — та же проблема. Попробовали scheduler.yield(): его продолжение встаёт впереди остальных задач, и React не успевал отрисовать «Собираем… 12 из 32». Сработал старый трюк с MessageChannel:
function nextTick(): Promise<void> {
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => resolve();
channel.port2.postMessage(null);
});
}Итог: 0,7–1,3 секунды на 32 страницы, с живым счётчиком.
Брошюра: печать дома на обычном принтере
Второй файл — для печати: A4 альбомной ориентации, по две страницы на каждой стороне листа. Стопку листов складывают пополам и скрепляют степлером по сгибу — это переплёт внакидку, как у тонкого журнала. Чтобы после сгиба страницы шли по порядку, их нужно разложить по листам в особом порядке (imposition).
Для книги из N страниц, где N кратно четырём, лист s (с нуля) выглядит так: на лицевой стороне страницы [N − 2s | 1 + 2s], на обороте [2 + 2s | N − 1 − 2s].
export function bookletSides(pages: number): BookletSide[] {
if (pages % 4 !== 0 || pages <= 0) throw new Error(`Брошюра собирается из числа страниц, кратного четырём: ${pages}`);
const sides: BookletSide[] = [];
for (let s = 0; s < pages / 4; s++) {
sides.push({ left: pages - 2 * s, right: 1 + 2 * s });
sides.push({ left: 2 + 2 * s, right: pages - 1 - 2 * s });
}
return sides;
}Для восьми страниц получается 8|1, 2|7, 6|3, 4|5.

Кратность четырём обеспечивает план. Недостающие страницы добиваются не пустыми листами, а страницами «Продолжение следует» с вопросами — их заполняет от руки тот, кому подарили книгу. Пустая страница выглядит как брак, а страница с вопросом «Первое, о чём подумалось, когда открылась эта книга» — как задумка.
Ещё одно практическое ограничение: больше 48 страниц (12 листов) обычный степлер не пробивает. Сборку это не блокирует, но мастерская честно предупреждает, что для брошюры на скобе страниц многовато.
Телефон: поделиться файлом
На телефоне естественное действие — не «скачать», а «отправить в мессенджер». navigator.share({ files: [file] }) это умеет, но на iOS окно «Поделиться» открывается только в ответ на касание: примерно в пределах секунды после тапа. Собрать PDF за это время нельзя, поэтому книга собирается заранее — как только готов предпросмотр. По тапу вызывается уже готовый файл, без await перед share.
Где файлом поделиться нельзя — на компьютере и во встроенных браузерах мессенджеров на Android, — PDF скачивается через <a download>. Safari начинает скачивание только после клика, поэтому URL.revokeObjectURL мы вызываем с задержкой в 10 секунд, а не сразу.
Хранение и синхронизация
Черновик книги (выбранные моменты, надпись, письмо) и фото для обложки живут в IndexedDB. Фото перед сохранением уменьшается до 1800 px по длинной стороне; поворот берём из EXIF через createImageBitmap(file, { imageOrientation: 'from-image' }).
Если человек хочет открыть книгу на другом устройстве, черновик и материал книги синхронизируются через аккаунт — но только зашифрованными:
из 32 случайных байт через HKDF‑SHA256 выводятся неизвлекаемый ключ AES‑GCM-256 и идентификатор ключа — сервер видит только идентификатор;
перед шифрованием данные сжимаются gzip, книга после сжатия весит 12–19 КБ;
в дополнительные данные (AAD) входят идентификатор снимка и хеш открытой части, чтобы сервер не мог подложить зашифрованный blob к чужому отчёту;
ключ на второе устройство переходит QR‑ссылкой, где секрет стоит после
#. Эту часть адреса браузер на сервер не отправляет.
Фото в синхронизацию не уходит никогда.
Отдельная боль — Safari: IndexedDB там может подвиснуть, пока базу держит другая вкладка. Поэтому все операции с хранилищем обёрнуты в таймауты.
Приватность: что обещает CSP, а что — нет
Сайт отдаётся с такой политикой (сокращённо):
default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data: blob:;
worker-src 'self' blob:; frame-ancestors 'none'connect-src 'self' и img-src без внешних доменов значат, что страница не может отправить данные на чужой домен: ни fetch, ни WebSocket, ни трюком с адресом картинки. Поэтому эмодзи, шрифты, аналитика — всё на своём домене, без CDN.
Но важно не переоценивать CSP: отправку на свой же домен он не запрещает. То, что файл не уходит на наш сервер, обеспечивает код, а не заголовок. Поэтому пользователю мы предлагаем проверку, которую можно сделать без доверия к нам: открыть сайт, включить авиарежим и выбрать файл. Статистика посчитается без сети. Работу офлайн обеспечивает service worker, заранее закешировавший приложение.
Мелочи, каждая из которых стоила времени
Сколько дней между датами. 15 марта 00:10 → 23 марта 14:00 — это 8 календарных дней, а округление разницы в миллисекундах даёт 9. Считаем через даты в часовом поясе переписки:
const day = (ts: number) => Date.parse(`${new Intl.DateTimeFormat('en-CA', { timeZone }).format(ts)}T00:00:00Z`); return Math.round((day(to) - day(from)) / 86_400_000);Длина строки — не число символов. Лимит в 200 символов проверялся через
text.length, и сообщения с эмодзи обрезались или выбрасывались: эмодзи занимает в JS‑строке две позиции, а то и больше. Теперь[...text].length.Сообщения в одну секунду. Стикер с подписью, альбом из фото — несколько сообщений с одинаковым временем делили один ключ. Одно пропадало, а «скрыть сообщение» скрывало оба. Лечится сдвигом на +1 мс при совпадении.
Lookbehind и старый Safari. Регулярка с
(?<!…)в литерале ломает разбор всего JS‑файла в Safari до 16.4 — не выражение, а файл целиком. Это фактически определило минимальную версию Safari для всего сайта.«Поздняя ночь» по первой версии правила на реальной переписке оказалась ночной пересылкой файлов. Теперь это ночной разговор с наибольшим числом собственных текстовых сообщений.
Что получилось и что бы мы сделали иначе
Что | Число |
|---|---|
Разбор и метрики, 100 тыс. сообщений | меньше 10 с (тест в CI) |
Метрики на 500 тыс. сообщений в Node | 3,5–4,3 с; в браузерном Worker примерно в 1,5 раза медленнее |
Материал книги | 47 мс на 100 тыс. сообщений |
Сборка PDF, 32 страницы | 0,7–1,3 с (было 33 с) |
PDF, 28 страниц | около 3 МБ |
Что бы мы попробовали дальше:
Рисовать страницы в воркере через OffscreenCanvas, чтобы основной поток вообще не участвовал в сборке. Сейчас холст живёт в основном потоке, а уступки через
MessageChannel— компромисс.Векторный текст в PDF, чтобы книгу можно было искать и выделять. Упирается в размер шрифтов и эмодзи.
А как бы вы сделали текст в таком PDF выделяемым, не таща в бандл мегабайты шрифтов? Интересно услышать, если кто‑то решал похожую задачу на клиенте.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.