Как я сделал себе читалку FB2 и DjVu и почему на этом не остановился

—

от автора

На моём сайте уже были инструменты для работы с текстом, но с книгами получалась нелепая история. FB2 приходилось открывать в одной программе, DjVu -в другой, а если из книги нужен был не просто отрывок для чтения, а нормальный редактируемый текст, начиналась ручная сборка. Хотелось открыть файл прямо в браузере, видеть главы слева и не искать каждый раз, куда у конкретной программы спрятаны сноски. Сначала я добавил FB2. Потом выяснилось, что слово «читалка» скрывает несколько разных задач: прочитать XML, не сломать структуру книги, показать её удобно, выгрузить в другие форматы. Следом появился DjVu, у которого вместо XML —страницы с изображением и иногда текстовым слоем. А из желания превратить этот текстовый слой обратно в книгу выросла отдельная очередь задач. Расскажу, как всё это устроено и где я успел наступить на грабли.

FB2: файл простой, книга -не очень

FB2 — это XML. Кажется, что достаточно вызвать DOMParser, найти <body> и вывести абзацы. Для самого простого файла этого хватит. Для книги, которую хочется читать, -уже нет. Внутри встречаются вложенные <section>, заголовки из нескольких <p>, отдельное тело с примечаниями, ссылки вида l:href="#note1", картинки в <binary>, стихи, цитаты, таблицы. И ещё кодировка не обязана быть UTF-8: сначала приходится посмотреть BOM или XML-декларацию и только потом декодировать байты. Я сделал проход по XML-дереву, который за один раз собирает два представления: HTML для чтения и Markdown для экспорта. Оглавление получается из разделов книги, а не из попытки угадать заголовки по размеру шрифта. У каждого раздела есть свой внутренний ID, поэтому переход из левой колонки ведёт к конкретному месту в тексте. С Markdown была особенно обидная ошибка. В PDF «Глава 1» выглядела как глава, а в Markdown могла стать обычной строкой. Причина оказалась в том, что вложенность XML-разделов не равна вложенности видимых заголовков. В FB2 бывает <section> без <title> -просто обёртка. Если увеличивать уровень заголовка на каждой такой обёртке, к настоящей главе уже добираешься на неверной глубине. Теперь глубина растёт только у раздела, у которого действительно есть видимый заголовок. Несколько абзацев внутри <title> я соединяю в одну строку перед записью Markdown: заголовок Глава 1 плюс Начало пути должен остаться одним заголовком, а не двумя соседними блоками.

const hasHeading = Boolean(titleLabel || (name === 'body' && label));const nextHeadingDepth = headingDepth + (hasHeading ? 1 : 0);const level = Math.min(6, headingDepth + 2);

Сноски тоже не захотели укладываться в «обычную ссылку». Часто они живут в отдельном <body name="notes">. Для чтения удобнее увидеть примечание сразу: навёл мышь или перевёл фокус клавиатурой на ссылку -рядом появился небольшой блок с текстом. Клик по ссылке по-прежнему работает. Пришлось учесть и книги, в которых ID сноски стоит на заголовке, а сам текст лежит в следующих абзацах. Если взять только textContent элемента с ID, в подсказке будет одно слово «Примечание». HTML из FB2 я не вставляю на страницу как есть. Парсер сам создаёт разметку из известного набора элементов и атрибутов; неизвестные теги не получают права тащить в документ скрипты, стили или произвольные ссылки. Это важно, потому что книга -такой же чужой файл, как и любой другой загруженный документ.

Экспорт: один исходник, три разных результата

Из читалки можно сохранить Markdown, DOCX и PDF. Markdown собирается при разборе FB2. Для DOCX и PDF он уходит на сервер, где уже существовал конвейер с Pandoc; PDF дальше проходит через LuaLaTeX. Звучит как готовое решение, пока не нажимаешь «Экспорт» на реальной книге. У меня первая версия PDF отвечала коротким Error generating PDF. Позже нашёл ещё более тихую проблему: Markdown-экспорт был формально успешным, но главы теряли уровень. Визуально книга открывалась, поэтому заметил это только когда попытался редактировать выгруженный текст. Отдельно пришлось разбираться с буквальной пунктуацией. Строка вроде Издательство (A & B), скидка [100%] -обычный книжный текст, но при проходе через Markdown и LaTeX некоторые сочетания символов могут получить специальный смысл. Здесь нельзя ограничиться проверкой «файл скачался»: я прогонял тестовую книгу через экспорт и проверял текст уже внутри полученного PDF. После этого экранирование обычной прозы стало отдельным правилом, а не побочным эффектом рендера. Ссылки на сноски при экспорте сохраняют цели. При этом я не добавляю пустой якорь к каждой XML-обёртке: визуальные Markdown-редакторы превращали такие якоря в пустые блоки между абзацами. Якорь нужен только там, куда действительно ведёт ссылка.

DjVu -это не «FB2, только другой файл»

