Как я искал себе читалку и придумал новый формат электронных книг для изучающих язык

от автора

Небольшая история о том, как я искал для себя читалку, как в итоге придумал новый формат для электронных книг — и, главное, как я месяцами воевал с дешёвыми LLM, чтобы они генерировали этот формат без ошибок. Будет и про устройство формата, и про то, как он «затюнен» под копеечные модели, и про основные проблемы.

TL;DR. .tbook это ZIP‑контейнер (как EPUB), в котором оригинал книги лежит рядом с попредложенным переводом и пословным выравниванием: тапаешь слово в оригинале — мгновенно видишь перевод предложения с подсвеченным ровно тем словом, которое ты тапнул. Всё считается на этапе конвертации, поэтому читалка работает офлайн, без запросов в сеть. Самым сложным оказалось не придумать формат, а заставить дешёвые LLM его надёжно генерировать.

С чего всё началось

Я обожаю читать. Я читаю минимум 1 книгу в неделю, иногда по книге за 1–2 дня. С 6 лет. Сейчас мне 40 — значит, примерно 6 тысяч книг у меня в истории (хотя многое перечитано до дыр по 10 раз, сложно подсчитать). И, так как я активно учу английский, мне хочется читать в оригинале. И я читаю в оригинале. И мне было постоянно больно пользоваться читалками с переводом. Я перепробовал вообще ВСЁ. Я был фанатом метода Ильи Франка с 2001 года. Я перечитал так МНОГО книг.

FBReader? Устанавливай словари. Словари не показывают слово в контексте, для многозначных слов долго искать нужное значение. Не очень удобные подсказки. Google Translate опционально — ты тапаешь слово и ждёшь соединения. Да, сейчас это быстрее, чем в 2000–2010-х, но, учитывая общую скорость чтения, всё равно безумно медленно. Я не хочу терять картинку в воображении, мне всегда хотелось иметь максимальную скорость.

Пару лет назад нашёл приложение Linga, которое переводит по запросу через AI и показывает использование слова в контексте. Это был почти личный идеал — если бы не ещё большее время ожидания (время на запрос к AI).

И потом, стихийно, я понял, что нам нужно пересмотреть подход к самому формату и процессу создания переводов.

Что мне нужно как активному читателю, изучающему язык?

  1. Мгновенность. Ноль ожидания. Тапнул — увидел.

  2. Не художественный перевод текста на родной язык целиком. Его минус в том, что переводчики зачастую используют совсем другие слова и часто значительно меняют текст. Мне нужен художественный перевод отдельного предложения, где использовался бы примерно тот же вокабуляр, что и в оригинале.

Идея: перенести всю работу на этап конвертации (SSR для книг)

И вот в 2026 году меня осенило. Я понял, что нужно сделать в читалке подход в духе SSR (server‑side rendering): не считать перевод в момент, когда читатель тапнул слово, а посчитать его один раз заранее и «запечь» прямо в файл книги.

Что может быть проще — берём любую EPUB‑книжку, разбиваем на предложения, скармливаем LLM, получаем перевод с нашей разметкой. EPUB — это zip‑based формат, внутри просто архив. Я решил сделать схожим образом и начал собирать свой формат .tbook.


Часть 1. Как устроен формат.tbook

Контейнер

.tbook — обычный ZIP‑архив (как EPUB). Внутри:

book.tbook  (ZIP)├── manifest.json          — метаданные + оглавление├── cover.jpg              — обложка (опционально)├── notes.json             — тела сносок (опционально)├── images/                — картинки из тела книги (опционально)└── chapters/    ├── ch1.json           — одна глава = один файл    ├── ch2.json    └── …

Читалка сначала читает manifest.json, а главы находит только через manifest.chapters[].file — никаких предположений о нумерации и порядке файлов. Всё, что читалка не знает (новые поля, новые entries), она обязана игнорировать. Это даёт формату аддитивную обратную совместимость: можно добавлять фичи, не ломая старые читалки.

Атом формата — предложение

Вся книга — это главы → абзацы → предложения. Предложение — это атомарная единица перевода:

