init
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
# EPUB builder module
|
||||
|
||||
Assembles a `chapters/` folder — laid out by hand into per-chapter
|
||||
subfolders — into a finished, valid `.epub`, with furigana rendered as
|
||||
proper `<ruby>` markup, an embedded font, a cover image, and metadata from
|
||||
`config.json`.
|
||||
|
||||
This is step 2 of the pipeline; step 1 is the [`ocr`](../ocr/README.md)
|
||||
module, which produces the per-page `.txt` files you'll sort into
|
||||
`chapters/`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
epub_builder/
|
||||
chapters/
|
||||
cover.jpg <- cover image (optional, any "cover.*" directly in chapters/)
|
||||
ch00_frontmatter/ <- everything before the main text (title page, TOC, ...)
|
||||
001.txt
|
||||
002.jpg
|
||||
ch01_chapter00/
|
||||
003.txt
|
||||
004.txt
|
||||
005.jpg
|
||||
ch02_chapter01/
|
||||
006.txt
|
||||
ch08_backmatter/ <- everything after the main text (afterword, ...)
|
||||
...
|
||||
font/
|
||||
YourFont.ttf <- font to embed (optional)
|
||||
build_epub.py
|
||||
furigana.py
|
||||
config.json
|
||||
```
|
||||
|
||||
## Chapter folder naming: `chNN_name`
|
||||
|
||||
- `chNN` — sequence number, determines sort order (`ch00` → `ch01` → ... →
|
||||
`ch10`; use leading zeros if you have more than 9 chapters so plain
|
||||
alphabetical sort stays correct).
|
||||
- `name` — default chapter title (override via `config.json` →
|
||||
`chapter_titles`).
|
||||
- Folders with `frontmatter` or `backmatter` anywhere in their name are
|
||||
**always** ignored: no title, no TOC entry — regardless of any flag or
|
||||
config setting. Their content is still included in the book, just
|
||||
without a section label.
|
||||
|
||||
Files inside a folder are sorted strictly by the number in the filename
|
||||
(extension doesn't matter — an image `004.jpg` will land between
|
||||
`003.txt` and `005.txt`).
|
||||
|
||||
## Page layout
|
||||
|
||||
- The first line of each `.txt` page (after stripping a leading page
|
||||
number) is rendered as a larger, bold "page header" — typically an
|
||||
in-story date/time/chapter marker (relevant if you're OCR-ing a novel
|
||||
where that detail matters to the plot, e.g. one involving time travel).
|
||||
- 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 (` `) — the standard
|
||||
indent in Japanese typography, embedded directly in the text rather than
|
||||
relying on CSS `text-indent` (which not every reader honors).
|
||||
|
||||
## config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Your Book Title",
|
||||
"author": "Author Name",
|
||||
"language": "ja",
|
||||
"identifier": "",
|
||||
"publisher": "",
|
||||
"description": "",
|
||||
"date": "",
|
||||
"rights": "",
|
||||
"series": "",
|
||||
"series_index": "",
|
||||
"font_family": "",
|
||||
"vertical": false,
|
||||
"show_chapter_titles": false,
|
||||
"output": "book.epub",
|
||||
"chapter_titles": {
|
||||
"ch00_frontmatter": "",
|
||||
"ch01_chapter00": "Chapter 00",
|
||||
"ch08_backmatter": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `title`, `author`, `language` — core metadata (`title` is required).
|
||||
- `identifier`, `publisher`, `description`, `date`, `rights` — optional
|
||||
Dublin Core metadata, only added if non-empty. Leave `identifier` empty
|
||||
to get a random UUID generated on every build; set it once and keep it
|
||||
stable if you plan to rebuild the file repeatedly and want reading apps
|
||||
to recognize it as the same book.
|
||||
- `series` / `series_index` — Calibre-style series metadata
|
||||
(`calibre:series` / `calibre:series_index`), recognized by Calibre,
|
||||
KOReader, and several other reading apps.
|
||||
- `font_family` — explicit name for the embedded font family (see "Font"
|
||||
below). Optional — without it, the name is guessed automatically.
|
||||
- `vertical` — vertical Japanese writing-mode (also toggleable via
|
||||
`--vertical`).
|
||||
- `show_chapter_titles` — whether to print the chapter title (`<h1>`) at
|
||||
the start of each chapter. Defaults to `false` — titles still show up in
|
||||
the table of contents either way. Toggle via `--show-chapter-titles`.
|
||||
- `output` — path for the resulting epub, used if `--output` isn't passed.
|
||||
- `chapter_titles` — folder name -> display title mapping. An empty string
|
||||
`""` means "no heading, no TOC entry" (this is already the default
|
||||
behavior for `frontmatter`/`backmatter` folders, so listing them here is
|
||||
optional, just for clarity).
|
||||
|
||||
`frontmatter`/`backmatter` entries in `chapter_titles` are ignored no
|
||||
matter what — they can't be "turned back on" via config.
|
||||
|
||||
## Font
|
||||
|
||||
Drop font file(s) (`.ttf`/`.otf`/`.woff`/`.woff2`) into `font/`. Name them
|
||||
`Regular.ttf` / `Bold.ttf` / `Italic.ttf` / `BoldItalic.ttf` (case
|
||||
doesn't matter, and the keyword can be part of a longer name, e.g.
|
||||
`NotoSerifJP-Bold.ttf`) and they'll automatically be unified under one
|
||||
font-family name with the correct `font-weight`/`font-style`. That means a
|
||||
bold chapter heading (`<h1>`, and the page-header line) will automatically
|
||||
pick up `Bold.ttf`, and italics (`<em>`), if any ever show up in the text,
|
||||
will pick up `Italic.ttf`.
|
||||
|
||||
If the filenames don't match this pattern, the old behavior kicks in:
|
||||
each file gets its own font-family name, and only the first one
|
||||
(alphabetically) is wired up automatically in `body { font-family }`; the
|
||||
rest are still embedded in the epub but need manual CSS edits to use.
|
||||
|
||||
The script tries to guess a shared family name from the common part of the
|
||||
filenames (e.g. `NotoSerifJP-Regular.ttf` + `NotoSerifJP-Bold.ttf` ->
|
||||
`NotoSerifJP`). To set it explicitly, use `config.json`:
|
||||
|
||||
```json
|
||||
{ "font_family": "MyBookFont" }
|
||||
```
|
||||
|
||||
**Check the font's license** before embedding it in a file you intend to
|
||||
share or publish — personal use on your own devices is usually fine, but
|
||||
redistribution terms vary by font.
|
||||
|
||||
## Running
|
||||
|
||||
Simplest case — no arguments, if everything is where it's expected:
|
||||
|
||||
```bash
|
||||
python build_epub.py
|
||||
```
|
||||
|
||||
Or with explicit paths/overrides:
|
||||
|
||||
```bash
|
||||
python build_epub.py \
|
||||
--pages-dir ./chapters \
|
||||
--font-dir ./font \
|
||||
--config ./config.json \
|
||||
--output ./my_book.epub \
|
||||
--show-chapter-titles \
|
||||
--vertical
|
||||
```
|
||||
|
||||
`--split-pages` makes each file its own xhtml document inside the epub,
|
||||
instead of merging every file in a folder into one.
|
||||
|
||||
## `furigana.py` as a standalone preview tool
|
||||
|
||||
Useful for quickly checking how a single OCR'd page will render before
|
||||
running the full build:
|
||||
|
||||
```bash
|
||||
python furigana.py --input page.txt --output preview.xhtml
|
||||
# or a quick one-liner check:
|
||||
echo "本文《ほんぶん》デザイン" | python furigana.py --stdin
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
```bash
|
||||
pip install natsort
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
EPUB builder module: assembles a chapters/ folder (per-page .txt files from
|
||||
the ocr module, plus optional illustrations) into a finished, valid .epub —
|
||||
with furigana rendered as proper <ruby> markup, an embedded font, a cover
|
||||
image, and metadata pulled from config.json.
|
||||
|
||||
Modules:
|
||||
furigana.py Aozora Bunko furigana notation -> HTML <ruby> markup,
|
||||
plus the shared page-parsing/rendering logic. Also
|
||||
runnable standalone as a single-page preview tool.
|
||||
build_epub.py Assembles the whole book. Runnable directly:
|
||||
python -m epub_builder.build_epub
|
||||
"""
|
||||
@@ -0,0 +1,665 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Your Book Title",
|
||||
"author": "Author Name",
|
||||
"language": "ja",
|
||||
"identifier": "",
|
||||
"publisher": "",
|
||||
"description": "",
|
||||
"date": "",
|
||||
"rights": "",
|
||||
"series": "",
|
||||
"series_index": "",
|
||||
"font_family": "",
|
||||
"vertical": false,
|
||||
"show_chapter_titles": false,
|
||||
"output": "book.epub",
|
||||
"chapter_titles": {
|
||||
"ch00_frontmatter": "",
|
||||
"ch01_chapter00": "Chapter 00",
|
||||
"ch08_backmatter": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared logic for turning OCR page text (see ocr/prompt.txt for the expected
|
||||
format) into HTML paragraphs, including furigana rendered as proper <ruby>
|
||||
markup. Used by build_epub.py, and also runnable standalone as a quick
|
||||
preview/debugging tool for a single page.
|
||||
|
||||
Input text format (as produced by the OCR module):
|
||||
- First line: page header/date/chapter-marker metadata.
|
||||
- Every following line: one paragraph (no blank-line separators).
|
||||
|
||||
Furigana notation on input (Aozora Bunko style):
|
||||
- base《reading》 Furigana over `base`. `base` is the nearest
|
||||
contiguous run of kanji immediately before《.
|
||||
- |base《reading》 Explicit start of `base`, needed when it doesn't
|
||||
match a plain "kanji run" (e.g. it's shorter, or
|
||||
contains non-kanji characters, or is glued to the
|
||||
previous word without a natural boundary).
|
||||
- Several |base《reading》 in a row are kept as separate <ruby> groups,
|
||||
in reading order (e.g. a compound word whose furigana was printed
|
||||
split across its parts).
|
||||
|
||||
Standalone usage:
|
||||
python furigana.py --input page.txt --output page.xhtml
|
||||
# or a quick one-off check:
|
||||
echo "本文《ほんぶん》デザイン" | python furigana.py --stdin
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Kanji range + the iteration mark 々 + a couple of CJK compatibility ranges
|
||||
KANJI_RUN = r"[\u4e00-\u9fff\u3005\u3007\uf900-\ufaff]+"
|
||||
|
||||
# 1) Explicit base boundary via |: |<anything but |《>《reading》
|
||||
RE_MARKED = re.compile(r"|([^|《]+?)《([^》]+)》")
|
||||
# 2) No |: base is the nearest kanji run right before《
|
||||
RE_AUTO = re.compile(r"(" + KANJI_RUN + r")《([^》]+)》")
|
||||
|
||||
# Strips a leading page number like "5 " or "23 " from the page header
|
||||
LEADING_PAGE_NUMBER_RE = re.compile(r"^\d+\s*")
|
||||
|
||||
# Full-width space — the standard paragraph indent in Japanese typography
|
||||
PARAGRAPH_INDENT = "\u3000"
|
||||
|
||||
XHTML_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>
|
||||
<style type="text/css">
|
||||
{extra_style} 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; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{body}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
VERTICAL_STYLE = " body { writing-mode: vertical-rl; font-family: serif; }\n"
|
||||
HORIZONTAL_STYLE = " body { font-family: serif; }\n"
|
||||
|
||||
|
||||
def furigana_to_ruby(text: str) -> str:
|
||||
"""Replaces Aozora Bunko furigana notation with <ruby><rb>...</rb><rt>...</rt></ruby>."""
|
||||
def repl(m: re.Match) -> str:
|
||||
base, reading = m.group(1), m.group(2)
|
||||
return f"<ruby><rb>{base}</rb><rt>{reading}</rt></ruby>"
|
||||
|
||||
text = RE_MARKED.sub(repl, text)
|
||||
text = RE_AUTO.sub(repl, text)
|
||||
return text
|
||||
|
||||
|
||||
def parse_page(text: str) -> tuple[str, list[str]]:
|
||||
"""Returns (header, [paragraphs]) from a page's raw OCR text.
|
||||
|
||||
The literal marker "[NO_TEXT]" (written by the OCR module for pages
|
||||
with no text at all, e.g. illustrations) parses as an empty page.
|
||||
"""
|
||||
if text.strip() == "[NO_TEXT]":
|
||||
return "", []
|
||||
lines = [ln.strip() for ln in text.strip("\n").split("\n") if ln.strip() != ""]
|
||||
if not lines:
|
||||
return "", []
|
||||
return lines[0], lines[1:]
|
||||
|
||||
|
||||
def strip_leading_page_number(header: str) -> str:
|
||||
return LEADING_PAGE_NUMBER_RE.sub("", header, count=1)
|
||||
|
||||
|
||||
def page_to_paragraphs_html(header: str, paragraphs: list[str]) -> str:
|
||||
"""
|
||||
Renders one page's (header, paragraphs) as HTML <p> elements:
|
||||
- The header (with its leading page number stripped) is shown in a
|
||||
larger, bold "page-header" paragraph — in the source novels this
|
||||
line is usually an in-story date/time/chapter marker, not just
|
||||
page-numbering noise.
|
||||
- If the header is empty after stripping the page number, an empty
|
||||
placeholder line is rendered instead, so the vertical rhythm of the
|
||||
page stays consistent whether or not there was a header.
|
||||
- A blank spacer line always follows the header.
|
||||
- Each paragraph is prefixed with a full-width space (the standard
|
||||
Japanese paragraph indent) rather than relying on CSS text-indent,
|
||||
so the indent survives even in readers that ignore that CSS rule.
|
||||
"""
|
||||
header = strip_leading_page_number(header).strip()
|
||||
|
||||
header_html = furigana_to_ruby(html.escape(header, quote=False)) if header else " "
|
||||
|
||||
out = [
|
||||
f'<p class="page-header">{header_html}</p>',
|
||||
'<p class="spacer"> </p>',
|
||||
]
|
||||
|
||||
for para in paragraphs:
|
||||
indented = PARAGRAPH_INDENT + para
|
||||
escaped = html.escape(indented, quote=False)
|
||||
out.append(f"<p>{furigana_to_ruby(escaped)}</p>")
|
||||
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def convert_page(raw_text: str) -> str:
|
||||
"""Convenience wrapper: raw OCR page text -> HTML <p> fragment (no <html>/<body> wrapper)."""
|
||||
header, paragraphs = parse_page(raw_text)
|
||||
if not header and not paragraphs:
|
||||
return ""
|
||||
return page_to_paragraphs_html(header, paragraphs)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Convert Aozora-style furigana notation to XHTML <ruby>")
|
||||
parser.add_argument("--input", help="Path to a text file (one page's OCR output)")
|
||||
parser.add_argument("--output", help="Where to write the XHTML (defaults to stdout)")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read text from stdin (for quick checks)")
|
||||
parser.add_argument(
|
||||
"--fragment", action="store_true",
|
||||
help="Output only the <p> fragment, without wrapping it in a full XHTML document. "
|
||||
"Useful when the fragment will be inserted into an assembled chapter file elsewhere."
|
||||
)
|
||||
parser.add_argument("--title", default="page", help="Document title (for --output, not --fragment)")
|
||||
parser.add_argument(
|
||||
"--vertical", action="store_true",
|
||||
help="Vertical Japanese writing-mode (as in the original scan). "
|
||||
"Defaults to horizontal (left-to-right) text, which renders more reliably "
|
||||
"across readers and on mobile."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.stdin:
|
||||
raw_text = sys.stdin.read()
|
||||
elif args.input:
|
||||
raw_text = Path(args.input).read_text(encoding="utf-8")
|
||||
else:
|
||||
print("Provide --input <file> or --stdin", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
body = convert_page(raw_text)
|
||||
|
||||
if args.fragment:
|
||||
result = body
|
||||
else:
|
||||
extra_style = VERTICAL_STYLE if args.vertical else HORIZONTAL_STYLE
|
||||
result = XHTML_TEMPLATE.format(title=html.escape(args.title), body=body, extra_style=extra_style)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(result, encoding="utf-8")
|
||||
print(f"Saved: {args.output}")
|
||||
else:
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user