brain backup 2026-09-14

This commit is contained in:
Adolfo Reyna
2026-09-14 22:38:47 -04:00
parent ca346cc6e7
commit f0ead4f6fa
147 changed files with 73451 additions and 8 deletions
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
{
"required_main_chapter_prefixes": ["Chapter 0:", "Chapter 1:"],
"required_contents_target_prefixes": ["chapter-0", "chapter-1"],
"forbidden_removed_main_chapters": true,
"subtitle_rule": "Remove only listed internal level-3 opening subtitles; preserve level-2 main chapter headings and anchors.",
"required_workbook_heading": "Reflection and Action",
"excluded_content": ["EDITORIAL COMMENT", "Development themes", "Themes captured for future book development"]
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Create the parent-review edition without altering the editorial source draft."""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "projects/covenant_parenting_bryan_hickman_revised.md"
OUTPUT = ROOT / "projects/covenant_parenting_bryan_hickman_parent_review.md"
BOOKS = (
"Genesis|Exodus|Leviticus|Numbers|Deuteronomy|Joshua|Judges|Ruth|"
"1 Samuel|2 Samuel|1 Kings|2 Kings|1 Chronicles|2 Chronicles|Ezra|Nehemiah|Esther|Job|"
"Psalms?|Proverbs|Ecclesiastes|Song of Solomon|Isaiah|Jeremiah|Lamentations|Ezekiel|Daniel|"
"Hosea|Joel|Amos|Obadiah|Jonah|Micah|Nahum|Habakkuk|Zephaniah|Haggai|Zechariah|Malachi|"
"Matthew|Mark|Luke|John|Acts|Romans|1 Corinthians|2 Corinthians|Galatians|Ephesians|Philippians|"
"Colossians|1 Thessalonians|2 Thessalonians|1 Timothy|2 Timothy|Titus|Philemon|Hebrews|James|"
"1 Peter|2 Peter|1 John|2 John|3 John|Jude|Revelation"
)
REFERENCE = re.compile(rf"\b(?:{BOOKS})\s+\d{{1,3}}:\d{{1,3}}(?:[–-]\d{{1,3}})?")
def mark_nkjv(match: re.Match[str]) -> str:
text = match.group(0)
return text + " (NKJV)"
def remove_opening_subheads(body: str) -> str:
"""Turn Chapter 1's labeled blocks into one natural, continuous reading."""
start = body.index("## Chapter 1:")
end = body.index("## Chapter 2:", start)
opening = body[start:end]
opening = re.sub(r"^### (?:The Ultimate Goal of Training|Jesus as the Ultimate Model|Building for Generations|Healing the Parents First)\n\n", "", opening, flags=re.M)
return body[:start] + opening + body[end:]
def relocate_workbook_sections(body: str) -> str:
"""Put each chapter's reflection material at that chapter's end, not in Chapter 6."""
start = body.index("## Chapter 6: The Covenant Parenting Workbook")
end = body.index("## The 30-Day Covenant Parenting Challenge Calendar", start)
workbook = body[start:end]
sections: dict[int, str] = {}
matches = list(re.finditer(r"^### Workbook — Chapter (\d) focus: .*?\n", workbook, flags=re.M))
for index, match in enumerate(matches):
next_start = matches[index + 1].start() if index + 1 < len(matches) else len(workbook)
chapter = int(match.group(1))
content = workbook[match.end():next_start]
content = re.sub(r"\n*---\s*$", "", content, flags=re.M).strip()
sections[chapter] = "### Reflection and Action\n\n" + content + "\n\n"
body = body[:start].rstrip() + "\n\n" + body[end:]
for chapter, section in sections.items():
chapter_start = body.index(f"## Chapter {chapter}:")
next_heading = re.search(r"^## ", body[chapter_start + 1:], flags=re.M)
next_chapter = chapter_start + 1 + next_heading.start() if next_heading else len(body)
body = body[:next_chapter].rstrip() + "\n\n" + section + body[next_chapter:]
return body
def add_images(body: str) -> str:
"""Place content illustrations at the two marked transition points."""
first = "The pages that follow develop these convictions through the realities of family life: how parents model the Father, how a home learns to respond to conflict, how husband and wife protect the atmosphere of the household, how families guard the influences shaping their children, how ordinary work and presence become part of discipleship, and how parents can rebuild trust when they are beginning late or carrying a complicated history. The final reflections and exercises are meant to move these ideas out of the abstract and into conversations, prayers, decisions, and small daily practices. Covenant parenting is not a checklist that proves we are doing everything right. It is a living relationship, walked out before the Lord and before our children, one faithful response at a time."
body = body.replace(first, first, 1)
first_chapter_anchor = "Our target is to have children who seamlessly step into a personal relationship with their heavenly Father, and in that place find their Kingdom identity and faithfully walk out their Kingdom purpose in this generation. [7]"
body = body.replace(first_chapter_anchor, first_chapter_anchor + "\n\n![The covenant parenting pathway](assets/covenant-parenting-pathway.svg)", 1)
second = "There is extraordinary, transformative power when a parent is willing to be authentic and humble before their children [166]. It models a soft, teachable heart and teaches children how to walk out repentance in their own lives [166]."
body = body.replace(second, second + "\n\n![A family rhythm of humility, repair, and peace](assets/family-rhythm-of-repair.svg)", 1)
return body
def main() -> None:
text = SOURCE.read_text(encoding="utf-8")
front, body = text.split("---\n", 2)[1:]
front = re.sub(r'title: .*', 'title: "The Pattern of Covenant Parenting — Parent Review Edition"', front)
front = re.sub(r'status: .*', 'status: parent-review-edition', front)
front = re.sub(r' - editorial-review\n', ' - parent-review\n', front)
body = body[body.index("<!-- EDITORIAL COMMENT: Chapter 0"):]
body = body.replace("Parenting is often", "## Chapter 0: Introduction\n\nParenting is often", 1)
body = "<!-- KEEP_PARENT_OPENING -->\n" + body
body = re.sub(r"^## Introduction\n\n", "", body, count=1, flags=re.M)
body = remove_opening_subheads(body)
body = relocate_workbook_sections(body)
body = add_images(body)
body = REFERENCE.sub(mark_nkjv, body)
body = re.sub(r"\s+\(NKJV\)\s+\(NKJV\)", " (NKJV)", body)
OUTPUT.write_text("---\n" + front.strip() + "\n---\n\n" + body.lstrip(), encoding="utf-8")
print(OUTPUT)
if __name__ == "__main__":
main()
+470
View File
@@ -0,0 +1,470 @@
#!/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 = "<!-- KEEP_PARENT_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"<!--\s*EDITORIAL COMMENT:\s*(.*?)\s*-->",
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'<a href="{html.escape(m.group(2), quote=True)}">{html.escape(m.group(1))}</a>'),
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"<code>\1</code>", value)
value = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", value)
value = re.sub(r"__([^_]+)__", r"<strong>\1</strong>", value)
value = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", value)
value = re.sub(r"(?<!_)_([^_]+)_(?!_)", r"<em>\1</em>", value)
value = value.replace(" ", "<br>")
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 = ["<table><thead><tr>"]
for cell in rows[0]:
out.append(f"<th>{inline_markdown(cell)}</th>")
out.append("</tr></thead><tbody>")
for row in rows[1:]:
out.append("<tr>" + "".join(f"<td>{inline_markdown(c)}</td>" for c in row) + "</tr>")
out.append("</tbody></table>")
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"<pre><code>{html.escape(chr(10).join(code_lines))}</code></pre>")
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'<aside class="editorial-comment"><b>Editorial comment</b><br>{inline_markdown(note)}</aside>')
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'<figure class="content-figure"><img src="{image_path.as_uri()}" alt="{html.escape(alt, quote=True)}">'
f'<figcaption>{inline_markdown(alt)}</figcaption></figure>'
)
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'<div class="chapter-start" id="{anchor}"></div>')
chunks.append(f'<h{level} id="{anchor}">{inline_markdown(title)}</h{level}>')
# The Contents link is retained without a distracting per-chapter outline.
if level == 2:
chunks.append('<div class="chapter-back"><a href="#contents">Back to Contents</a></div>')
i += 1
next(heading_iter, None)
continue
if re.match(r"^[-*_]{3,}\s*$", line):
chunks.append("<hr>")
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'<blockquote>{inline_markdown(chr(10).join(q))}</blockquote>')
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("<ul>" + "".join(f"<li>{inline_markdown(x)}</li>" for x in items) + "</ul>")
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("<ol>" + "".join(f"<li>{inline_markdown(x)}</li>" for x in items) + "</ol>")
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"<p>{inline_markdown(chr(10).join(para))}</p>")
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 = ['<nav class="toc-list">']
for level, text, anchor in headings:
if level == 2:
toc.append(f'<li><a href="#{anchor}">{inline_markdown(text)}</a></li>')
elif level == 3 and mode == "editorial":
# Editorial PDFs get the detailed outline; clean PDFs stay easy to scan.
toc.append(f'<li class="toc-level-3"><a href="#{anchor}">{inline_markdown(text)}</a></li>')
toc.append("</nav>")
html_doc = f'''<!doctype html>
<html><head><meta charset="utf-8"><title>{html.escape(title)}</title><style>{CSS}</style></head>
<body>
<section class="cover" id="cover"><h1>{html.escape(title)}</h1><div class="rule"></div><div class="subtitle">{html.escape(subtitle)}</div><div class="mode">Bryan Hickman teaching synthesis</div></section>
<section class="contents" id="contents"><h1>Contents</h1><p class="contents-note">Use the links below to move through the book. A Back to Contents link appears at the beginning of each chapter.</p>{''.join(toc)}</section>
<main>{body_html}</main>
</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()
+20
View File
@@ -0,0 +1,20 @@
import importlib.util
from pathlib import Path
ROOT = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("render_parent_book", ROOT / "render_parent_book.py")
render_parent_book = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(render_parent_book)
def test_parent_review_contract_preserves_chapter_anchors_and_rejects_internal_subtitles():
valid = "## Chapter 0: A Beginning\n\nText\n\n## Chapter 1: The Pattern\n\nText\n\n### Reflection and Action\n\nWork"
render_parent_book.validate_parent_review_contract(valid)
def test_parent_review_contract_rejects_missing_chapter_anchor():
import pytest
with pytest.raises(ValueError, match="Chapter 1"):
render_parent_book.validate_parent_review_contract("## Chapter 0: A Beginning\n")