# pdf2epub Конвертация PDF, собранного из электронной книги (Producer: calibre), в EPUB/AZW3 для Kindle **с сохранением форматирования**: курсив, полужирный, заголовки глав, врезки, иллюстрации, склейка абзацев через границы страниц. ## Зачем не `ebook-convert` напрямую Прямая конвертация `ebook-convert book.pdf book.epub` теряет курсив: poppler определяет начертание по имени шрифта и не опознаёт сокращённые имена вроде `MinionPro-It` (без подстроки `Italic`). На «The Unicorn Project» получилось **435 курсивных фрагментов в PDF против 1 тега `` в EPUB**. Плюс calibre размечает основной текст как `

` (2817 штук), потому что определяет заголовки по кеглю относительно самого частого. `scripts/pdf2html.py` извлекает текст через PyMuPDF, где начертание берётся из свойств span'а, и сам раскладывает блоки по семантике. ## Что делает скрипт - курсив/полужирный — по имени шрифта span'а (`-It`, `Italic`, `Bold`, `Semibold`); - заголовки — по кеглю: `>= 28` или декоративный шрифт — `h1.title`, `>= 24` — `h1` (главы и части), `>= 17` — `h2` (подзаголовок с датой); - врезки (дневник, чаты, слайды) — отдельный шрифт семейства → `p.note`; - абзац = блок PDF; блок без красной строки в начале страницы приклеивается к абзацу с предыдущей страницы; - переносы на конце строки снимаются, соседние `` схлопываются; - иллюстрации выгружаются в `images/`, обложка рендерится со страницы 1 в 150 dpi; - повёрнутый на 90° текст выбрасывается целиком: боковые врезки и подписи к таблицам распознаются в кашу («aoHegoduenueArdng») и лезут в заголовки, потому что кегль у них крупный. У Брикмана это 105 строк из 17 300, и все до одной — брак; - мусорный заголовок понижается до `

`, а не удаляется: оглавление чистится, текст остаётся. Проверка — `looks_garbled()` в `scripts/bookhtml.py`, шесть признаков структуры, любых двух хватает. Вместе с фильтром поворота это увело Брикмана с 99 `

