Files
Phelan_Varrent/build_book.py

318 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""Build a styled HTML file from chapter markdown drafts for email/review."""
import argparse
import re
import sys
from pathlib import Path
import markdown
CHAPTER_DIR = Path(__file__).parent / "chapters" / "book1"
OUTPUT_DIR = Path(__file__).parent / "exports"
HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Floundry Affair - A Phelan Varrant Novel (Draft)</title>
<style>
:root[data-theme="dark"] {{
--bg: #1a1a2e;
--text: #d4d4dc;
--text-muted: #888899;
--text-subtle: #6a6a7a;
--border: #2a2a3e;
--toggle-bg: #2a2a3e;
--toggle-hover: #3a3a4e;
}}
:root[data-theme="light"] {{
--bg: #fefefe;
--text: #222;
--text-muted: #555;
--text-subtle: #888;
--border: #ccc;
--toggle-bg: #eee;
--toggle-hover: #ddd;
}}
body {{
max-width: 700px;
margin: 40px auto;
padding: 0 20px;
font-family: Georgia, 'Times New Roman', serif;
font-size: 18px;
line-height: 1.8;
color: var(--text);
background: var(--bg);
transition: color 0.3s, background 0.3s;
}}
h1 {{
font-size: 28px;
margin-top: 80px;
margin-bottom: 30px;
text-align: center;
page-break-before: always;
}}
h1:first-of-type {{
margin-top: 40px;
page-break-before: avoid;
}}
p {{
margin: 0 0 1em 0;
text-indent: 1.5em;
}}
/* Scene breaks */
p:has(> br:only-child), p:empty {{
text-indent: 0;
}}
.scene-break {{
text-align: center;
margin: 2em 0;
text-indent: 0 !important;
letter-spacing: 0.5em;
font-size: 14px;
}}
em {{ font-style: italic; }}
strong {{ font-weight: bold; }}
/* Title page */
.title-page {{
text-align: center;
padding: 80px 0 60px;
border-bottom: 1px solid var(--border);
margin-bottom: 40px;
}}
.title-page h1 {{
font-size: 36px;
margin: 0 0 10px;
page-break-before: avoid;
}}
.title-page .subtitle {{
font-size: 22px;
font-style: italic;
color: var(--text-muted);
margin: 0 0 30px;
}}
.title-page .draft-notice {{
font-size: 14px;
color: var(--text-subtle);
margin-top: 20px;
}}
/* Table of contents */
.toc {{
max-width: 400px;
margin: 0 auto 40px;
padding: 20px 30px;
border: 1px solid var(--border);
border-radius: 8px;
}}
.toc h2 {{
font-size: 18px;
text-align: center;
margin: 0 0 16px;
color: var(--text-muted);
font-variant: small-caps;
letter-spacing: 0.1em;
}}
.toc ol {{
list-style: none;
padding: 0;
margin: 0;
}}
.toc li {{
margin: 8px 0;
}}
.toc a {{
color: var(--text);
text-decoration: none;
font-size: 16px;
transition: color 0.2s;
}}
.toc a:hover {{
color: var(--text-muted);
text-decoration: underline;
}}
/* First paragraph after heading shouldn't indent */
h1 + p {{
text-indent: 0;
}}
/* Theme toggle */
.theme-toggle {{
position: fixed;
top: 16px;
right: 16px;
background: var(--toggle-bg);
border: 1px solid var(--border);
border-radius: 24px;
padding: 6px 14px;
cursor: pointer;
font-size: 14px;
font-family: sans-serif;
color: var(--text-muted);
transition: background 0.3s, color 0.3s, border-color 0.3s;
z-index: 100;
}}
.theme-toggle:hover {{
background: var(--toggle-hover);
}}
@media print {{
.theme-toggle {{ display: none; }}
}}
</style>
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle light/dark mode">
<span id="theme-icon">&#9788;</span>
</button>
<div class="title-page">
<h1>The Floundry Affair</h1>
<div class="subtitle">A Phelan Varrant Novel — Book 1</div>
<div class="draft-notice">DRAFT - Chapters {chapter_label} - For Review Only</div>
</div>
{toc}
{content}
<script>
function toggleTheme() {{
var html = document.documentElement;
var icon = document.getElementById('theme-icon');
if (html.getAttribute('data-theme') === 'dark') {{
html.setAttribute('data-theme', 'light');
icon.textContent = '\\u263E';
}} else {{
html.setAttribute('data-theme', 'dark');
icon.textContent = '\\u2606';
}}
}}
</script>
</body>
</html>
"""
def parse_chapter_range(spec: str) -> list[int]:
"""Parse a chapter spec like '1-5' or '1,3,5' or '3' into a list of ints."""
chapters = []
for part in spec.split(","):
part = part.strip()
if "-" in part:
start, end = part.split("-", 1)
chapters.extend(range(int(start), int(end) + 1))
else:
chapters.append(int(part))
return sorted(set(chapters))
def find_chapter_file(num: int) -> Path:
"""Find the best available file for a chapter number (prefer final, fall back to draft)."""
final = CHAPTER_DIR / f"ch{num:02d}-final.md"
draft = CHAPTER_DIR / f"ch{num:02d}-draft.md"
if final.exists():
return final
if draft.exists():
return draft
return None
def convert_scene_breaks(html: str) -> str:
"""Replace bare '* * *' paragraphs with styled scene breaks."""
html = html.replace("<p>* * *</p>", '<p class="scene-break">* * *</p>')
return html
def extract_title(text: str) -> str:
"""Extract the chapter title from the first # heading in the markdown."""
match = re.match(r"^#\s+(.+)$", text.strip(), re.MULTILINE)
return match.group(1) if match else None
def add_heading_id(html: str, anchor_id: str) -> str:
"""Add an id attribute to the first <h1> tag in the HTML."""
return html.replace("<h1>", f'<h1 id="{anchor_id}">', 1)
def build_toc(entries: list[tuple[str, str]]) -> str:
"""Build a table of contents HTML block from (anchor_id, title) pairs."""
items = "\n".join(
f' <li><a href="#{aid}">{title}</a></li>' for aid, title in entries
)
return f'<nav class="toc">\n <h2>Contents</h2>\n <ol>\n{items}\n </ol>\n</nav>'
def build(chapters: list[int]) -> str:
"""Combine and convert chapters to styled HTML."""
md = markdown.Markdown(extensions=["smarty"])
combined_html = []
toc_entries = []
for num in chapters:
path = find_chapter_file(num)
if path is None:
print(f"Warning: No file found for chapter {num}, skipping.", file=sys.stderr)
continue
text = path.read_text(encoding="utf-8")
title = extract_title(text)
anchor_id = f"ch{num:02d}"
if title:
toc_entries.append((anchor_id, title))
md.reset()
html = md.convert(text)
html = convert_scene_breaks(html)
html = add_heading_id(html, anchor_id)
combined_html.append(html)
print(f" Added: {path.name}")
if not combined_html:
print("Error: No chapters found.", file=sys.stderr)
sys.exit(1)
if len(chapters) == 1:
chapter_label = str(chapters[0])
else:
chapter_label = f"{chapters[0]}-{chapters[-1]}"
toc = build_toc(toc_entries) if len(toc_entries) > 1 else ""
content = "\n\n".join(combined_html)
return HTML_TEMPLATE.format(content=content, chapter_label=chapter_label, toc=toc)
def main():
parser = argparse.ArgumentParser(description="Build HTML from chapter markdown drafts.")
parser.add_argument(
"-chapters",
required=True,
help="Chapter range: e.g. '1-5', '1,3,5', or '3'",
)
parser.add_argument(
"-o", "--output",
help="Output filename (default: auto-generated in exports/)",
)
args = parser.parse_args()
chapters = parse_chapter_range(args.chapters)
print(f"Building HTML for chapters: {chapters}")
html = build(chapters)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
if args.output:
out_path = Path(args.output)
else:
if len(chapters) == 1:
label = f"ch{chapters[0]:02d}"
else:
label = f"ch{chapters[0]:02d}-{chapters[-1]:02d}"
out_path = OUTPUT_DIR / f"book1-{label}-review.html"
out_path.write_text(html, encoding="utf-8")
print(f"Output: {out_path}")
if __name__ == "__main__":
main()