#!/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 = [""] for cell in rows[0]: out.append(f"") out.append("") for row in rows[1:]: out.append("" + "".join(f"" for c in row) + "") out.append("
{inline_markdown(cell)}
{inline_markdown(c)}
") return "".join(out) def headings_from_markdown(text: str) -> list[tuple[int, str, str]]: used: set[str] = set() found = [] for line in text.splitlines(): m = re.match(r"^(#{1,6})\s+(.*?)\s*$", line) if not m: continue level = len(m.group(1)) title = m.group(2).strip().strip("# ") if level == 1: continue found.append((level, title, slugify(title, used))) return found def markdown_to_body(text: str, mode: str, source_dir: Path) -> tuple[str, list[tuple[int, str, str]]]: headings = headings_from_markdown(text) heading_iter = iter(headings) used: set[str] = set() lines = text.splitlines() chunks: list[str] = [] i = 0 in_code = False code_lines: list[str] = [] while i < len(lines): line = lines[i] if line.startswith("```"): if in_code: chunks.append(f"
{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'
{html.escape(alt, quote=True)}' f'
{inline_markdown(alt)}
' ) i += 1 continue m = re.match(r"^(#{1,6})\s+(.*?)\s*$", line) if m: level = len(m.group(1)) title = m.group(2).strip().strip("# ") if level == 1: i += 1 continue anchor = slugify(title, used) if level == 2: chunks.append(f'
') chunks.append(f'{inline_markdown(title)}') # The Contents link is retained without a distracting per-chapter outline. if level == 2: chunks.append('
Back to Contents
') i += 1 next(heading_iter, None) continue if re.match(r"^[-*_]{3,}\s*$", line): chunks.append("
") i += 1 continue if is_table_line(line): table_lines = [] while i < len(lines) and is_table_line(lines[i]): table_lines.append(lines[i]) i += 1 chunks.append(table_html(table_lines)) continue if re.match(r"^>\s?", line): q = [] while i < len(lines) and re.match(r"^>\s?", lines[i]): q.append(re.sub(r"^>\s?", "", lines[i])) i += 1 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("") continue if re.match(r"^\s*\d+[.)]\s+", line): items = [] while i < len(lines) and re.match(r"^\s*\d+[.)]\s+", lines[i]): items.append(re.sub(r"^\s*\d+[.)]\s+", "", lines[i])) i += 1 chunks.append("
    " + "".join(f"
  1. {inline_markdown(x)}
  2. " for x in items) + "
") continue # Paragraph: collect until a block boundary. para = [line.strip()] i += 1 while i < len(lines) and lines[i].strip(): nxt = lines[i] if (re.match(r"^(#{1,6})\s+", nxt) or re.match(r"^>\s?", nxt) or re.match(r"^\s*[-*+]\s+", nxt) or re.match(r"^\s*\d+[.)]\s+", nxt) or re.match(r"^[-*_]{3,}\s*$", nxt) or is_table_line(nxt) or nxt.startswith("@@EDITORIAL_COMMENT ")): break para.append(nxt.strip()) i += 1 chunks.append(f"

{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''' {html.escape(title)}

{html.escape(title)}

{html.escape(subtitle)}
Bryan Hickman teaching synthesis

Contents

Use the links below to move through the book. A Back to Contents link appears at the beginning of each chapter.

{''.join(toc)}
{body_html}
''' return html_doc, title, headings def add_pdf_navigation(output: Path, title: str, headings: list[tuple[int, str, str]], mode: str) -> int: """Add reader-sidebar bookmarks after Chrome has preserved HTML anchors.""" import fitz doc = fitz.open(output) page_text = [re.sub(r"\s+", " ", page.get_text()).strip() for page in doc] toc: list[list[object]] = [] for level, heading, _anchor in headings: if level not in (2, 3): continue needle = re.sub(r"\s+", " ", heading).strip() page_number = next( (i + 1 for i, text in enumerate(page_text[2:], start=2) if needle in text), None, ) if page_number is not None: toc.append([1 if level == 2 else 2, heading, page_number]) doc.set_toc(toc) metadata = dict(doc.metadata) metadata.update({ "title": title, "subject": "Clickable Contents, chapter maps, and PDF bookmarks", "keywords": f"Bryan Hickman, covenant parenting, {mode} edition", }) doc.set_metadata(metadata) temp = output.with_suffix(".navigation.pdf") doc.save(temp, garbage=4, deflate=True) doc.close() temp.replace(output) return len(toc) def render(source: Path, output: Path, mode: str) -> dict[str, object]: if not CHROME.exists(): raise SystemExit(f"Google Chrome not found at {CHROME}") html_doc, title, headings = make_html(source, mode) output.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="parent-book-") as td: html_path = Path(td) / "book.html" profile = Path(td) / "chrome-profile" html_path.write_text(html_doc, encoding="utf-8") cmd = [ str(CHROME), "--headless=new", "--no-sandbox", "--disable-gpu", f"--user-data-dir={profile}", "--no-pdf-header-footer", "--allow-file-access-from-files", "--run-all-compositor-stages-before-draw", "--virtual-time-budget=3000", f"--print-to-pdf={output}", html_path.as_uri(), ] # Chrome can finish writing the PDF but keep a headless helper alive # on macOS. Treat that specific case as success, after terminating the # short-lived renderer instead of making the caller wait for 180s. proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) try: _, stderr = proc.communicate(timeout=45) except subprocess.TimeoutExpired: proc.kill() _, stderr = proc.communicate() if not output.exists() or output.stat().st_size == 0: raise RuntimeError(f"Chrome PDF render timed out: {stderr[-2000:]}") if proc.returncode not in (0, -9) and (not output.exists() or output.stat().st_size == 0): raise RuntimeError(f"Chrome PDF render failed: {stderr[-2000:]}") # Remove an accidental zero-byte artifact before reporting failure. if not output.exists() or output.stat().st_size == 0: raise RuntimeError("Chrome reported success but produced no PDF") bookmarks = add_pdf_navigation(output, title, headings, mode) return {"title": title, "output": str(output), "bytes": output.stat().st_size, "mode": mode, "bookmarks": bookmarks} def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--source", type=Path, default=DEFAULT_SOURCE) ap.add_argument("--mode", choices=["clean", "editorial"], required=True) ap.add_argument("--output", type=Path) args = ap.parse_args() if args.output is None: stem = args.source.stem suffix = "clean" if args.mode == "clean" else "editorial" args.output = DEFAULT_OUT / f"{stem}_{suffix}.pdf" result = render(args.source, args.output, args.mode) print(result) if __name__ == "__main__": main()