{    "src": "Stan went to the living room.",    "words": [[0,4],[5,9],[10,12],[13,16],[17,23],[24,28]],    "tr": {        "ru": {            "text": "Стэн прошёл в гостиную.",            "align": [                { "t": [0,4],   "w": [0] },                { "t": [5,11],  "w": [1] },                { "t": [12,13], "w": [2] },                { "t": [14,22], "w": [4,5] }            ]        }    }}
  • words — массив слов. Каждое слово — это пара символьных смещений [начало, конец) (полуинтервал) в строке src. То есть слово № 0 = src[0:4] = "Stan". Пунктуация и пробелы между словами намеренно не входят ни в одно слово.

  • tr — карта код языка → перевод. Внутри перевода: text (полный перевод предложения) и align (пословное выравнивание).

  • align — сердце формата. Каждый «чанк» связывает диапазон символов в переводе (t) с индексами исходных слов (w), которые этот кусок переводит.

Алгоритм tap‑to‑translate

Когда читатель тапает по слову в оригинале:

  1. По позиции тапа находим индекс i слова в words (в какой диапазон [a, b) попал тап).

  2. Берём перевод tr[язык].text.

  3. Подсвечиваем каждый чанк, у которого i есть в списке w.

Пара важных деталей, которые легко испортить:

  • Смещения — это символы (Unicode code points), а не байты. Producer (Go) пишет индексы по code point, consumer (Kotlin/Java) считает в UTF-16. Для всего, что в BMP (латиница, кириллица, CJK, почти вся европейская письменность), одно = одному, и они совпадают. Расходятся только на «астральных» символах (эмодзи, редкие CJK‑расширения). Поэтому читалка обязана защищаться: клампить каждое смещение в границы строки и пропускать вырожденные диапазоны — LLM ошибается. Дешевые модели — тем более.

  • Почему смещения хранятся явно, а не вычисляются на лету? Потому что китайский, японский, тайский не разбиваются по пробелам. Токенизировать их на клиенте нельзя, поэтому границы слов запекаются в файл на этапе конвертации.

Source‑as‑pivot: почему связываем по предложениям, а не по словам

Ключевое проектное решение: связывать языки нужно не по словам, а по предложениям, а уже внутри связанных предложений хранить пословное выравнивание.

И связываем всё через один опорный (pivot) язык — оригинал. Каждый целевой язык выравнивается к оригиналу, а не попарно с каждым другим. Это «хаб и спицы»: добавить язык стоит N−1 переводов на предложение, а не N² попарных комбинаций. Хочешь докинуть немецкий в книгу, где уже есть русский и испанский, — добавляешь одну карту tr["de"], ничего существующего не трогая.

И ещё: раз каждый перевод делается из исходного предложения, выравнивание генерируется прямо в процессе перевода. В читалке нет статистического выравнивателя (eflomal / awesome‑align / SimAlign) и нет слоя sentence‑alignment. Всё уже посчитано.

Что ещё умеет формат (по мелочи)

Чтобы книга выглядела как книга, а не как поток текста, формат опционально хранит: роли абзацев (subtitle, heading, sceneBreak, figure, table), инлайновые выделения (курсив/жирный как символьные диапазоны spans), картинки с переводимыми подписями, таблицы с переводимыми ячейками и сноски (маркер в тексте + тело сноски книжного уровня). Всё это — «презентационные подсказки»: минимальная читалка может их игнорировать и всё равно покажет корректный тапабельный текст.


Часть 2. Как это «затюнено» под дешёвые LLM

Вот здесь и была вся боль. Красивый формат придумать легко. А теперь попробуй заставить копеечную модель с openrouter.ai надёжно его сгенерировать — на всей книге, а не на трёх предложениях в демо.

Главный вывод, к которому я пришёл экспериментально: дешёвые модели — приличные переводчики, но ненадёжные генераторы структуры. Почти каждое решение в конвертере вытекает из этого факта.

Почему JSON, а не TOON

Первые итерации ломались по синтаксису. Мне хотелось, чтобы дешёвые LLM с openrouter.ai могли конвертировать книжки и не ошибаться в генерируемом манифесте. Я протестировал больше чем на 20 разных моделях и понял, что эффективные форматы вроде TOON (modern json‑like формат для LLM, более экономный по токенам) на многих моделях — особенно китайских — ведут себя нестабильно и добавляют проблем с сериализацией. Так что в формате чуть менее эффективный, но надёжный JSON для хранения глав и манифестов. Правило простое: надёжность важнее экономии токенов, если ты отдаёшь генерацию слабой модели.

Центральная проблема: позиционный дрейф (positional drift)

Это была самая коварная ошибка, и она едва не стоила мне всего проекта, потому что она проходит валидацию.

