На «Запускаем Ansible» 3-го изд. 10 из 593 картинок — блоки с b["ext"]=="png" и colorspace=4, но реальные байты — CMYK JPEG (FFD8, подтверждено file). Запись b["image"] как есть под именем .png давала файл, который pngquant и часть читалок отказывались декодировать. _image_png() декодирует сырые байты через pymupdf.Pixmap (MuPDF определяет формат по содержимому, не по чужому ext), при colorspace не Gray/RGB конвертирует в RGB, и всегда отдаёт настоящий PNG. На отказе декодирования — откат к исходным байтам, не падать. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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; - повёрнутый на 90° текст выбрасывается целиком: боковые врезки и подписи к таблицам распознаются в кашу («aoHegoduenueArdng») и лезут в заголовки, потому что кегль у них крупный. У Брикмана это 105 строк из 17 300, и все до одной — брак;
- мусорный заголовок понижается до
<p>, а не удаляется: оглавление чистится, текст остаётся. Проверка —looks_garbled()вscripts/bookhtml.py, шесть признаков структуры, любых двух хватает. Вместе с фильтром поворота это увело Брикмана с 99<h1>до 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,
структура каталогов на разных ПК может не совпадать:
git clone https://git.katze-lab.ru/chesirecatt/pdf2epub.git ~/.claude/skills/pdf-to-kindle
Скил pdf-to-kindle появится в списке доступных при следующем запуске сессии.
Ручной клон нужен только если нет синхронизации памяти. Штатный путь —
автоматический: репозиторий перечислен в массиве SKILL_REPOS в sync.sh
репозитория claude-memory,
который крутится по таймеру claude-memory-sync.timer раз в 10 минут. На новом
ПК скил клонируется сам при первом же прогоне синхронизации памяти, дальше
обновляется. Ручных шагов и правки systemd-юнита не требуется.
Синхронизация односторонняя (pull): правки вносятся и пушатся руками из
~/.claude/skills/pdf-to-kindle.
Чтобы добавить в автосинхронизацию ещё один скил — строка в SKILL_REPOS
формата "<git-url> <имя каталога скила>".
Требования
PyMuPDF и calibre:
python3 -m venv ~/.venvs/pdf2epub
~/.venvs/pdf2epub/bin/pip install pymupdf
sudo dnf install calibre # если ebook-convert ещё нет
Использование
Разведка — какие в PDF шрифты и кегли (пороги в скрипте подобраны под вёрстку 15pt/letter, для другой книги их правят по этому выводу):
~/.venvs/pdf2epub/bin/python scripts/pdf2html.py --fonts book.pdf
Конвертация:
~/.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
(DeepSeek или локальная Ollama). Репозиторий не модифицируется — он
вызывается через свой штатный CLI и форматы файлов. scripts/translate.py —
мост: раскладывает наш XHTML в его входной JSON, запускает
03_translate_parallel.py --all, собирает результат обратно.
Подготовка (один раз):
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,
в командную строку и логи не выводить.
Сначала проверка, нужен ли перевод вообще (язык, объём; ничего не тратит):
~/.venvs/pdf2epub/bin/python scripts/translate.py out/book.html out/book.ru.html \
--repo ~/Projects/book_translator --workdir out/tr --check-only
Если книга уже на целевом языке, скрипт откажется переводить (--force
переопределяет). Сам перевод:
~/.venvs/pdf2epub/bin/python scripts/translate.py out/book.html out/book.ru.html \
--repo ~/Projects/book_translator --workdir out/tr --workers 12
Прерванный перевод возобновляется: сторонний скрипт помнит готовые главы. Это же свойство мешает при неудачном прогоне — упавшие главы он считает готовыми, поэтому перед повторной попыткой:
rm -rf out/tr/progress out/tr/context out/tr/translations
После сборки скрипт сам проверяет язык результата и завершается с ошибкой,
если текст остался на языке оригинала или содержит [UNTRANSLATED] —
совпадения числа абзацев мало, при сбое API сторонний скрипт подставляет
оригинал. Файл, не прошедший проверку, упаковывать нельзя.
Дальше out/book.ru.html упаковывается тем же ebook-convert с
--language=ru.
Курсив и полужирный переживают внешний переводчик в виде маркеров
⟦i⟧…⟦/i⟧; на обратном пути баланс маркеров проверяется по каждому абзацу.
Картинки, заголовки и порядок блоков вообще не покидают наш скрипт — наружу
уходит только текст, сборка идёт по индексам. В отчёте после перевода:
«без перевода» — главы, вернувшиеся с другим числом абзацев (оставлен
оригинал), «разметка потеряна» — абзацы, где модель испортила маркеры и
курсив снят. Оба числа должны быть близки к нулю.
Самопроверка моста без сети и ключей:
cd scripts && python3 test_translate.py
Озвучка (опционально)
Синтез тоже делает book_translator (05_create_audiobook.py, движок
edge-tts от Microsoft — бесплатный, нужен интернет). В scripts/audiobook.py
две обвязки; чужой репозиторий по-прежнему не трогаем.
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), иначе русский голос
исковеркает все английские термины.
Проверка результата
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 тыс. знаков.