33845bd47a
Извлечение (pdf2html.py), каждая правка вызвана конкретной книгой: * колонтитул опознаётся по повтору текста в верхнем поле на многих полосах, а не по одной позиции. Позиционный признак рубит и настоящие заголовки: у Вернона («DDD Distilled») глава начинается ровно с верха полосы и короче 80 знаков, так пропали 14 заголовков из 15. Это ровно тот второй проход, о котором говорил ponytail-комментарий на прежнем фильтре; * блок режется, если шапка набрана крупнее следующего за ней текста. У Вернона 85 подзаголовков лежали в одном блоке с первым абзацем раздела: оглавление теряло второй уровень, а абзац начинался с заголовка без точки; * порог раздела снижен с 1.15 до 1.10 кегля текста. У Вернона подзаголовок 17.2 при тексте 15.0 — ровно на пять сотых ниже прежнего порога, и все 85 разделов уезжали в прозу. Тот же множитель, что у разреза блока, иначе разрез и классификация расходятся; * кандидатом в главы не может быть кегль, у которого в блоках нет букв. У Нейгарда («Release it!») номер главы набран кеглем 100 отдельным блоком («1», «2», …), и главой объявлялся он, а настоящие заголовки в 30 пунктов уезжали в разделы — оглавление выходило пустым. Отбор по наличию букв, а не по длине: «Preface» — семь знаков, порог по длине отсекал бы и его. Упаковка (pack_epub.py): XML 1.0 запрещает управляющие символы, а из PDF они приезжают регулярно. У Бейера («Site Reliability Engineering») 33 главы из 45 не разбирались как XML из-за одного такого знака в начале абзаца, и читалка спотыкается на таком файле молча. Чистим на упаковке, а не надеемся на извлечение. Тест на это добавлен и проверен: с обезвреженной правкой он падает. SKILL.md приведён в соответствие с репозиторием. Шаг упаковки с 25.08 делает pack_epub.py, а текст всё ещё требовал calibre на PATH — из-за этого проверка инструментов давала ложный отказ. Замерено 05.09.2026: ebook-convert отсутствует на всех трёх здешних машинах, и все четыре книги собрались без него. За calibre остался только .azw3 для Kindle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCiPWRi3Jqy4saD2LwU1Dp
118 lines
5.1 KiB
Python
118 lines
5.1 KiB
Python
#!/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>Книга & книга</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_cover_used_in_text_is_not_duplicated(tmp: Path):
|
||
"""Обложка, встречающаяся и в тексте, не должна попадать в архив дважды."""
|
||
src = tmp / "book.html"
|
||
src.write_text("<h1>Глава</h1>\n"
|
||
'<p class="figure"><img src="images/cover.jpg"/></p>\n'
|
||
"<p>Текст главы.</p>", encoding="utf-8")
|
||
(tmp / "images").mkdir()
|
||
(tmp / "images" / "cover.jpg").write_bytes(b"\xff\xd8\xff")
|
||
out = tmp / "book.epub"
|
||
pack_epub.build(src, out, {"title": "t", "author": "a", "lang": "ru",
|
||
"cover": "cover.jpg", "id": "i"})
|
||
names = zipfile.ZipFile(out).namelist()
|
||
assert names.count("OEBPS/images/cover.jpg") == 1, names
|
||
opf = zipfile.ZipFile(out).read("OEBPS/content.opf").decode()
|
||
assert opf.count('href="images/cover.jpg"') == 1, opf
|
||
|
||
|
||
def test_control_chars_stripped(tmp: Path):
|
||
"""Управляющий знак из PDF делает главу неразбираемой как XML.
|
||
|
||
У Бейера («Site Reliability Engineering») так падали 33 главы из 45:
|
||
один \x02 в начале абзаца, и читалка молча спотыкается на файле.
|
||
"""
|
||
import xml.etree.ElementTree as ET
|
||
|
||
src = tmp / "book.html"
|
||
src.write_text(BOOK.replace("<p>Текст первой главы.</p>",
|
||
"<p>\x02Текст первой главы.</p>"),
|
||
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"
|
||
pack_epub.build(src, out, {"title": "T", "author": "A", "lang": "ru",
|
||
"cover": "cover.jpg", "id": "urn:uuid:test"})
|
||
z = zipfile.ZipFile(out)
|
||
for name in z.namelist():
|
||
if name.endswith((".xhtml", ".opf", ".ncx")):
|
||
ET.fromstring(z.read(name)) # падает, если знак остался
|
||
|
||
|
||
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_cover_used_in_text_is_not_duplicated,
|
||
test_no_content):
|
||
with tempfile.TemporaryDirectory() as d:
|
||
case(Path(d))
|
||
print("OK")
|