Автоматический подбор порогов вместо ручного + упаковка 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:
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""XHTML от pdf2html.py/epub2html.py -> EPUB без сторонних зависимостей.
|
||||
|
||||
calibre для упаковки не нужен: EPUB — это zip с манифестом, а установка calibre
|
||||
требует root, которого на чужой машине может не быть. Формат намеренно
|
||||
консервативный (EPUB 2 + NCX): его одинаково понимают KOReader, штатные читалки
|
||||
PocketBook и calibre, если он всё-таки понадобится.
|
||||
|
||||
Книга режется по <h1>: одна глава — один файл. Так читалка не разбирает
|
||||
мегабайтный документ на каждом перелистывании и получает оглавление даром.
|
||||
"""
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
H1 = re.compile(r"^<h1[^>]*>(.*?)</h1>$")
|
||||
IMG = re.compile(r'<img src="images/([^"]+)"')
|
||||
NS = 'xmlns="http://www.w3.org/1999/xhtml"'
|
||||
|
||||
CHAPTER = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html %s><head><meta charset="utf-8"/><title>%s</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css"/></head>
|
||||
<body>
|
||||
%s
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def split_chapters(lines):
|
||||
"""[(заголовок, [строки])] — режем по <h1>, преамбулу оставляем главой."""
|
||||
chapters, title, buf = [], "Начало", []
|
||||
for line in lines:
|
||||
m = H1.match(line.strip())
|
||||
if m and buf:
|
||||
chapters.append((title, buf))
|
||||
title, buf = re.sub("<[^>]+>", "", m.group(1)).strip() or "Глава", [line]
|
||||
continue
|
||||
if m:
|
||||
title = re.sub("<[^>]+>", "", m.group(1)).strip() or "Глава"
|
||||
buf.append(line)
|
||||
if buf:
|
||||
chapters.append((title, buf))
|
||||
return [(t, b) for t, b in chapters if any(x.strip() for x in b)]
|
||||
|
||||
|
||||
def build(src, out, meta):
|
||||
text = src.read_text(encoding="utf-8")
|
||||
css = re.search(r"<style>(.*?)</style>", text, re.S)
|
||||
css = css.group(1) if css else ""
|
||||
body = re.search(r"<body>(.*)</body>", text, re.S)
|
||||
lines = (body.group(1) if body else text).splitlines()
|
||||
chapters = split_chapters(lines)
|
||||
if not chapters:
|
||||
sys.exit("в %s нет содержимого" % src)
|
||||
|
||||
imgdir = src.parent / "images"
|
||||
used, files = [], []
|
||||
with zipfile.ZipFile(out, "w") as z:
|
||||
# mimetype обязан идти первым и без сжатия — иначе часть читалок
|
||||
# не опознаёт архив как EPUB
|
||||
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip",
|
||||
compress_type=zipfile.ZIP_STORED)
|
||||
z.writestr("META-INF/container.xml",
|
||||
'<?xml version="1.0"?>\n<container version="1.0" '
|
||||
'xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
|
||||
'<rootfiles><rootfile full-path="OEBPS/content.opf" '
|
||||
'media-type="application/oebps-package+xml"/></rootfiles></container>')
|
||||
z.writestr("OEBPS/style.css", css)
|
||||
for n, (title, buf) in enumerate(chapters):
|
||||
name = "ch%03d.xhtml" % n
|
||||
files.append((name, title))
|
||||
chunk = "\n".join(buf)
|
||||
for img in IMG.findall(chunk):
|
||||
if img not in used and (imgdir / img).exists():
|
||||
used.append(img)
|
||||
z.writestr("OEBPS/" + name,
|
||||
CHAPTER % (NS, html.escape(title), chunk))
|
||||
for img in used:
|
||||
z.write(imgdir / img, "OEBPS/images/" + img)
|
||||
if meta["cover"] and (imgdir / meta["cover"]).exists():
|
||||
z.write(imgdir / meta["cover"], "OEBPS/images/" + meta["cover"])
|
||||
|
||||
items = ['<item id="css" href="style.css" media-type="text/css"/>',
|
||||
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>']
|
||||
spine = []
|
||||
for i, (name, _) in enumerate(files):
|
||||
items.append('<item id="c%d" href="%s" media-type="application/xhtml+xml"/>'
|
||||
% (i, name))
|
||||
spine.append('<itemref idref="c%d"/>' % i)
|
||||
mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".svg": "image/svg+xml"}
|
||||
for i, img in enumerate(used + ([meta["cover"]] if meta["cover"] else [])):
|
||||
items.append('<item id="i%d" href="images/%s" media-type="%s"%s/>'
|
||||
% (i, img, mime.get(Path(img).suffix.lower(), "image/jpeg"),
|
||||
' properties="cover-image"' if img == meta["cover"] else ""))
|
||||
z.writestr("OEBPS/content.opf", """<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="bid">%s</dc:identifier>
|
||||
<dc:title>%s</dc:title>
|
||||
<dc:creator>%s</dc:creator>
|
||||
<dc:language>%s</dc:language>
|
||||
</metadata>
|
||||
<manifest>%s</manifest>
|
||||
<spine toc="ncx">%s</spine>
|
||||
</package>""" % (html.escape(meta["id"]), html.escape(meta["title"]),
|
||||
html.escape(meta["author"]), meta["lang"],
|
||||
"\n ".join(items), "\n ".join(spine)))
|
||||
|
||||
nav = "\n".join(
|
||||
'<navPoint id="n%d" playOrder="%d"><navLabel><text>%s</text></navLabel>'
|
||||
'<content src="%s"/></navPoint>' % (i, i + 1, html.escape(t), n)
|
||||
for i, (n, t) in enumerate(files))
|
||||
z.writestr("OEBPS/toc.ncx", """<?xml version="1.0" encoding="utf-8"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head><meta name="dtb:uid" content="%s"/></head>
|
||||
<docTitle><text>%s</text></docTitle>
|
||||
<navMap>%s</navMap>
|
||||
</ncx>""" % (html.escape(meta["id"]), html.escape(meta["title"]), nav))
|
||||
return len(chapters), len(used)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("source", type=Path, help="XHTML от pdf2html.py/epub2html.py")
|
||||
ap.add_argument("output", type=Path, help="куда писать .epub")
|
||||
ap.add_argument("--title", required=True)
|
||||
ap.add_argument("--author", default="")
|
||||
ap.add_argument("--lang", default="ru")
|
||||
ap.add_argument("--cover", default="cover.jpg", help="имя файла в images/")
|
||||
args = ap.parse_args()
|
||||
meta = {"title": args.title, "author": args.author, "lang": args.lang,
|
||||
"cover": args.cover, "id": "urn:uuid:" + args.output.stem.replace(" ", "-")}
|
||||
n, imgs = build(args.source, args.output, meta)
|
||||
size = args.output.stat().st_size / 2**20
|
||||
print("%s: глав %d, картинок %d, %.1f МБ" % (args.output.name, n, imgs, size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user