#!/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("

* * *

", '

* * *

') def inject_first_para_class(html: str) -> str: """Add class='first-para' to paragraphs following h1 and scene breaks.""" # After html = re.sub( r"(\s*)

", r'\1

', html, ) # After scene break html = re.sub( r'(

.*?

\s*)

', r'\1

', 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"""\

{title}

{subtitle}

by {author}

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