С DjVu подход пришлось менять. Там книга состоит из страниц. У страницы есть изображение, а текстовый слой может быть или отсутствовать. Изображение нужно показать быстро, текст -дать прочитать отдельно, оглавление -связать с номерами страниц. Для чтения использовал DjVu.js в Web Worker. Браузер открывает файл локально; серверу для просмотра ничего не отправляется. У библиотеки беру число страниц и закладки, превращаю ссылки из закладок в номера страниц. Для текущей страницы запрашиваю либо PNG-представление, либо текстовый слой. В интерфейсе это два режима: «Страница» и «Только текст».

const pageText = await worker.doc.getPage(page).getText().run();const pageImage = await worker.doc.getPage(page).createPngObjectUrl().run();

Здесь быстро обнаружилась мелочь, которая заметна именно при листании: пользователь уже перешёл на следующую страницу, а результат предыдущего асинхронного запроса приходит позже. Поэтому у каждого запроса есть номер поколения. Если ответ устарел, я его не показываю, а созданный URL картинки освобождаю. Страницы без текстового слоя не считаю ошибкой чтения: картинку всё равно можно посмотреть. Но конвертировать такую книгу в FB2 из одного только DjVu-файла читалка не умеет. OCR изображений в этом проекте нет -это отдельная задача, и я не хотел выдавать пустую книгу за результат конвертации. Обе читалки принимают файл кнопкой и перетаскиванием. Если бросить FB2 на страницу DjVu Reader или DjVu на страницу FB2 Reader, сайт сам откроет нужный просмотрщик и передаст ему выбранный файл. Для пользователя это мелочь, но иначе приходилось сначала понять формат файла, потом вспомнить правильный экран и снова искать его на диске.

Когда захотелось получить FB2 из DjVu

Читать текстовый слой -одно дело. Сделать из него связную книгу -совсем другое. В сыром тексте страницы могут заканчиваться посередине предложения, рядом попадаются номера страниц, колонтитулы и обрывки сносок. Простое объединение всех строк даёт текст, который неприятно читать. Оглавление DjVu я использую как каркас будущего FB2. Закладка указывает на страницу, но иногда новая глава начинается в середине этой страницы. Тогда ищу в её тексте сам заголовок и делю страницу в найденном месте: фрагмент до него остаётся в предыдущей главе, фрагмент после — идёт в новую. Закладки вида p0001.djvu, которые на деле просто перечисляют страницы, из оглавления исключаю. Дальше текст режется на блоки примерно по 6000 символов. В нейросетевую обработку уходит только новый блок и короткий хвост предыдущего результата для согласования границы. Заголовок главы передаётся как контекст, но в ответе не нужен: он уже придёт из оглавления. От модели ожидается не свободный пересказ, а небольшой XML-фрагмент с ограниченными тегами FB2 — абзацы, подзаголовки, жирный, курсив и несколько других элементов. Ответ разбирается и проверяется. Если модель вернула пустоту, странную разметку или подозрительно мало текста, блок не принимается. Первая версия делала всё прямо во вкладке. Она работала, пока вкладка была открыта. Стоило закрыть браузер на длинной книге — работа пропадала. Для задачи из десятков блоков это плохой контракт. Теперь браузер сначала извлекает из DjVu текст и оглавление, отправляет их как одну задачу, а дальше обработка идёт на сервере. Исходные блоки, готовые фрагменты и прогресс лежат в базе. После сбоя или перезапуска процесс начинает с первого несделанного блока; временную ошибку отдельного блока пробует повторить сам. Когда FB2 собран, его можно скачать или сразу открыть в той же читалке. Вкладку разрешено закрыть после того, как задача появилась в очереди: до этого браузер ещё читает файл и готовит данные. Сами изображения страниц в очередь не отправляются. Это осознанное ограничение: конвертация работает с уже существующим текстовым слоем, а итоговый FB2 не сохраняет сканы как иллюстрации. На моём сайте у задач есть лимиты; кредиты списываются за готовую книгу, а не за ошибку на середине обработки.

Что получилось на практике

Сейчас у меня два режима работы с одной и той же книгой. Если нужно просто читать DjVu, всё остаётся в браузере: картинка, текстовый слой, оглавление. Если нужен аккуратный редактируемый результат, отправляю книгу в очередь и позже открываю FB2 уже на сайте. Для готового FB2 есть экспорт в Markdown, DOCX и PDF. Самой сложной частью оказалась не отрисовка страницы и даже не вызов модели. Больше времени ушло на места между форматами: кодировки, глубину заголовков, ссылки на сноски, пунктуацию при экспорте, границы глав на одной странице и сохранение прогресса при закрытой вкладке. Именно на этих мелочах читалка либо становится привычным инструментом, либо остаётся демо, которое красиво открывает один тестовый файл. Если хочется попробовать на своей книге: FB2 Reader, DjVu Reader. Для DjVu с текстовым слоем есть отдельный экран «Мои книги» с очередью конвертации.

ссылка на оригинал статьи https://habr.com/ru/articles/1101898/