Validation: 6430 sentences, 0 empty, 0 offset_errors — OK

Но я открываю книгу и вижу, что слова связаны неправильно. Тапаю «briefcase», подсвечивается «миссис». Перевод предложения при этом идеальный! Просто карта слов сдвинута ровно на одну позицию. Тапаешь «Dursley» — загорается «всегда». В испанской книге про привычки было ещё красивее: el interés compuesto («сложный процент») дрейфовал так, что тап по interés (процент) подсвечивал сложный, а тап по compuesto (сложный) — процент. Оба слова перепутаны местами.

Почему это не ловится валидацией. Структурный валидатор проверяет только, что каждое слово из выравнивания реально существует в src. Идеально сдвинутое выравнивание проходит это с 0 offset_errors. Больше того — покрытие (coverage) остаётся ~100%: ведь каждое целевое слово действительно на что‑то маппится, просто не на то. Отсюда важнейший урок:

Структурная корректность и смысловая корректность выравнивания — это две независимые вещи. Ни offset‑ошибки, ни покрытие, ни любая механическая метрика не видят частичного позиционного дрейфа.

Корень проблемы. Изначально один промпт просил модель одновременно переводить И строить пословное выравнивание. Слабая модель под нагрузкой (большой батч + двойная когнитивная задача) сваливается в самое дешёвое решение: «русское слово № 1 ↔ английское слово № 1». Позиционно. Структурно валидно. Но по факту мусор.

Решение № 1: разделить перевод и выравнивание на два прохода

Я поставил контролируемый эксперимент на «золотом» наборе трудных предложений с перестановкой слов, на реальном дешёвом Haiku (temperature 0):

Формат промпта

маленький батч

батч 16 (боевой)

Совмещённый (перевод + выравнивание за один проход)

15/19

0/14 — и JSON порвался посреди вывода

Выравнивание по числовым индексам

9/19

худший, отклонён

Только выравнивание (перевод дан отдельно)

19/19

14/14

Вот в этом вся суть ловушки: совмещённый промпт даёт правдоподобные 15/19 на трёх предложениях — именно поэтому он проходил ручные спот‑чеки и «выглядел нормально». А на боевом батче из 16 предложений он падает до 0/14 с позиционным дрейфом и синтаксической ошибкой в JSON.

Разделение на два прохода (сначала перевести {id, src}, потом отдельно выровнять {id, src, tr}) само по себе поднимает батч-16 с 0/14 до 14/14!!!

И это не причуда Haiku. Тот же A/B на OpenRouter (батч 16, 14 золотых пар):

Модель

Совмещённый проход

Два прохода (align‑only)

Gemini 2.5 Flash

сломанный JSON посреди вывода

14/14

DeepSeek V3

4/14 (миссис→briefcase, руку→shaking)

14/14

Haiku

0/14

14/14

Совмещённый проход разваливается у всех дешёвых моделей: Gemini выдаёт битый JSON, DeepSeek и Haiku дрейфуют. А align‑only — 14/14 у всех троих. Разделение проходов — вот что вообще делает дешёвую модель пригодной, независимо от того, какая это дешёвая модель.

Цена разделения: примерно ×2 запросов, но <×2 токенов (перевод — короткий вывод, тяжёлый вывод — это выравнивание). И кэш разнесён по namespace, чтобы «переведённое, но не выровненное» предложение никогда не считалось готовым.

Решение № 2: «считать нельзя, потому что нечего считать»

Даже с пословными чанками ранняя версия просила модель возвращать числовые индексы исходных слов. И модель их теряла: после перестановки фразы все индексы уезжали на единицу. Просить LLM держать точный счёт 0-based индексов через реордеринг — заведомо хрупко.

Решение оказалось элегантным: пусть модель не считает, а копирует. Вместо индекса модель возвращает текст исходного слова, а конвертер уже сам локально сопоставляет текст обратно с индексами (match‑by‑text).

Counting can’t drift because there’s no counting.

Это доросло до контракта v5 «numbered echo»: во вход выравнивания слова подаются пронумерованным списком ("0:Los 1:hábitos 2:son …"), а модель на каждое целевое слово возвращает {"tgt": "<кусок перевода>", "en": ["3:interés", …]} — и индекс, и текст. Текст работает как контрольная сумма для индекса: если модель ошиблась в номере, конвертер это ловит и откатывается на match‑by‑text. Поле называется en независимо от реального языка оригинала. Пустой en ([]) означает вставленное целевое слово без исходного соответствия (артикль, частица) — на этом дальше отдельная история.

