adding export checklists and script to do the export
This commit is contained in:
446
export_kindle.py
Normal file
446
export_kindle.py
Normal file
@@ -0,0 +1,446 @@
|
||||
#!/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 Unbreakable Curse"
|
||||
author = args.author or "Phillip Tarrant"
|
||||
subtitle = f"Book {book_num} of The Shade Series"
|
||||
|
||||
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"shade-series-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 Unbreakable Curse)",
|
||||
)
|
||||
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()
|
||||
Reference in New Issue
Block a user