Автоматический подбор порогов вместо ручного + упаковка EPUB без calibre

Ручная настройка block_kind() под каждую книгу оказалась неприменима: у части
изданий имена шрифтов вычищены до «Fd820825», а флаги стилей у всех спанов
равны 4, то есть ни курсива, ни моноширинности по ним не видно. Пороги теперь
считаются из самого файла:

- кегль основного текста — по гистограмме символов;
- кегль глав — по частоте блочных кеглей, а не множителем от текста: иначе
  подзаголовки разделов попадают в главы и оглавление пухнет до трёхсот пунктов;
- глава отличается от раздела положением в верхней трети полосы, и правило
  применяется, только если глав так набирается хотя бы пять;
- красная строка — по распределению x0.

Листинг опознаётся флагом моноширинности, а при его отсутствии — кеглем мельче
основного текста плюс плотностью кодовой пунктуации.

Отсев картинок: мельче 24 pt (линейки и буллеты) и крупнее 60% площади полосы
(подложка отсканированной страницы). На «Программисте-прагматике» второе
правило убрало 370 сканов полос и уменьшило EPUB со 124 МБ до 17 МБ.

pack_epub.py собирает EPUB на стандартной библиотеке: calibre для упаковки не
нужен, а его установка требует root. Книга режется по h1, оглавление NCX
строится само. Тест test_pack_epub.py проверяет несжатый mimetype первым файлом,
экранирование разметки в метаданных, сохранение переносов в листинге и то, что
преамбула до первой главы не теряется.
This commit is contained in:
chesirecatt
2026-08-20 20:11:03 +03:00
parent 1bacd38292
commit a21a4d713b
3 changed files with 329 additions and 25 deletions
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Самопроверка упаковщика без сети: python3 test_pack_epub.py"""
import re
import tempfile
import zipfile
from pathlib import Path
import pack_epub
BOOK = """<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>T</title>
<style>p { margin: 0; }</style></head><body>
<p>Предисловие до первой главы.</p>
<h1>Глава 1. Начало</h1>
<p>Текст первой главы.</p>
<pre>def f():
return 1</pre>
<p class="figure"><img src="images/fig.png"/></p>
<h1>Глава 2. Продолжение</h1>
<p>Текст второй главы.</p>
</body></html>"""
def test_pack(tmp: Path):
src = tmp / "book.html"
src.write_text(BOOK, encoding="utf-8")
(tmp / "images").mkdir()
(tmp / "images" / "fig.png").write_bytes(b"\x89PNG\r\n\x1a\n")
(tmp / "images" / "cover.jpg").write_bytes(b"\xff\xd8\xff")
out = tmp / "book.epub"
chapters, imgs = pack_epub.build(src, out, {
"title": "Книга & книга", "author": "Автор", "lang": "ru",
"cover": "cover.jpg", "id": "urn:uuid:test"})
# преамбула до первой главы не теряется, поэтому глав три, а не две
assert (chapters, imgs) == (3, 1), (chapters, imgs)
z = zipfile.ZipFile(out)
names = z.namelist()
# без этого часть читалок не опознаёт файл как EPUB
assert names[0] == "mimetype"
assert z.getinfo("mimetype").compress_type == zipfile.ZIP_STORED
assert "OEBPS/images/fig.png" in names and "OEBPS/images/cover.jpg" in names
opf = z.read("OEBPS/content.opf").decode("utf-8")
assert "<dc:title>Книга &amp; книга</dc:title>" in opf, "разметку надо экранировать"
assert opf.count("<itemref") == 3
assert 'properties="cover-image"' in opf
ncx = z.read("OEBPS/toc.ncx").decode("utf-8")
assert re.findall(r"<text>([^<]*)</text>", ncx)[1:] == [
"Начало", "Глава 1. Начало", "Глава 2. Продолжение"]
ch1 = z.read("OEBPS/ch001.xhtml").decode("utf-8")
assert "<pre>def f():\n return 1</pre>" in ch1, "переносы в листинге значимы"
assert 'href="style.css"' in ch1
assert "Текст второй главы" not in ch1, "главы не должны склеиваться"
def test_no_content(tmp: Path):
src = tmp / "empty.html"
src.write_text("<html><body>\n</body></html>", encoding="utf-8")
try:
pack_epub.build(src, tmp / "x.epub", {"title": "t", "author": "", "lang": "ru",
"cover": "", "id": "i"})
except SystemExit:
return
raise AssertionError("пустой источник должен останавливать упаковку")
if __name__ == "__main__":
for case in (test_pack, test_no_content):
with tempfile.TemporaryDirectory() as d:
case(Path(d))
print("OK")