A/B нового контракта против старого (16 трудных ES→RU предложений, судья — модель посильнее):

Вариант

Неверных пар слов

Старый промпт, батч 16 (боевой)

13.8% … 29.3% (два прогона!)

Старый промпт, батч 4

18.4%

Новый промпт, батч 16

9.5%

Новый промпт, батч 4

5.4%

Старый промпт, более сильная модель (flash), батч 16

11.2%

Заголовок этой таблицы: новый промпт + маленькие align‑батчи на дешёвой модели (5.4%) обыгрывают более сильную модель на старом промпте (11.2%) — по цене «лайт». А ещё обратите внимание на строку с двумя прогонами: 13.8% и 29.3% на одинаковом входе. Вот почему на дешёвых моделях нельзя полагаться на один удачный прогон — нужен контракт‑с-контрольной‑суммой плюс судья.

Решение № 3: маленькие батчи именно для выравнивания

Перевод отлично идёт батчами по 16. А вот выравнивание — нет: внимание модели по многим длинным элементам выравнивания в большом батче как раз и есть то место, где дешёвые модели дрейфуют. Поэтому дефолт: align-batch = batch/4 (то есть 4, когда основной батч 16). В коде это буквально так и подписано — «small align batches curb positional drift».

Решение № 4: каскады off‑by‑one и вставленные слова

Механику дрейфа удалось локализовать точно: когда в русском появляется слово без испанского соответствия («Вы», «и», «это»), модель норовит примапить его к следующему исходному слову вместо того, чтобы пометить как вставленное — и дальше каждая пара сдвигается на единицу. Хвост предложения уезжает, последние исходные слова осыпаются в пустые чанки. Лечение: явное правило в промпте, что вставленное слово должно иметь en: [] (не потреблять ни одного исходного слова), плюс контрастный пример CORRECT/WRONG с перекрёстным порядком слов.

Решение № 5: LLM‑судья

Раз ни валидация, ни покрытие не ловят смысловые ошибки, единственный надёжный детектор — семантическая проверка другой моделью. Я пробовал ловить дрейф эвристикой «близость к диагонали» — она ловила 2 правильных предложения на каждый реальный дрейф. Не работает.

Как устроен судья (--judge): независимая модель (обязательно посильнее — Haiku слишком слаб, чтобы судить сам себя) читает {src, перевод, карта выравнивания} по каждому предложению и ставит две независимые оценки: alignment_ok (каждое ли слово примаплено к тому, что оно значит, а не позиционно) и translation_ok (верен ли сам перевод).

Что судья нашёл на 60 случайных предложениях уже доставленной книги (Harry Potter, весь Haiku):

  • выравнивание: ~23% с дефектами (14 из 60),

  • перевод: ~12% с дефектами (7 из 60).

Примеры фактических ляпов: mustache → усадьба (модель перепутала mustache с estate), перевёрнутый по смыслу «Beats me how…», обрезанные предложения.

Петля исправления: судья возвращает список сбойных src--invalidate вычищает кэш ровно этих предложений → перегенерация (можно более сильной моделью — про эскалацию ниже) → пересуд → повтор до чистоты. По деньгам это дешевле, чем кажется: тяжёлый выходной ток уже оплачен на этапе выравнивания, а судья читает примерно тот же материал, но эмитит 10–20 токенов. Полный проход «проверить всё» добавляет примерно +30–40% токенов, а не +100%.

Решение № 6: статический детектор дрейфа — словарь вместо токенов

Судья хорош, но он стоит токенов. А позиционный дрейф — ошибка с очень характерным почерком. Можно ли ловить её вообще бесплатно, офлайн? Оказалось, да — обычным двуязычным словарём (--lexcheck).

Словари берутся из OPUS OpenSubtitles v2018 — это данные пословного выравнивания на гигантском корпусе субтитров. Скрипт скачивает .dic‑файлы и ужимает их в компактный формат «слово → топ-12 переводов по частоте» (отбрасывая пары, встретившиеся меньше 3 раз). Все 15 пар языков de/en/es/fr/it/ru → 30 лексиконов (оба направления), суммарно ~25 МБ gzip. В сам .tbook словари, конечно, не попадают — это инструмент конвертера.

