#!/usr/bin/env python3
"""Render the Bryan Parent books as linked PDFs.
The renderer intentionally keeps Markdown as the source of truth. It produces:
- editorial: comments and editorial review sections remain visible;
- clean: HTML comments, editorial review sections, and development-theme notes
are removed for a distraction-free reading edition.
Chrome's print engine preserves same-document anchors, so the generated PDF has
clickable contents, chapter maps, and repeated "Contents" links.
"""
from __future__ import annotations
import argparse
import html
import json
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SOURCE = ROOT / "projects/covenant_parenting_bryan_hickman_revised.md"
DEFAULT_OUT = ROOT / "exports"
CHROME = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
PARENT_REVIEW_CONTRACT = ROOT / "tools" / "parent_review_contract.json"
def validate_parent_review_contract(markdown: str) -> None:
"""Fail closed if the parent-review transformation lost requested semantics."""
contract = json.loads(PARENT_REVIEW_CONTRACT.read_text(encoding="utf-8"))
headings = [line[3:].strip() for line in markdown.splitlines() if line.startswith("## ")]
for prefix in contract["required_main_chapter_prefixes"]:
if not any(heading.startswith(prefix) for heading in headings):
raise ValueError(f"Parent-review contract requires main heading {prefix!r}")
if contract["required_workbook_heading"] not in markdown:
raise ValueError("Parent-review contract requires the Reflection and Action workbook placement")
for forbidden in contract["excluded_content"]:
if forbidden in markdown:
raise ValueError(f"Parent-review contract forbids excluded content {forbidden!r}")
EDITORIAL_SECTION = re.compile(
r"^(?:Development themes|Themes captured for future (?:book|workbook) development|"
r"Editorial(?:, pastoral, developmental, and safeguarding| and pastoral)? review(?: flags)?|"
r"Editorial, pastoral, developmental, and safeguarding review)$",
re.I,
)
def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---", 4)
if end < 0:
return {}, text
raw = text[4:end].strip("\n")
data: dict[str, str] = {}
for line in raw.splitlines():
if ":" in line and not line.startswith(" "):
key, value = line.split(":", 1)
data[key.strip()] = value.strip().strip('"')
return data, text[end + 4 :].lstrip("\n")
def clean_markdown(text: str, mode: str) -> str:
"""Remove or preserve editorial material before Markdown conversion."""
# The parent-review edition deliberately retains its flowing opening.
keep_opening = "" in text
# Normalize comments first. In editorial mode we retain a parser token so
# the note is rendered as a visible callout rather than as raw HTML.
if mode == "clean":
text = re.sub(r"", "", text, flags=re.S)
else:
text = re.sub(
r"",
lambda m: "@@EDITORIAL_COMMENT " + re.sub(r"\s+", " ", m.group(1)).strip() + "@@",
text,
flags=re.S | re.I,
)
text = re.sub(r"", "", text, flags=re.S)
if mode != "clean":
return text
# Drop editorial-only sections, but keep the surrounding teaching. A
# heading at level 3 owns content until the next heading at level 3 or 2.
lines = text.splitlines()
output: list[str] = []
skip_level: int | None = None
for line in lines:
heading = re.match(r"^(#{1,6})\s+(.*?)\s*$", line)
if heading:
level = len(heading.group(1))
title = heading.group(2).strip().strip("# ")
if skip_level is not None and level <= skip_level:
skip_level = None
if level == 3 and EDITORIAL_SECTION.match(title):
skip_level = 3
continue
if skip_level is None:
output.append(line)
# A clean reading edition begins with the first chapter, not the revised
# draft's provenance, editorial-status, and Scripture-audit preamble.
first_chapter = next(
(idx for idx, line in enumerate(output) if re.match(r"^##\s+", line)),
None,
)
if first_chapter is not None and not keep_opening:
output = output[first_chapter:]
# NotebookLM source-reference markers such as [1] and [2, 48]
# belong in the archival/editorial material, not in the reading edition.
output_text = "\n".join(output)
output_text = re.sub(r"\s*\[\s*\d+(?:\s*,\s*\d+)*\s*\]", "", output_text)
return output_text
def inline_markdown(value: str) -> str:
"""Small, safe inline Markdown subset used by this book."""
# Preserve URLs before escaping, then restore link markup.
placeholders: list[str] = []
def stash(fragment: str) -> str:
placeholders.append(fragment)
return f"\x00{len(placeholders) - 1}\x00"
value = re.sub(
r"\[([^\]]+)\]\((https?://[^\s)]+|#[^\s)]+)\)",
lambda m: stash(f'{html.escape(m.group(1))}'),
value,
)
value = re.sub(
r"\[\[([^\]]+)\]\]",
lambda m: html.escape(m.group(1).replace("_", " ")),
value,
)
value = html.escape(value, quote=False)
value = re.sub(r"`([^`]+)`", r"\1", value)
value = re.sub(r"\*\*([^*]+)\*\*", r"\1", value)
value = re.sub(r"__([^_]+)__", r"\1", value)
value = re.sub(r"(?\1", value)
value = re.sub(r"(?\1", value)
value = value.replace(" ", "
")
for i, fragment in enumerate(placeholders):
value = value.replace(f"\x00{i}\x00", fragment)
return value
def slugify(text: str, used: set[str]) -> str:
plain = re.sub(r"[*_`]+", "", text)
plain = re.sub(r"[^\w\s-]", "", plain, flags=re.UNICODE).strip().lower()
slug = re.sub(r"[-\s]+", "-", plain) or "section"
base = slug
n = 2
while slug in used:
slug = f"{base}-{n}"
n += 1
used.add(slug)
return slug
def is_table_line(line: str) -> bool:
return line.lstrip().startswith("|") and line.rstrip().endswith("|")
def table_html(lines: list[str]) -> str:
rows = []
for line in lines:
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if all(re.fullmatch(r":?-+:?", c) for c in cells):
continue
rows.append(cells)
if not rows:
return ""
out = ["
| {inline_markdown(cell)} | ") out.append("
|---|
| {inline_markdown(c)} | " for c in row) + "
{html.escape(chr(10).join(code_lines))}")
code_lines = []
in_code = False
else:
in_code = True
i += 1
continue
if in_code:
code_lines.append(line)
i += 1
continue
if not line.strip():
i += 1
continue
if line.startswith("@@EDITORIAL_COMMENT ") and line.endswith("@@"):
if mode == "editorial":
note = line[len("@@EDITORIAL_COMMENT ") : -2].strip()
chunks.append(f'')
i += 1
continue
image = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$", line)
if image:
alt, target = image.groups()
image_path = (source_dir / target).resolve()
if not image_path.is_file():
raise FileNotFoundError(f"Image not found: {image_path}")
chunks.append(
f'{inline_markdown(chr(10).join(q))}') continue if re.match(r"^\s*[-*+]\s+", line): items = [] while i < len(lines) and re.match(r"^\s*[-*+]\s+", lines[i]): items.append(re.sub(r"^\s*[-*+]\s+", "", lines[i])) i += 1 chunks.append("
{inline_markdown(chr(10).join(para))}
") return "\n".join(chunks), headings CSS = r""" @page { size: 6in 9in; margin: .70in .65in .70in .75in; } * { box-sizing: border-box; } html { background: #e9e7e1; } body { margin: 0; color: #25231f; background: #fffdf8; font-family: Georgia, 'Times New Roman', serif; font-size: 10.1pt; line-height: 1.40; } a { color: #315d75; text-decoration: underline; } .cover { height: 7.55in; display: flex; flex-direction: column; justify-content: center; text-align: center; page-break-after: always; } .cover h1 { font-size: 28pt; line-height: 1.08; margin: 0 auto .24in; max-width: 4.7in; color: #263d4a; } .cover .subtitle { font-size: 12pt; color: #6b5d4d; max-width: 4.2in; margin: 0 auto; } .cover .rule { width: 1.2in; border-top: 2px solid #b39057; margin: .28in auto; } .cover .mode { margin-top: .35in; font-size: 8.5pt; letter-spacing: .08em; text-transform: uppercase; color: #827463; } .contents { page-break-after: always; } .contents h1 { color: #263d4a; font-size: 22pt; margin-top: 0; } .toc-list { list-style: none; padding: 0; margin: .2in 0; } .toc-list li { margin: .11in 0; } .toc-list a { text-decoration: none; color: #263d4a; } .toc-list .toc-level-3 { margin-left: .22in; font-size: 9.2pt; } .contents-note { color: #746b60; font-size: 8.7pt; border-left: 2px solid #b39057; padding-left: .12in; margin-bottom: .25in; } .chapter-start { page-break-before: always; } h2 { color: #263d4a; font-size: 19pt; line-height: 1.12; margin: 0 0 .18in; page-break-after: avoid; } h3 { color: #486372; font-size: 13pt; margin: .28in 0 .08in; page-break-after: avoid; } h4 { color: #655b4e; font-size: 11pt; margin: .2in 0 .05in; page-break-after: avoid; } p { margin: 0 0 .13in; widows: 3; orphans: 3; } blockquote { margin: .18in .18in; padding: .10in .16in; border-left: 3px solid #b39057; color: #51483e; background: #f5f0e7; font-size: 10pt; } ul, ol { margin: .05in 0 .16in .25in; padding-left: .18in; } li { margin: .035in 0; } hr { border: 0; border-top: 1px solid #d4c9b8; margin: .28in 0; } code { font-family: Menlo, monospace; font-size: .88em; } pre { white-space: pre-wrap; background: #f0ede7; padding: .12in; font-size: 8pt; } table { border-collapse: collapse; width: 100%; margin: .16in 0; font-size: 8.4pt; } th, td { border: 1px solid #d8d0c4; padding: .05in; vertical-align: top; } th { background: #eef2f1; color: #263d4a; text-align: left; } .chapter-map { margin: 0 0 .24in; padding: .12in .16in .08in; background: #eef2f1; border: 1px solid #d3dfdf; page-break-inside: avoid; font-size: 8.8pt; } .chapter-map strong { color: #263d4a; } .chapter-map ul { margin: .04in 0 .02in .18in; } .chapter-map li { margin: .01in 0; } .editorial-comment { margin: .15in 0; padding: .10in .14in; border-left: 3px solid #a25b38; background: #fbefe8; color: #70432f; font-family: Arial, sans-serif; font-size: 8.6pt; } .chapter-back { text-align: right; margin: -.10in 0 .20in; font: 7.5pt Arial, sans-serif; } .chapter-back a { color: #6b5d4d; text-decoration: none; } .content-figure { margin: .24in 0 .30in; padding: 0; page-break-inside: avoid; text-align: center; } .content-figure img { display: block; width: 100%; max-height: 3.1in; object-fit: contain; } .content-figure figcaption { margin-top: .06in; color: #746b60; font: italic 8.5pt Georgia, serif; } @media print { html { background: white; } } """ def make_html(source: Path, mode: str) -> tuple[str, str, list[tuple[int, str, str]]]: raw = source.read_text(encoding="utf-8") meta, body = parse_frontmatter(raw) body = clean_markdown(body, mode) if meta.get("status") == "parent-review-edition" and mode == "clean": validate_parent_review_contract(body) body_html, headings = markdown_to_body(body, mode, source.parent) title = meta.get("title", "The Pattern of Covenant Parenting") title = re.sub(r"\s+—\s+Bryan Hickman Teaching.*$", "", title) subtitle = "A clean reading edition" if mode == "clean" else "Editorial review draft" major = [(level, text, anchor) for level, text, anchor in headings if level == 2] toc = ['") html_doc = f'''Use the links below to move through the book. A Back to Contents link appears at the beginning of each chapter.
{''.join(toc)}