` до 48; - позиционирование не сохраняется намеренно — текст должен течь под любой размер шрифта на читалке. ## DjVu со своим текстовым слоем: `scripts/djvu2html.py` У сканов в DjVu слой распознавания обычно уже есть, и он лучше нашего прогона через `ocrmypdf`. Замер на Прате (C++ 6-е рус. изд., 1244 полосы): слов со смесью алфавитов внутри слова было 433 на 391 тысячу слов (10,9 на 10 000), после сборки из родного слоя — 109 (2,8). ⚠️ **Через PDF этот слой не проходит.** `ddjvu -format=pdf` кладёт страницы картинками, `pdftotext` после этого отдаёт пустоту. Текст берётся напрямую из `djvutxt --detail=line`, скрипт разбирает его сам. Чего в слое DjVu нет вовсе — курсива и полужирного: в скане их и не было. Заголовки опознаются высотой строки, листинги — пунктуацией. ⚠️ **Высота строки в DjVu — это габарит с выносными элементами, а не кегль.** Строка с «Ц» или «р» выше соседней на те же 10%, поэтому пороги взяты по измеренным разрывам, а не «чуть выше основного текста». У Праты при основной строке 78: колонтитулы 85–90, разделы 105–125, названия глав 170–185. Строки заголовка склеиваются подряд: название главы занимает две-три строки, и без склейки «Класс string и стандартная библиотека шаблонов» разваливается на четыре пункта оглавления. Самопроверки без сети: `python3 scripts/djvu2html.py --selftest` и `python3 scripts/pdf2html.py --selftest --selftest` (флаг занимает оба обязательных аргумента). ## Подключение как скил Claude Code Репозиторий одновременно является скилом (`SKILL.md` в корне) и клонируется **прямо в каталог скила** — никаких симлинков и зависимости от `~/Projects`, структура каталогов на разных ПК может не совпадать: ```bash git clone https://git.katze-lab.ru/chesirecatt/pdf2epub.git ~/.claude/skills/pdf-to-kindle ``` Скил `pdf-to-kindle` появится в списке доступных при следующем запуске сессии. Ручной клон нужен только если нет синхронизации памяти. **Штатный путь — автоматический:** репозиторий перечислен в массиве `SKILL_REPOS` в `sync.sh` репозитория [`claude-memory`](https://git.katze-lab.ru/chesirecatt/claude-memory), который крутится по таймеру `claude-memory-sync.timer` раз в 10 минут. На новом ПК скил клонируется сам при первом же прогоне синхронизации памяти, дальше обновляется. Ручных шагов и правки systemd-юнита не требуется. Синхронизация односторонняя (`pull`): правки вносятся и пушатся руками из `~/.claude/skills/pdf-to-kindle`. Чтобы добавить в автосинхронизацию ещё один скил — строка в `SKILL_REPOS` формата `" <имя каталога скила>"`. ## Требования PyMuPDF и calibre: ```bash python3 -m venv ~/.venvs/pdf2epub ~/.venvs/pdf2epub/bin/pip install pymupdf sudo dnf install calibre # если ebook-convert ещё нет ``` ## Использование Разведка — какие в PDF шрифты и кегли (пороги в скрипте подобраны под вёрстку 15pt/letter, для другой книги их правят по этому выводу): ```bash ~/.venvs/pdf2epub/bin/python scripts/pdf2html.py --fonts book.pdf ``` Конвертация: ```bash ~/.venvs/pdf2epub/bin/python scripts/pdf2html.py book.pdf out/book.html ebook-convert out/book.html "Название.epub" \ --title="Название" \ --authors="Автор" \ --language=en \ --cover=out/images/cover.jpg \ --level1-toc='//h:h1' --level2-toc='//h:h2' \ --page-breaks-before='//h:h1' \ --no-default-epub-cover ebook-convert "Название.epub" "Название.azw3" ``` `--level1-toc`/`--level2-toc` обязательны: без них calibre строит оглавление своей эвристикой и ломает разбивку по главам. ## Куда класть на Kindle - `.epub` — через Send to Kindle (веб или почта), Amazon конвертирует на своей стороне; - `.azw3` — по USB прямо в `documents/` на устройстве. ## Перевод (опционально) Перевод делает сторонний проект [`vetermanve/book_translator`](https://github.com/vetermanve/book_translator) (DeepSeek или локальная Ollama). **Репозиторий не модифицируется** — он вызывается через свой штатный CLI и форматы файлов. `scripts/translate.py` — мост: раскладывает наш XHTML в его входной JSON, запускает `03_translate_parallel.py --all`, собирает результат обратно. Подготовка (один раз): ```bash git clone https://github.com/vetermanve/book_translator.git ~/Projects/book_translator # их requirements.txt неполон: openai и pyyaml импортируются, но не перечислены ~/.venvs/pdf2epub/bin/pip install openai pyyaml python-dotenv umask 077 mkdir -p out/tr cat > out/tr/.env <<'EOF' USE_LOCAL_MODEL=false DEEPSEEK_API_KEY=<ключ> EOF ``` Без `pyyaml` каждый запрос падает внутри `_create_system_prompt` **до** обращения к API, а сторонний скрипт молча подставляет `[UNTRANSLATED]` и рапортует «API запросов: 0, ошибок: N». Ключ держать только в `.env` с правами `600`, в командную строку и логи не выводить. Сначала проверка, нужен ли перевод вообще (язык, объём; ничего не тратит): ```bash ~/.venvs/pdf2epub/bin/python scripts/translate.py out/book.html out/book.ru.html \ --repo ~/Projects/book_translator --workdir out/tr --check-only ``` Если книга уже на целевом языке, скрипт откажется переводить (`--force` переопределяет). Сам перевод: ```bash ~/.venvs/pdf2epub/bin/python scripts/translate.py out/book.html out/book.ru.html \ --repo ~/Projects/book_translator --workdir out/tr --workers 12 ``` Прерванный перевод возобновляется: сторонний скрипт помнит готовые главы. Это же свойство мешает при **неудачном** прогоне — упавшие главы он считает готовыми, поэтому перед повторной попыткой: ```bash rm -rf out/tr/progress out/tr/context out/tr/translations ``` После сборки скрипт сам проверяет язык результата и завершается с ошибкой, если текст остался на языке оригинала или содержит `[UNTRANSLATED]` — совпадения числа абзацев мало, при сбое API сторонний скрипт подставляет оригинал. Файл, не прошедший проверку, упаковывать нельзя. Дальше `out/book.ru.html` упаковывается тем же `ebook-convert` с `--language=ru`. Курсив и полужирный переживают внешний переводчик в виде маркеров `⟦i⟧…⟦/i⟧`; на обратном пути баланс маркеров проверяется по каждому абзацу. Картинки, заголовки и порядок блоков вообще не покидают наш скрипт — наружу уходит только текст, сборка идёт по индексам. В отчёте после перевода: «без перевода» — главы, вернувшиеся с другим числом абзацев (оставлен оригинал), «разметка потеряна» — абзацы, где модель испортила маркеры и курсив снят. Оба числа должны быть близки к нулю. Самопроверка моста без сети и ключей: ```bash cd scripts && python3 test_translate.py ``` ## Озвучка (опционально) Синтез тоже делает `book_translator` (`05_create_audiobook.py`, движок edge-tts от Microsoft — бесплатный, нужен интернет). В `scripts/audiobook.py` две обвязки; чужой репозиторий по-прежнему не трогаем. ```bash pip install edge-tts pydub # ffmpeg нужен отдельно, из пакетов системы # 1. снять маркеры разметки — иначе TTS проговорит ⟦i⟧ python3 scripts/audiobook.py prep out/tr/translations out/tr/translations_tts # 2. синтез (в фоне; на 12 часов звучания уходит 1.5–3 часа) cd out/tr && python3 ~/Projects/book_translator/05_create_audiobook.py \ --translations-dir translations_tts --voice dmitry --rate '+0%' # 3. проверка ДО того, как скрипт уберёт temp_audio/ python3 scripts/audiobook.py verify out/tr/translations_tts out/tr/audiobook # 4. нарезка по главам — тоже до уборки temp_audio/ python3 scripts/audiobook.py split out/tr/translations_tts out/tr/audiobook \ out/tr/tracks --album "Название книги" --author "Автор" --gap 0.3 ``` `verify` находит главы с недостающими фрагментами, битые и нулевые mp3 и главы, которые короче расчётной длительности. Норму «секунд на знак» он берёт как медиану по всем главам, поэтому не зависит от выбранного голоса и скорости. Код возврата ненулевой, если что-то не так. Повторный запуск синтеза добирает пропущенное: сторонний скрипт пропускает фрагмент, если файл существует **и непустой**. Битые файлы, названные `verify`, надо удалить руками — их он проверять не станет. Ограничения чужой стадии: на выходе один слитный `audiobook_complete.mp3` без разбивки по главам и без меток — для плеера неудобно, разбивка здесь не реализована. Для технической книги стоит сначала прогнать фонетику (`07_extract_terms.py` + `08_generate_phonetics.py`), иначе русский голос исковеркает все английские термины. ## Проверка результата ```bash python3 - <<'EOF' import re t = open('out/book.html').read() print('курсив:', t.count(''), 'полужирный:', t.count('')) print('заголовки:', len(re.findall(r']+>', '', t).count(' ')) EOF ``` Двойных пробелов должно быть единицы, курсива — сотни. Ноль курсива означает, что имена шрифтов в PDF не попали под правило в `style()`. ## Ограничения - рассчитан на PDF с текстовым слоем; сканы требуют OCR и сюда не годятся; - пороги кеглей и семейство шрифта для врезок — константы под конкретную вёрстку, правятся руками по выводу `--fonts`; - колонтитулы и номера страниц не вырезаются: в PDF от calibre их нет. Для типографского PDF понадобится отбрасывать блоки по координате `y`. ## Проверено на `The Unicorn Project` (Gene Kim), 399 страниц: 40 заголовков, оглавление на 62 пункта, 430 курсивных фрагментов, 178 врезок, 5 иллюстраций, ~733 тыс. знаков.