Дальше идея простая: для каждого чанка выравнивания спрашиваем словарь — правдоподобен ли этот фрагмент перевода как перевод того слова, на которое он претендует? Но тут есть ловушка, из‑за которой наивная версия не работает: одиночный промах словаря ничего не доказывает. Слова многозначны, а словарь из субтитров смещён по регистру — литературного синонима в нём часто просто нет. Поэтому предложение флагается только по совокупным уликам, двумя детекторами:

  • low‑support: словарь «видит» минимум 4 пары, и правдоподобны из них ≤34% — выравнивание в целом не похоже на правду;

  • shift‑pattern — самая красивая часть: ≥2 пар, где фрагмент перевода не подходит своему исходному слову, но подходит его соседу. И этот признак устойчив к многозначности: случайная лакуна словаря не будет систематически совпадать именно с соседним словом.

Морфология — без морфоанализатора, тремя дешёвыми приёмами: словари OPUS хранят поверхностные формы (большинство словоизменений там есть буквально), плюс консервативное префиксное сравнение («цель/целей», «совершенствование/совершенствования» — длинный общий префикс, короткий разъехавшийся хвост), плюс лёгкое срезание окончаний с исходной стороны (metasmeta). Важное свойство: ошибаться «в мягкую сторону» безопасно — более снисходительный матчинг означает меньше флагов дрейфа, не больше.

Пороги не угаданы, а выбраны по данным. Для этого есть отдельный инструмент lexeval, и у него симпатичный трюк: где взять размеченный датасет дрейфа? Сгенерировать. Берём готовую книгу; каждое предложение — предположительно хороший «негатив» (можно ограничиться теми, что одобрил LLM‑судья); а «позитив» синтезируем, сдвинув все маппинги выравнивания на одно исходное слово вправо — это ровно сигнатура позиционного дрейфа (плюс вариант со сдвигом только второй половины — каскад, начавшийся с середины предложения). Детектор обязан флагать сдвинутые копии и не флагать оригиналы. По этой сетке прогоняется перебор порогов.

Результаты на реальной книге: ~87% recall на полных каскадах, ~52% на каскадах с середины предложения, при ~2.4% ложных срабатываний (точность 97%); словарь покрывает 72% пар. Пороги сознательно сдвинуты в пользу точности, а не полноты: каждый флаг уходит на платную переделку, и ложные срабатывания — это буквально потраченные деньги.

Решение № 7: эскалация — и почему сильную модель тоже нужно проверять

Флаги обоих гейтов (lexcheck + судья) стекаются в --escalate-model: всё зафлаганное немедленно переделывается (перевод + выравнивание) более сильной моделью. Тонкость: результат пишется в кэш‑namespace основной дешёвой модели — книга остаётся одним файлом, где ~95% сделано дешёвой моделью и только трудные несколько процентов — сильной.

Но главный урок этой части: более сильная модель — не значит проверенная модель. Поэтому эскалация замкнута в петлю перепроверки: те же гейты прогоняются по эскалированному выводу; что всё ещё флагается — получает ровно одну повторную попытку; а стойкие отказники не подшиваются в книгу молча, а деградируются явно: признанный судьёй неверный перевод отправляется в книгу непереведённым (и пере‑переводится при следующем прогоне), а сбой выравнивания — сырым текстом без подсветок. Список таких предложений пишется в <out>.unverified.json. Принцип: ничто из отвергнутого гейтами не попадает в книгу тихо — без этой петли эскалированная галлюцинация ложится в книгу незамеченной.

И тут всплыл самый контринтуитивный результат всей калибровки. Казалось бы, судьёй надо ставить модель посильнее. Я померил (на вручную вычитанной странице плюс «дрейфовых двойниках», инструмент driftdemo): сильный судья (gemini-2.5-flash) ловит дрейф идеально — 14/14, — но из перфекционизма помечает 36–53% чистого текста за придирки к маппингу. А в двухглавной петле он забраковал 69% собственного эскалированного вывода — эскалация вырождается в массовый fallback: система бесконечно переделывает то, что уже хорошо. Дешёвый же судья — та же модель, что и переводила — ловит 12/14 дрейфов почти без ложных срабатываний. Поэтому дефолтный судья = та же дешёвая модель, а сильный судья остался только для разовых аудитов.

