Files
Phelan_Varrent/export_kindle.py

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()