237 lines
13 KiB
Markdown
237 lines
13 KiB
Markdown
# 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 тега `<i>` в EPUB**. Плюс calibre
|
||
размечает основной текст как `<h2>` (2817 штук), потому что определяет заголовки
|
||
по кеглю относительно самого частого.
|
||
|
||
`scripts/pdf2html.py` извлекает текст через PyMuPDF, где начертание берётся из
|
||
свойств span'а, и сам раскладывает блоки по семантике.
|
||
|
||
## Что делает скрипт
|
||
|
||
- курсив/полужирный — по имени шрифта span'а (`-It`, `Italic`, `Bold`, `Semibold`);
|
||
- заголовки — по кеглю: `>= 28` или декоративный шрифт — `h1.title`,
|
||
`>= 24` — `h1` (главы и части), `>= 17` — `h2` (подзаголовок с датой);
|
||
- врезки (дневник, чаты, слайды) — отдельный шрифт семейства → `p.note`;
|
||
- абзац = блок PDF; блок без красной строки в начале страницы приклеивается
|
||
к абзацу с предыдущей страницы;
|
||
- переносы на конце строки снимаются, соседние `</i><i>` схлопываются;
|
||
- иллюстрации выгружаются в `images/`, обложка рендерится со страницы 1 в 150 dpi;
|
||
- позиционирование не сохраняется намеренно — текст должен течь под любой
|
||
размер шрифта на читалке.
|
||
|
||
## Подключение как скил 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`
|
||
формата `"<git-url> <имя каталога скила>"`.
|
||
|
||
## Требования
|
||
|
||
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
|
||
```
|
||
|
||
`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('<i>'), 'полужирный:', t.count('<b>'))
|
||
print('заголовки:', len(re.findall(r'<h1', t)))
|
||
print('двойные пробелы:', re.sub('<[^>]+>', '', t).count(' '))
|
||
EOF
|
||
```
|
||
|
||
Двойных пробелов должно быть единицы, курсива — сотни. Ноль курсива означает,
|
||
что имена шрифтов в PDF не попали под правило в `style()`.
|
||
|
||
## Ограничения
|
||
|
||
- рассчитан на PDF с текстовым слоем; сканы требуют OCR и сюда не годятся;
|
||
- пороги кеглей и семейство шрифта для врезок — константы под конкретную вёрстку,
|
||
правятся руками по выводу `--fonts`;
|
||
- колонтитулы и номера страниц не вырезаются: в PDF от calibre их нет.
|
||
Для типографского PDF понадобится отбрасывать блоки по координате `y`.
|
||
|
||
## Проверено на
|
||
|
||
`The Unicorn Project` (Gene Kim), 399 страниц: 40 заголовков, оглавление на
|
||
62 пункта, 430 курсивных фрагментов, 178 врезок, 5 иллюстраций, ~733 тыс. знаков.
|