Формулировки промпта судьи оказались столь же хрупкими: промежуточная версия с мягким «флагай только дефекты, видимые читателю» уронила recall дешёвого судьи по дрейфу до нуля — снисходительная формулировка дала слабому судье повод одобрять всё подряд. Правило с тех пор: любое изменение промпта судьи калибруется на дрейфовых двойниках до выката.

Весь боевой конвейер теперь — одна команда:

tools/fetch-lexicons.sh es-ru   # один раз на языковую паруconvert book.epub -o book.tbook --lexcheck --judge --escalate-model google/gemini-2.5-flash

Дешёвая модель переводит и выравнивает всё; словарь (бесплатно) и дешёвый судья помечают подозрительное; сильная модель переделывает только помеченное; переделанное перепроверяется; неисправимое честно деградирует и ретраится в следующем прогоне.

Экономика: почему это всё дёшево

  • Кэш на диске. Каждый результат кэшируется по ключу promptVersion | model | sourceLang | targetLang | src (SHA-256). Повторный запуск делает только то, чего нет в кэше. Так смена контракта выравнивания только выравнивает книгу, не переводя её заново.

  • Глоссарий (--glossary). Один предварительный вызов вытаскивает из книги повторяющиеся ключевые термины и имена собственные с фиксированными переводами, и они дописываются в каждый промпт перевода — чтобы терминология не плясала от батча к батчу. Раз промпт меняется, у кэша появляется суффикс с хешем глоссария, и переводы с глоссарием не смешиваются с обычными.

  • Дефолтная модель конвертера — google/gemini-2.5-flash (быстрая, ~2 с на предложение, корректный JSON). А ещё у меня есть скилы для Claude, и в свободное время я перевожу книжки на базе своей подписки: медленно, зато без затраты токенов, за ночь справляется.

Итог по деньгам: сконвертировать книгу вроде Revelation Space стоит чуть менее $1 на DeepSeek и может попасть в бесплатный лимит Gemma на openrouter (на момент написания статьи), но — 4–7USD за Хайку по токенам.

Часть 3. Факапы

История разработки — это в основном история того, как дешёвые модели (и агенты) находили новые способы всё сломать.

1. Haiku — надёжный переводчик, но ненадёжный эмиттер JSON. На большом прогоне (книга Азимова, 9508 предложений, 584 батча) 150 из 584 результатов (~26%) не распарсились нормально.

2. Агенты жульничали с валидатором. Первый валидатор проверял только, что tgt воспроизводит перевод. Агенты «проходили» его, оставляя en пустым (то есть нулевое выравнивание): один батч вернулся со 100% пустых en, другой — с 85%. Аудит нашёл 6.9% предложений (439 штук) с более чем 60% пустых en, кучкующихся целыми «жульническими» группами агентов. Пришлось делать валидатор строже и орать в промпте капсом, что так нельзя.

3. Мультисловный en как одна строка → тихая потеря подсветки. Модели эмитили "living room", "the large" как одну строку, а резолвер считал строку одним токеном и не находил соответствия — подсветки нет. Таких чанков в одной книге было ~4342. В итоге строку с пробелами бьём на токены. Самый сложный момент, это в текущем TODO, так как с точки зрения смысла это неверно, пока это выглядит хотфиксом но не решением.

4. У судьи был свой баг. Первый полный проход судьи оставил 2122 предложения «непросуженными», потому что слал 64-символьные hex‑ключи кэша в качестве id элементов, а дешёвая модель их теряла. Лечение — короткие id "1","2",… в пределах батча: их дешёвая модель эхо‑воспроизводит надёжно. (Та же тема «большой сложный промпт → хуже для дешёвой модели» заставила и судью работать батчами batch/2.)


Что есть сейчас

  1. Спека: https://tbook.vercel.app/

  2. Go‑конвертер: https://github.com/adubovskoy/tbook_converter

  3. Android‑приложение читалки

  4. Десктоп‑читалка (написана на rust, так как я на linux, она протестирована только под linux)

Всё будет OpenSource под MIT — и спека, и конвертеры. Приложение будет бесплатным и без рекламы. Мне хочется, чтобы начала формироваться библиотека книг: проще обмениваться, чем конвертировать каждый раз заново.

Нужны тестировщики

Сейчас для прохождения валидации в Google Play мне нужны тестировщики приложения. Если хотите помочь — пишите мне сюда или на alex@techlovers.team, пришлю ссылку на тестирование в Google Play. Могу бесплатно для тестеров сконвертировать любую вашу книгу — присылайте, отправлю и ссылку, и книгу.

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