brain backup 2026-09-14
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user