Files
pf-ocr/epub_builder/build_epub.py
T
2026-09-01 00:30:34 +03:00

666 lines
25 KiB
Python

#!/usr/bin/env python3
"""
Builds an EPUB from a chapters/ folder laid out by hand into per-chapter
subfolders, embedding a font from font/ and metadata from config.json.
Expected layout (next to this script):
epub_builder/
chapters/
cover.jpg <- cover image (optional, any "cover.*" directly in chapters/)
ch00_frontmatter/ <- everything before the main text
001.txt
002.jpg
ch01_chapter00/
003.txt
004.txt
005.jpg
ch02_chapter01/
006.txt
ch08_backmatter/
...
font/
YourFont.ttf <- font to embed (optional)
build_epub.py
furigana.py
config.json <- metadata and build settings
Chapter folder naming: chNN_name
- chNN determines sort order.
- name is the default chapter title (can be overridden via
"chapter_titles" in config.json).
- Folders whose name (as a whole, or the part after chNN_) contains
"frontmatter" or "backmatter" are ALWAYS ignored: no title, no TOC
entry, no in-body heading — regardless of any flag or config
setting. Their content is still included in the book, just without
a section label.
Page layout:
- The first line of each .txt page (after stripping a leading page
number) is rendered as a larger, bold "page header" — usually an
in-story date/time/chapter marker, not just page-numbering noise.
- If nothing is left after stripping the page number, two blank lines
are shown instead, so the page's vertical rhythm stays consistent.
- A blank line always follows the header, then the text.
- Each paragraph starts with a full-width space indent (standard in
Japanese typography), not a tab character that HTML/EPUB would
collapse anyway.
Chapter titles are shown in the table of contents by default, but NOT as
an in-body <h1> heading — pass --show-chapter-titles (or set
"show_chapter_titles": true in config.json) to also print them at the
start of each chapter.
Usage:
python build_epub.py
# (all settings are read from config.json / chapters/ / font/ next to this script)
# or with explicit paths/overrides:
python build_epub.py --pages-dir ./chapters --font-dir ./font \\
--config ./config.json --output ./book.epub --show-chapter-titles
Requirements:
pip install natsort
(furigana.py must live in the same folder as this script)
"""
import argparse
import html
import json
import re
import sys
import uuid
import zipfile
from pathlib import Path
from natsort import natsorted
sys.path.insert(0, str(Path(__file__).resolve().parent))
from furigana import parse_page, page_to_paragraphs_html # noqa: E402
SCRIPT_DIR = Path(__file__).resolve().parent
TEXT_EXT = {".txt"}
IMAGE_EXT = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".gif": "image/gif"}
FONT_EXT = {".ttf": "font/ttf", ".otf": "font/otf", ".woff": "font/woff", ".woff2": "font/woff2"}
CHAPTER_FOLDER_RE = re.compile(r"^ch(\d+)_(.+)$", re.IGNORECASE)
CSS_BASE = """
body {{ font-family: {font_stack}; line-height: 1.8; }}
body.vertical {{ writing-mode: vertical-rl; }}
p {{ margin: 0 0 1em 0; }}
p.page-header {{ font-size: 1.3em; font-weight: bold; margin: 1.5em 0 0 0; }}
p.spacer {{ margin: 0 0 1em 0; }}
rt {{ font-size: 0.5em; }}
h1 {{ text-align: center; margin: 2em 0; }}
div.illustration {{ text-align: center; margin: 1em 0; }}
div.illustration img {{ max-width: 100%; max-height: 100%; }}
div.cover {{ text-align: center; margin: 0; padding: 0; }}
div.cover img {{ max-width: 100%; height: auto; }}
{font_faces}
"""
FONT_FACE_TEMPLATE = """@font-face {{
font-family: "{family}";
src: url("../fonts/{filename}") format("{fmt}");
font-weight: {weight};
font-style: {style};
}}
"""
CONTAINER_XML = """<?xml version="1.0" encoding="UTF-8"?>
<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>
"""
CHAPTER_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja">
<head>
<meta charset="UTF-8"/>
<title>{title}</title>
<link rel="stylesheet" type="text/css" href="../css/style.css"/>
</head>
<body{body_class}>
{h1}
{content}
</body>
</html>
"""
COVER_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja">
<head>
<meta charset="UTF-8"/>
<title>Cover</title>
<link rel="stylesheet" type="text/css" href="../css/style.css"/>
</head>
<body>
<div class="cover"><img src="../{img_href}" alt="Cover"/></div>
</body>
</html>
"""
# ---------------------------------------------------------------------------
# Page files: sorting, folder-name parsing
# ---------------------------------------------------------------------------
def numeric_sort_key(path: Path):
"""Sorts files strictly by the number found in the filename, ignoring the extension."""
m = re.search(r"\d+", path.stem)
if m:
return (0, int(m.group()), path.name)
return (1, 0, path.name)
def sort_files(files: list[Path]) -> list[Path]:
return sorted(files, key=numeric_sort_key)
def parse_chapter_folder_name(folder_name: str) -> tuple[str | None, str]:
"""
Parses a folder name of the form "chNN_name" -> (NN, name).
Returns (None, folder_name) if it doesn't match the pattern.
"""
m = CHAPTER_FOLDER_RE.match(folder_name)
if m:
return m.group(1), m.group(2)
return None, folder_name
def is_ignored_section(folder_name: str) -> bool:
"""frontmatter/backmatter sections are always ignored, regardless of any flag."""
normalized = folder_name.lower().replace("_", "").replace("-", "")
return "frontmatter" in normalized or "backmatter" in normalized
def resolve_title(folder_name: str, chapter_titles_map: dict) -> str | None:
"""
Returns the display title for a chapter, or None if the section should
have no heading and no TOC entry (frontmatter/backmatter, or an
explicit empty string in chapter_titles_map).
"""
if is_ignored_section(folder_name):
return None
if folder_name in chapter_titles_map:
value = chapter_titles_map[folder_name].strip()
return value if value else None
_, remainder = parse_chapter_folder_name(folder_name)
default_title = remainder.replace("_", " ").strip()
return default_title if default_title else folder_name
# ---------------------------------------------------------------------------
# Processing the files in one chapter folder (text + images)
# ---------------------------------------------------------------------------
def build_doc_from_folder(files: list[Path], doc_id: str):
"""Returns (content_html, [(arcname, filepath, media_type), ...])."""
content_parts = []
images = []
for fpath in files:
suffix = fpath.suffix.lower()
if suffix in TEXT_EXT:
text = fpath.read_text(encoding="utf-8")
header, paragraphs = parse_page(text)
if not header and not paragraphs:
continue # [NO_TEXT] / empty page
content_parts.append(page_to_paragraphs_html(header, paragraphs))
elif suffix in IMAGE_EXT:
arcname = f"images/{doc_id}_{fpath.stem}{suffix}"
content_parts.append(f'<div class="illustration"><img src="../{arcname}" alt=""/></div>')
images.append((arcname, fpath, IMAGE_EXT[suffix]))
else:
print(f"Skipping unknown file type: {fpath}", file=sys.stderr)
return "\n".join(content_parts), images
def find_cover(pages_dir: Path, explicit_cover: str | None) -> Path | None:
if explicit_cover:
p = Path(explicit_cover)
return p if p.exists() else None
for ext in IMAGE_EXT:
candidates = list(pages_dir.glob(f"cover{ext}")) + list(pages_dir.glob(f"cover{ext.upper()}"))
if candidates:
return candidates[0]
return None
# ---------------------------------------------------------------------------
# Grouping pages into documents (chapters), with the split-pages option
# ---------------------------------------------------------------------------
def group_pages_into_docs(pages_dir: Path, cover_path: Path | None, chapter_titles_map: dict, split_pages: bool):
subdirs = natsorted([d for d in pages_dir.iterdir() if d.is_dir()], key=lambda d: d.name)
root_files = [
p for p in pages_dir.iterdir()
if p.is_file() and p.suffix.lower() in (TEXT_EXT | set(IMAGE_EXT)) and p != cover_path
]
if not subdirs and not root_files:
print(f"No subfolders or page files found in {pages_dir}.", file=sys.stderr)
sys.exit(1)
docs = []
all_images = []
doc_counter = 0
def add_doc_merged(folder_name: str, files: list[Path]):
nonlocal doc_counter
if not files:
return
doc_counter += 1
doc_id = f"doc{doc_counter:03d}_{re.sub(r'[^a-zA-Z0-9]+', '', folder_name) or 'section'}"
content, images = build_doc_from_folder(files, doc_id)
if not content:
doc_counter -= 1
return
title = resolve_title(folder_name, chapter_titles_map)
docs.append({"id": doc_id, "title": title, "content": content})
all_images.extend(images)
def add_docs_split(folder_name: str, files: list[Path]):
nonlocal doc_counter
if not files:
return
folder_title = resolve_title(folder_name, chapter_titles_map)
safe_name = re.sub(r"[^a-zA-Z0-9]+", "", folder_name) or "section"
first_page_in_folder = True
for fpath in files:
doc_counter += 1
doc_id = f"doc{doc_counter:03d}_{safe_name}_{fpath.stem}"
content, images = build_doc_from_folder([fpath], doc_id)
if not content:
doc_counter -= 1
continue
title = folder_title if first_page_in_folder else None
first_page_in_folder = False
docs.append({"id": doc_id, "title": title, "content": content})
all_images.extend(images)
add_doc = add_docs_split if split_pages else add_doc_merged
if root_files:
add_doc("root", sort_files(root_files))
for subdir in subdirs:
files = sort_files(
[p for p in subdir.iterdir() if p.is_file() and p.suffix.lower() in (TEXT_EXT | set(IMAGE_EXT))]
)
add_doc(subdir.name, files)
return docs, all_images
# ---------------------------------------------------------------------------
# Font handling
# ---------------------------------------------------------------------------
def find_fonts(font_dir: Path) -> list[Path]:
if not font_dir.exists():
return []
fonts = [p for p in font_dir.iterdir() if p.is_file() and p.suffix.lower() in FONT_EXT]
return natsorted(fonts, key=lambda p: p.name)
def sanitize_family_name(stem: str) -> str:
return re.sub(r"[^a-zA-Z0-9 _-]+", "", stem).strip() or "EmbeddedFont"
STYLE_KEYWORDS_RE = re.compile(r"(?i)bold[\s_-]*italic|italic[\s_-]*bold|bold|italic|oblique|regular")
def detect_font_role(stem: str) -> str | None:
"""
Detects a font variant from its filename: Regular/Bold/Italic/BoldItalic
(case-insensitive, anywhere in the name). Returns None if it can't be
determined (non-standard filename).
"""
lower = stem.lower()
has_bold = "bold" in lower
has_italic = "italic" in lower or "oblique" in lower
if has_bold and has_italic:
return "bolditalic"
if has_bold:
return "bold"
if has_italic:
return "italic"
if "regular" in lower:
return "regular"
return None
ROLE_WEIGHT_STYLE = {
"regular": ("normal", "normal"),
"bold": ("bold", "normal"),
"italic": ("normal", "italic"),
"bolditalic": ("bold", "italic"),
}
def strip_style_keyword(stem: str) -> str:
"""Removes style keywords from a font filename, returning the remainder (may be empty)."""
stripped = STYLE_KEYWORDS_RE.sub("", stem)
stripped = re.sub(r"[\s_-]+", " ", stripped).strip(" -_")
return stripped
def resolve_font_plan(fonts: list[Path], explicit_family: str | None):
"""
Decides how to wire up the fonts found in font/:
- If every file's variant is recognized (Regular/Bold/Italic/BoldItalic,
with at least one Regular among them), all of them get ONE shared
font-family name with the correct font-weight/font-style, so
<b>/<strong>/<em>/bold headings automatically pick up the right file.
- Otherwise (non-standard filenames): the old behavior — each file gets
its own family name, and only the first one (alphabetically) gets
wired up automatically in the CSS.
Returns a list of tuples (font_path, family, weight, style, is_primary).
"""
if not fonts:
return []
roles = {f: detect_font_role(f.stem) for f in fonts}
all_recognized = all(r is not None for r in roles.values())
has_regular = any(r == "regular" for r in roles.values())
if all_recognized and has_regular:
if explicit_family:
family = explicit_family
else:
stripped_names = [strip_style_keyword(f.stem) for f in fonts]
non_empty = [n for n in stripped_names if n]
family = max(set(non_empty), key=non_empty.count) if non_empty else "MainFont"
family = sanitize_family_name(family) or "MainFont"
plan = []
for f in fonts:
weight, style = ROLE_WEIGHT_STYLE[roles[f]]
is_primary = roles[f] == "regular"
plan.append((f, family, weight, style, is_primary))
return plan
# Fallback: non-standard filenames — separate families
plan = []
for i, f in enumerate(fonts):
family = explicit_family if (explicit_family and i == 0) else sanitize_family_name(f.stem)
plan.append((f, family, "normal", "normal", i == 0))
return plan
# ---------------------------------------------------------------------------
# Assembling the final epub
# ---------------------------------------------------------------------------
def build_epub(docs, all_images, cover_path, fonts: list[Path], output_path: Path,
meta: dict, vertical: bool, show_chapter_titles: bool):
book_id = meta.get("identifier") or f"urn:uuid:{uuid.uuid4()}"
body_class = ' class="vertical"' if vertical else ""
manifest_items = []
spine_items = []
nav_items = []
package_files = {}
# --- Font ---
font_faces_css = ""
font_family_stack = "serif"
if fonts:
font_plan = resolve_font_plan(fonts, meta.get("font_family"))
font_face_rules = []
primary_family = None
variant_labels = []
for font_path, family, weight, style, is_primary in font_plan:
fmt = FONT_EXT[font_path.suffix.lower()].split("/")[-1]
font_face_rules.append(
FONT_FACE_TEMPLATE.format(family=family, filename=font_path.name, fmt=fmt, weight=weight, style=style)
)
arcname = f"fonts/{font_path.name}"
package_files[f"OEBPS/{arcname}"] = font_path.read_bytes()
font_id = "font_" + re.sub(r"[^a-zA-Z0-9]+", "_", font_path.name)
manifest_items.append(f'<item id="{font_id}" href="{arcname}" media-type="{FONT_EXT[font_path.suffix.lower()]}"/>')
variant_labels.append(f"{font_path.name} ({weight}/{style})")
if is_primary and primary_family is None:
primary_family = family
font_faces_css = "\n".join(font_face_rules)
font_family_stack = f'"{primary_family}", serif'
unified = len(set(family for _, family, *_ in font_plan)) == 1 and len(font_plan) > 1
if unified:
print(f'Font: {len(fonts)} variant(s) unified under the name "{primary_family}": '
+ ", ".join(variant_labels))
elif len(fonts) > 1:
print(f"Found {len(fonts)} font file(s). Filenames don't look like Regular/Bold/Italic — "
f"using {fonts[0].name} as the primary one. The rest are embedded in the epub but "
f"not wired up in the CSS automatically — edit style.css by hand if needed.")
css_content = CSS_BASE.format(font_stack=font_family_stack, font_faces=font_faces_css)
# --- Cover ---
cover_meta = ""
guide_xml = ""
if cover_path:
cover_ext = cover_path.suffix.lower()
cover_media = IMAGE_EXT.get(cover_ext, "image/jpeg")
cover_img_arcname = f"images/cover{cover_ext}"
package_files[f"OEBPS/{cover_img_arcname}"] = cover_path.read_bytes()
manifest_items.append(
f'<item id="cover-image" href="{cover_img_arcname}" media-type="{cover_media}" properties="cover-image"/>'
)
package_files["OEBPS/text/cover.xhtml"] = COVER_TEMPLATE.format(img_href=cover_img_arcname)
manifest_items.append('<item id="cover-page" href="text/cover.xhtml" media-type="application/xhtml+xml"/>')
spine_items.append('<itemref idref="cover-page" linear="yes"/>')
cover_meta = '<meta name="cover" content="cover-image"/>'
guide_xml = '<guide><reference type="cover" title="Cover" href="text/cover.xhtml"/></guide>'
# --- Images from chapters ---
for arcname, filepath, media_type in all_images:
package_files[f"OEBPS/{arcname}"] = filepath.read_bytes()
img_id = "img_" + re.sub(r"[^a-zA-Z0-9]+", "_", arcname)
manifest_items.append(f'<item id="{img_id}" href="{arcname}" media-type="{media_type}"/>')
# --- Chapters ---
for doc in docs:
fname = f"text/{doc['id']}.xhtml"
display_title = doc["title"]
xhtml_title = display_title if display_title else " "
h1 = f"<h1>{html.escape(display_title)}</h1>" if (display_title and show_chapter_titles) else ""
package_files[f"OEBPS/{fname}"] = CHAPTER_TEMPLATE.format(
title=html.escape(xhtml_title), body_class=body_class, h1=h1, content=doc["content"],
)
manifest_items.append(f'<item id="{doc["id"]}" href="{fname}" media-type="application/xhtml+xml"/>')
spine_items.append(f'<itemref idref="{doc["id"]}"/>')
if display_title:
nav_items.append(f'<li><a href="{fname}">{html.escape(display_title)}</a></li>')
manifest_extra = (
'<item id="css" href="css/style.css" media-type="text/css"/>\n'
' <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>\n'
' <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>'
)
# --- Additional dc: metadata ---
dc_extra = []
if meta.get("publisher"):
dc_extra.append(f"<dc:publisher>{html.escape(meta['publisher'])}</dc:publisher>")
if meta.get("description"):
dc_extra.append(f"<dc:description>{html.escape(meta['description'])}</dc:description>")
if meta.get("date"):
dc_extra.append(f"<dc:date>{html.escape(meta['date'])}</dc:date>")
if meta.get("rights"):
dc_extra.append(f"<dc:rights>{html.escape(meta['rights'])}</dc:rights>")
if meta.get("series"):
dc_extra.append(f'<meta name="calibre:series" content="{html.escape(meta["series"])}"/>')
if meta.get("series_index"):
dc_extra.append(f'<meta name="calibre:series_index" content="{html.escape(str(meta["series_index"]))}"/>')
content_opf = f"""<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="BookId">{book_id}</dc:identifier>
<dc:title>{html.escape(meta['title'])}</dc:title>
<dc:creator>{html.escape(meta['author'])}</dc:creator>
<dc:language>{meta['language']}</dc:language>
{cover_meta}
{chr(10).join(' ' + line for line in dc_extra)}
</metadata>
<manifest>
{manifest_extra}
{chr(10).join(' ' + item for item in manifest_items)}
</manifest>
<spine>
{chr(10).join(' ' + item for item in spine_items)}
</spine>
{guide_xml}
</package>
"""
nav_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="{meta['language']}">
<head><meta charset="UTF-8"/><title>Table of Contents</title></head>
<body>
<nav epub:type="toc" id="toc">
<h1>Table of Contents</h1>
<ol>
{chr(10).join(nav_items)}
</ol>
</nav>
</body>
</html>
"""
nav_points = []
playorder = 0
for doc in docs:
if not doc["title"]:
continue
playorder += 1
nav_points.append(f""" <navPoint id="navpoint-{playorder}" playOrder="{playorder}">
<navLabel><text>{html.escape(doc['title'])}</text></navLabel>
<content src="text/{doc['id']}.xhtml"/>
</navPoint>""")
toc_ncx = f"""<?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="{book_id}"/></head>
<docTitle><text>{html.escape(meta['title'])}</text></docTitle>
<navMap>
{chr(10).join(nav_points)}
</navMap>
</ncx>
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output_path, "w") as zf:
zf.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED)
zf.writestr("META-INF/container.xml", CONTAINER_XML)
zf.writestr("OEBPS/content.opf", content_opf)
zf.writestr("OEBPS/nav.xhtml", nav_xhtml)
zf.writestr("OEBPS/toc.ncx", toc_ncx)
zf.writestr("OEBPS/css/style.css", css_content)
for arcname, content in package_files.items():
zf.writestr(arcname, content)
print(f"EPUB built: {output_path}")
print(f"Total sections: {len(docs)}")
for doc in docs:
label = doc["title"] if doc["title"] else "(untitled, not in TOC)"
print(f" - {doc['id']}: {label}")
if cover_path:
print(f"Cover: {cover_path}")
if fonts:
print(f"Fonts embedded: {len(fonts)} (primary: {fonts[0].name})")
# ---------------------------------------------------------------------------
# Config and entry point
# ---------------------------------------------------------------------------
def load_config(config_path: Path) -> dict:
if not config_path.exists():
return {}
return json.loads(config_path.read_text(encoding="utf-8"))
def main():
parser = argparse.ArgumentParser(description="Build an EPUB from a chapters/ folder, with metadata from config.json")
parser.add_argument("--pages-dir", default=str(SCRIPT_DIR / "chapters"), help="Folder with chapter subfolders")
parser.add_argument("--font-dir", default=str(SCRIPT_DIR / "font"), help="Folder with the font to embed")
parser.add_argument("--config", default=str(SCRIPT_DIR / "config.json"), help="Path to config.json")
parser.add_argument("--output", default=None, help="Path to the resulting .epub (defaults to config.json's value)")
parser.add_argument("--cover", default=None, help="Explicit cover image path (overrides auto-detection and config.json)")
parser.add_argument("--title", default=None, help="Override title from config.json")
parser.add_argument("--author", default=None, help="Override author from config.json")
parser.add_argument("--language", default=None, help="Override language from config.json")
parser.add_argument("--vertical", action="store_true", default=None, help="Vertical Japanese writing-mode")
parser.add_argument(
"--show-chapter-titles", action="store_true", default=None,
help="Print the chapter title (<h1>) at the start of each chapter. By default it only appears in the TOC."
)
parser.add_argument(
"--split-pages", action="store_true",
help="Make each file its own xhtml document, instead of merging all files in a folder into one."
)
args = parser.parse_args()
config = load_config(Path(args.config))
meta = {
"title": args.title or config.get("title"),
"author": args.author or config.get("author", "Unknown"),
"language": args.language or config.get("language", "ja"),
"identifier": config.get("identifier") or None,
"font_family": config.get("font_family") or None,
"publisher": config.get("publisher"),
"description": config.get("description"),
"date": config.get("date"),
"rights": config.get("rights"),
"series": config.get("series"),
"series_index": config.get("series_index"),
}
if not meta["title"]:
print("No title set — put one in config.json or pass --title.", file=sys.stderr)
sys.exit(1)
vertical = args.vertical if args.vertical is not None else bool(config.get("vertical", False))
show_chapter_titles = (
args.show_chapter_titles if args.show_chapter_titles is not None
else bool(config.get("show_chapter_titles", False))
)
chapter_titles_map = config.get("chapter_titles", {})
output_path = Path(args.output) if args.output else Path(config.get("output", str(SCRIPT_DIR / "book.epub")))
pages_dir = Path(args.pages_dir)
font_dir = Path(args.font_dir)
explicit_cover = args.cover or config.get("cover")
cover_path = find_cover(pages_dir, explicit_cover)
fonts = find_fonts(font_dir)
docs, all_images = group_pages_into_docs(pages_dir, cover_path, chapter_titles_map, args.split_pages)
build_epub(
docs, all_images, cover_path, fonts, output_path,
meta=meta, vertical=vertical, show_chapter_titles=show_chapter_titles,
)
if __name__ == "__main__":
main()