447 lines
13 KiB
Python
447 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Export Book chapters to a Kindle-ready EPUB file.
|
|
|
|
Usage:
|
|
venv/bin/python export_kindle.py --book 1
|
|
# or: source venv/bin/activate && python export_kindle.py --book 1
|
|
|
|
Requires: pip install -r requirements.txt (ebooklib, markdown)
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import markdown
|
|
from ebooklib import epub
|
|
|
|
ROOT = Path(__file__).parent
|
|
KINDLE_CSS = """\
|
|
body {
|
|
font-family: Georgia, "Times New Roman", serif;
|
|
font-size: 1em;
|
|
line-height: 1.6;
|
|
margin: 1em;
|
|
}
|
|
h1 {
|
|
font-size: 1.8em;
|
|
text-align: center;
|
|
margin-top: 2em;
|
|
margin-bottom: 1.5em;
|
|
page-break-before: always;
|
|
}
|
|
p {
|
|
margin: 0 0 0.8em 0;
|
|
text-indent: 1.5em;
|
|
}
|
|
p.first-para {
|
|
text-indent: 0;
|
|
}
|
|
p.scene-break {
|
|
text-align: center;
|
|
text-indent: 0;
|
|
margin: 1.5em 0;
|
|
letter-spacing: 0.5em;
|
|
font-size: 0.85em;
|
|
}
|
|
.title-page {
|
|
text-align: center;
|
|
margin-top: 4em;
|
|
margin-bottom: 4em;
|
|
}
|
|
.title-page h1 {
|
|
font-size: 2.2em;
|
|
margin-bottom: 0.3em;
|
|
page-break-before: avoid;
|
|
}
|
|
.title-page p {
|
|
text-indent: 0;
|
|
font-size: 1.1em;
|
|
font-style: italic;
|
|
}
|
|
em { font-style: italic; }
|
|
strong { font-weight: bold; }
|
|
"""
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Markdown → HTML conversion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def convert_md_to_html(text: str) -> str:
|
|
"""Convert markdown text to HTML with smart quotes and scene breaks."""
|
|
md = markdown.Markdown(extensions=["smarty"])
|
|
html = md.convert(text)
|
|
html = convert_scene_breaks(html)
|
|
html = inject_first_para_class(html)
|
|
return html
|
|
|
|
|
|
def convert_scene_breaks(html: str) -> str:
|
|
"""Replace bare '* * *' paragraphs with styled scene breaks."""
|
|
return html.replace("<p>* * *</p>", '<p class="scene-break">* * *</p>')
|
|
|
|
|
|
def inject_first_para_class(html: str) -> str:
|
|
"""Add class='first-para' to paragraphs following h1 and scene breaks."""
|
|
# After </h1>
|
|
html = re.sub(
|
|
r"(</h1>\s*)<p>",
|
|
r'\1<p class="first-para">',
|
|
html,
|
|
)
|
|
# After scene break
|
|
html = re.sub(
|
|
r'(<p class="scene-break">.*?</p>\s*)<p>',
|
|
r'\1<p class="first-para">',
|
|
html,
|
|
)
|
|
return html
|
|
|
|
|
|
def extract_title(text: str) -> str:
|
|
"""Extract the chapter title from the first # heading."""
|
|
match = re.match(r"^#\s+(.+)$", text.strip(), re.MULTILINE)
|
|
return match.group(1) if match else None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chapter discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def find_chapter_file(chapter_dir: Path, num: int) -> Path | None:
|
|
"""Find the best file for a chapter number (prefer final, fall back to draft)."""
|
|
for suffix in ("final", "draft"):
|
|
path = chapter_dir / f"ch{num:02d}-{suffix}.md"
|
|
if path.exists():
|
|
return path
|
|
return None
|
|
|
|
|
|
def find_special_chapter(chapter_dir: Path, name: str) -> Path | None:
|
|
"""Find prologue or epilogue (prefer final, fall back to draft)."""
|
|
for suffix in ("final", "draft"):
|
|
path = chapter_dir / f"{name}-{suffix}.md"
|
|
if path.exists():
|
|
return path
|
|
return None
|
|
|
|
|
|
def discover_chapters(chapter_dir: Path) -> list[tuple[str, Path]]:
|
|
"""Discover all chapter files in order: prologue, ch01..chNN, epilogue."""
|
|
chapters = []
|
|
|
|
# Prologue
|
|
prologue = find_special_chapter(chapter_dir, "prologue")
|
|
if prologue:
|
|
chapters.append(("prologue", prologue))
|
|
|
|
# Numbered chapters
|
|
nums = set()
|
|
for p in chapter_dir.glob("ch*-final.md"):
|
|
m = re.match(r"ch(\d+)", p.name)
|
|
if m:
|
|
nums.add(int(m.group(1)))
|
|
for p in chapter_dir.glob("ch*-draft.md"):
|
|
m = re.match(r"ch(\d+)", p.name)
|
|
if m:
|
|
nums.add(int(m.group(1)))
|
|
|
|
for num in sorted(nums):
|
|
path = find_chapter_file(chapter_dir, num)
|
|
if path:
|
|
chapters.append((f"ch{num:02d}", path))
|
|
|
|
# Epilogue
|
|
epilogue = find_special_chapter(chapter_dir, "epilogue")
|
|
if epilogue:
|
|
chapters.append(("epilogue", epilogue))
|
|
|
|
return chapters
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Template handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def is_stub(text: str) -> bool:
|
|
"""Check if a template file is a stub (placeholder with no real content)."""
|
|
# Strip headings, HTML comments, and whitespace
|
|
content = re.sub(r"^#+\s+.*$", "", text, flags=re.MULTILINE)
|
|
content = re.sub(r"<!--.*?-->", "", content, flags=re.DOTALL)
|
|
content = content.strip()
|
|
return len(content.split()) < 50
|
|
|
|
|
|
def load_template(path: Path) -> str | None:
|
|
"""Load a template file, returning None if it doesn't exist or is a stub."""
|
|
if not path.exists():
|
|
return None
|
|
text = path.read_text(encoding="utf-8")
|
|
if is_stub(text):
|
|
print(f" Skipping stub template: {path.name}", file=sys.stderr)
|
|
return None
|
|
return text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EPUB construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def make_epub_chapter(filename: str, title: str, body_html: str) -> epub.EpubHtml:
|
|
"""Create an EPUB chapter item from HTML body content."""
|
|
chapter = epub.EpubHtml(
|
|
title=title,
|
|
file_name=filename,
|
|
lang="en",
|
|
)
|
|
chapter.set_content(body_html)
|
|
chapter.add_link(href="style/kindle.css", rel="stylesheet", type="text/css")
|
|
return chapter
|
|
|
|
|
|
def build_title_page(title: str, subtitle: str, author: str) -> epub.EpubHtml:
|
|
"""Generate a title page chapter."""
|
|
body = f"""\
|
|
<div class="title-page">
|
|
<h1>{title}</h1>
|
|
<p>{subtitle}</p>
|
|
<p>by {author}</p>
|
|
</div>"""
|
|
return make_epub_chapter("title.xhtml", "Title Page", body)
|
|
|
|
|
|
def build_epub(args) -> Path:
|
|
"""Build the EPUB file and return its path."""
|
|
book_num = args.book
|
|
title = args.title or "The Floundry Affair"
|
|
author = args.author or "Phillip Tarrant"
|
|
subtitle = f"A Phelan Varrant Novel — Book {book_num}"
|
|
|
|
chapter_dir = ROOT / "chapters" / f"book{book_num}"
|
|
if not chapter_dir.exists():
|
|
print(f"Error: Chapter directory not found: {chapter_dir}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
template_dir = ROOT / "kindle" / "templates"
|
|
export_dir = ROOT / "kindle" / "exports"
|
|
export_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# --- Create the EPUB book ---
|
|
book = epub.EpubBook()
|
|
book.set_identifier(f"phelan-varrant-book{book_num}")
|
|
book.set_title(f"{title}: {subtitle}")
|
|
book.set_language("en")
|
|
book.add_author(author)
|
|
|
|
# Cover image
|
|
if args.cover:
|
|
cover_path = Path(args.cover)
|
|
if cover_path.exists():
|
|
cover_data = cover_path.read_bytes()
|
|
ext = cover_path.suffix.lower()
|
|
media_type = {
|
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
}.get(ext, "image/jpeg")
|
|
book.set_cover("cover" + ext, cover_data)
|
|
print(f" Cover: {cover_path.name}")
|
|
else:
|
|
print(f" Warning: Cover image not found: {args.cover}", file=sys.stderr)
|
|
|
|
# CSS
|
|
css = epub.EpubItem(
|
|
uid="kindle_css",
|
|
file_name="style/kindle.css",
|
|
media_type="text/css",
|
|
content=KINDLE_CSS,
|
|
)
|
|
book.add_item(css)
|
|
|
|
spine = ["nav"]
|
|
toc = []
|
|
|
|
# --- Title page ---
|
|
title_page = build_title_page(title, subtitle, author)
|
|
book.add_item(title_page)
|
|
spine.append(title_page)
|
|
|
|
# --- Copyright page ---
|
|
copyright_md = load_template(template_dir / "copyright-page.md")
|
|
if copyright_md:
|
|
html = convert_md_to_html(copyright_md)
|
|
ch = make_epub_chapter("copyright.xhtml", "Copyright", html)
|
|
book.add_item(ch)
|
|
spine.append(ch)
|
|
|
|
# --- Front matter ---
|
|
front_md = load_template(template_dir / "front-matter.md")
|
|
if front_md:
|
|
html = convert_md_to_html(front_md)
|
|
ch = make_epub_chapter("front-matter.xhtml", "Front Matter", html)
|
|
book.add_item(ch)
|
|
spine.append(ch)
|
|
|
|
# --- Chapters ---
|
|
chapters = discover_chapters(chapter_dir)
|
|
if not chapters:
|
|
print("Error: No chapters found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(f" Found {len(chapters)} chapter(s)")
|
|
|
|
for slug, path in chapters:
|
|
text = path.read_text(encoding="utf-8")
|
|
ch_title = extract_title(text) or slug.title()
|
|
html = convert_md_to_html(text)
|
|
|
|
filename = f"{slug}.xhtml"
|
|
ch = make_epub_chapter(filename, ch_title, html)
|
|
book.add_item(ch)
|
|
spine.append(ch)
|
|
toc.append(ch)
|
|
print(f" Added: {path.name} → {ch_title}")
|
|
|
|
# --- Back matter templates ---
|
|
for template_name, epub_title in [
|
|
("back-matter.md", "Back Matter"),
|
|
("also-by.md", "Also by the Author"),
|
|
]:
|
|
back_md = load_template(template_dir / template_name)
|
|
if back_md:
|
|
html = convert_md_to_html(back_md)
|
|
fname = template_name.replace(".md", ".xhtml")
|
|
ch = make_epub_chapter(fname, epub_title, html)
|
|
book.add_item(ch)
|
|
spine.append(ch)
|
|
|
|
# --- Finalize ---
|
|
book.toc = toc
|
|
book.spine = spine
|
|
book.add_item(epub.EpubNcx())
|
|
book.add_item(epub.EpubNav())
|
|
|
|
# Output
|
|
if args.output:
|
|
out_path = Path(args.output)
|
|
else:
|
|
out_path = export_dir / f"book{book_num}-kindle.epub"
|
|
|
|
epub.write_epub(str(out_path), book, {})
|
|
return out_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Checklist generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def generate_checklist(book_num: int, epub_path: Path) -> Path:
|
|
"""Write a publishing checklist to notes/kindle-publish-checklist.md."""
|
|
notes_dir = ROOT / "notes"
|
|
notes_dir.mkdir(parents=True, exist_ok=True)
|
|
checklist_path = notes_dir / "kindle-publish-checklist.md"
|
|
|
|
checklist = f"""\
|
|
# Kindle Publishing Checklist — Book {book_num}
|
|
|
|
Generated from `export_kindle.py`. Review and complete before uploading.
|
|
|
|
EPUB: `{epub_path.relative_to(ROOT)}`
|
|
|
|
---
|
|
|
|
## Front & Back Matter
|
|
|
|
- [ ] Copyright page — fill in year, pen name, legal text (`kindle/templates/copyright-page.md`)
|
|
- [ ] Dedication (optional) — add to front matter if desired
|
|
- [ ] Author\u2019s Note (optional) — add to back matter if desired
|
|
- [ ] \u201cAlso by the Author\u201d page — create `kindle/templates/also-by.md` when applicable
|
|
- [ ] Next book preview — append preview chapter to back matter when available
|
|
|
|
## Metadata
|
|
|
|
- [ ] Confirm pen name / author name
|
|
- [ ] Confirm book title and subtitle
|
|
- [ ] Write book description / blurb (for KDP listing)
|
|
- [ ] Choose KDP categories (2 allowed)
|
|
- [ ] Select 7 keywords for KDP search
|
|
- [ ] Set price (consider KU 70% royalty tier: $2.99\u2013$9.99)
|
|
|
|
## Cover Image
|
|
|
|
- [ ] Cover image ready (2560\u00d71600px recommended for Kindle)
|
|
- [ ] Cover passes KDP image quality checks
|
|
- [ ] Run `export_kindle.py --cover path/to/cover.jpg` to embed cover in EPUB
|
|
|
|
## Quality Checks
|
|
|
|
- [ ] Open EPUB in Kindle Previewer \u2014 check rendering on multiple devices
|
|
- [ ] Verify Table of Contents links work
|
|
- [ ] Verify chapter breaks (each chapter starts on new page)
|
|
- [ ] Verify scene breaks (`* * *`) are centered and styled
|
|
- [ ] Check smart quotes, em dashes, ellipses throughout
|
|
- [ ] Check first paragraphs are not indented after headings/scene breaks
|
|
- [ ] Proofread front and back matter
|
|
- [ ] Confirm no `[CONTINUITY FLAG]` markers remain in text
|
|
|
|
## Upload
|
|
|
|
- [ ] Log in to KDP (kdp.amazon.com)
|
|
- [ ] Create new Kindle eBook
|
|
- [ ] Upload EPUB manuscript
|
|
- [ ] Upload cover image
|
|
- [ ] Fill in metadata (title, description, categories, keywords)
|
|
- [ ] Set pricing and royalty
|
|
- [ ] Enroll in Kindle Unlimited (KDP Select)
|
|
- [ ] Preview and publish
|
|
"""
|
|
|
|
checklist_path.write_text(checklist, encoding="utf-8")
|
|
return checklist_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Export chapter markdown to a Kindle-ready EPUB file.",
|
|
)
|
|
parser.add_argument(
|
|
"--book", type=int, default=1,
|
|
help="Book number (default: 1)",
|
|
)
|
|
parser.add_argument(
|
|
"--output", "-o",
|
|
help="Output EPUB path (default: kindle/exports/bookN-kindle.epub)",
|
|
)
|
|
parser.add_argument(
|
|
"--title",
|
|
help="Book title (default: The Floundry Affair)",
|
|
)
|
|
parser.add_argument(
|
|
"--author",
|
|
help="Author name (default: [Author Name])",
|
|
)
|
|
parser.add_argument(
|
|
"--cover",
|
|
help="Path to cover image (JPG or PNG)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
print(f"Building EPUB for Book {args.book}...")
|
|
|
|
epub_path = build_epub(args)
|
|
print(f"\nEPUB written: {epub_path}")
|
|
|
|
checklist_path = generate_checklist(args.book, epub_path)
|
|
print(f"Checklist written: {checklist_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|