Files
balam/.claude/skills/proposal-pdf/scripts/build_pdf.py
T
JohannVelazquez 72b7602e8e Bitácora 8–30 jun: contrato firmado, plan de actividades y skill proposal-pdf
- Comunicaciones (REGISTRO #15–#21): respuesta v1.1 (10-jun), luz verde (16-jun),
  contrato (envío 25-jun, ajustes + firma 26-jun), arranque con Erika y kickoff 1-jul
- Evidencia en fuentes/ (correos 8/10/16-jun, WhatsApp Paola y Erika)
- Contratos (sin firmar y firmado) en propuesta/
- Plan de actividades Etapa 0–3 + guion del kickoff (planeacion/)
- Skill proposal-pdf instalado (.claude/skills) + Propuesta-Balam.pdf regenerada
- README/PENDIENTES al día; limpieza de archivos sueltos; .gitignore (locks de Office)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:42:45 -06:00

460 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
build_pdf.py — print-optimized PDF builder for editorial proposals/reports.
Pipeline (the reliable part — don't reinvent it):
1. Render the authored HTML to PDF with headless Chromium (Playwright),
letting CSS @page rules control the page geometry.
2. Resolve the table-of-contents page numbers in a SECOND pass: render once
with the {{PG_*}} tokens blanked, find the real start page of each section
by searching the rendered PDF for a unique body phrase, substitute the
numbers, then re-render.
3. Stamp a running footer (hairline + confidential line + page number) on
every page except the cover, using a registered TTF.
4. Write PDF metadata (title / author / subject).
Why two passes instead of computing pages from the DOM: with CSS paged media,
the mapping from DOM position to printed page is not linear (page margins and
break-before rules eat space). Searching the actually-rendered PDF sidesteps
all of it and is rock-solid.
Usage:
python3 build_pdf.py [config.json] # build
python3 build_pdf.py [config.json] --check # validate config + env only
If no config path is given it looks for ./pdf.config.json.
See pdf.config.example.json for the full schema.
"""
import sys, os, re, json, io, tempfile, pathlib
# Windows consoles default to cp1252 and choke on the ✓/⚠/✗ status glyphs this
# script prints. Force UTF-8 on stdout/stderr so progress output never crashes.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except Exception:
pass
# --- friendly dependency check -------------------------------------------- #
_MISSING = []
try:
from playwright.sync_api import sync_playwright
except Exception:
_MISSING.append("playwright")
try:
import pdfplumber
except Exception:
_MISSING.append("pdfplumber")
try:
import pypdf
except Exception:
_MISSING.append("pypdf")
try:
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
from reportlab.lib.colors import Color
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont as RLTTFont
except Exception:
_MISSING.append("reportlab")
if _MISSING:
sys.exit(
"Missing Python packages: " + ", ".join(_MISSING) + "\n"
"Install with:\n"
" pip install playwright pdfplumber pypdf reportlab\n"
" python -m playwright install chromium"
)
# --------------------------------------------------------------------------- #
# Page geometry
# --------------------------------------------------------------------------- #
# Named sizes in points (1pt = 1/72in). Used for the footer overlay canvas so
# it matches whatever Chromium printed.
PAGE_SIZES_PT = {
"letter": (612.0, 792.0),
"legal": (612.0, 1008.0),
"a4": (595.28, 841.89),
"a3": (841.89, 1190.55),
}
def resolve_page_size(page_size):
"""Return (playwright_format_or_None, width_pt, height_pt, width_mm, height_mm).
page_size may be a string ("Letter"/"A4"/...) or a dict
{"width_mm": .., "height_mm": ..} for a custom size.
"""
if isinstance(page_size, dict):
wmm = float(page_size["width_mm"]); hmm = float(page_size["height_mm"])
return None, wmm * 72 / 25.4, hmm * 72 / 25.4, wmm, hmm
key = str(page_size).strip().lower()
if key not in PAGE_SIZES_PT:
sys.exit(f"Unknown page_size {page_size!r}. Use one of "
f"{sorted(PAGE_SIZES_PT)} or a {{width_mm,height_mm}} object.")
wpt, hpt = PAGE_SIZES_PT[key]
return key.capitalize(), wpt, hpt, wpt * 25.4 / 72, hpt * 25.4 / 72
# --------------------------------------------------------------------------- #
# Config
# --------------------------------------------------------------------------- #
def load_config(path):
p = pathlib.Path(path)
if not p.exists():
sys.exit(f"Config not found: {path}\nCopy pdf.config.example.json and edit it.")
try:
cfg = json.loads(p.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
sys.exit(f"Config is not valid JSON ({path}): {e}")
cfg.setdefault("metadata", {})
cfg.setdefault("toc", {})
cfg.setdefault("footer", {})
f = cfg["footer"]
f.setdefault("skip_first_page", True)
f.setdefault("skip_pages", [])
f.setdefault("rule", True)
f.setdefault("font_size_pt", 7.5)
f.setdefault("margin_mm", 12) # distance from the bottom edge
f.setdefault("color", [0.46, 0.51, 0.57])
f.setdefault("page_number_format", "{page}")
cfg.setdefault("fonts", {})
cfg.setdefault("cover_full_bleed", True)
cfg.setdefault("page_size", "Letter")
cfg.setdefault("render_timeout_ms", 30000)
base = p.resolve().parent
for key in ("input_html", "output_pdf"):
if key in cfg and not os.path.isabs(cfg[key]):
cfg[key] = str(base / cfg[key])
ttf = cfg["fonts"].get("footer_ttf")
if ttf and not os.path.isabs(ttf):
cand = base / ttf
# only rewrite to a config-relative path if that file actually exists;
# otherwise leave the original (likely an absolute system font path)
if cand.exists():
cfg["fonts"]["footer_ttf"] = str(cand)
return cfg
# --------------------------------------------------------------------------- #
# Token / anchor helpers
# --------------------------------------------------------------------------- #
TOKEN_RE = re.compile(r"\{\{PG_([^}]+)\}\}")
def _norm(s):
return re.sub(r"\s+", " ", s or "")
def strip_comments(html):
# HTML comments never belong in the rendered PDF, and any {{PG_*}} examples
# inside them must not be treated as real tokens.
return re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
def tokens_in_html(html):
return set(TOKEN_RE.findall(html))
def blank_tokens(html):
return TOKEN_RE.sub("", html)
def fill_tokens(html, pages):
def repl(m):
key = m.group(1)
if key not in pages:
sys.exit(f"Token {{{{PG_{key}}}}} has no matching entry in config 'toc'.")
return str(pages[key])
return TOKEN_RE.sub(repl, html)
# --------------------------------------------------------------------------- #
# Preflight validation
# --------------------------------------------------------------------------- #
def chromium_ok():
try:
with sync_playwright() as p:
b = p.chromium.launch()
b.close()
return True, ""
except Exception as e:
return False, str(e).splitlines()[0]
def preflight(cfg, html):
"""Validate config + environment. Returns list of warning strings; exits on
hard errors."""
problems, warnings = [], []
# input html
if not os.path.exists(cfg.get("input_html", "")):
problems.append(f"input_html not found: {cfg.get('input_html')!r}")
# token <-> toc consistency
toks = tokens_in_html(html)
toc_keys = {k for k in cfg["toc"] if not k.startswith("_")}
missing_cfg = toks - toc_keys # token in HTML, no anchor in config
unused_cfg = toc_keys - toks # anchor in config, no token in HTML
if missing_cfg:
problems.append("TOC tokens in the HTML with no anchor in config 'toc': "
+ ", ".join(sorted("{{PG_%s}}" % k for k in missing_cfg)))
if unused_cfg:
warnings.append("config 'toc' keys with no matching {{PG_*}} token in the HTML: "
+ ", ".join(sorted(unused_cfg)))
# footer font
ttf = cfg["fonts"].get("footer_ttf")
if ttf and not os.path.exists(ttf):
warnings.append(f"footer_ttf not found ({ttf}); falling back to Helvetica.")
# output dir
out = cfg.get("output_pdf")
if out:
os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
# chromium
ok, err = chromium_ok()
if not ok:
problems.append("Chromium failed to launch: " + err
+ "\n Run: python -m playwright install chromium")
if problems:
sys.exit("Preflight failed:\n" + "\n".join("" + p for p in problems))
return warnings
# --------------------------------------------------------------------------- #
# 1. Render
# --------------------------------------------------------------------------- #
def render(html_text, out_pdf, pw_format, width_pt, height_pt, timeout_ms, base_dir=None):
# Write the temp HTML in the SAME directory as the source document so that
# relative resources in the HTML — @font-face url("fonts/..."), <img src>,
# CSS background images — resolve against the document folder. (Rendering
# from the system temp dir would break every relative path.)
with tempfile.NamedTemporaryFile("w", prefix=".pdfbuild_", suffix=".html",
delete=False, encoding="utf-8", dir=base_dir) as fh:
fh.write(html_text)
tmp_path = fh.name
try:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_default_timeout(timeout_ms)
uri = pathlib.Path(tmp_path).resolve().as_uri()
# networkidle is ideal but can hang on a stuck resource; fall back to
# 'load' so a self-contained document always renders.
try:
page.goto(uri, wait_until="networkidle", timeout=timeout_ms)
except Exception:
page.goto(uri, wait_until="load", timeout=timeout_ms)
page.emulate_media(media="print")
pdf_kwargs = dict(
path=out_pdf,
print_background=True,
# margin 0 here so the CSS @page rules are the single source of
# truth — including @page:first{margin:0} for a full-bleed cover.
margin={"top": "0", "right": "0", "bottom": "0", "left": "0"},
prefer_css_page_size=True,
)
if pw_format:
pdf_kwargs["format"] = pw_format
else: # custom size
pdf_kwargs["width"] = f"{width_pt}pt"
pdf_kwargs["height"] = f"{height_pt}pt"
page.pdf(**pdf_kwargs)
browser.close()
finally:
os.unlink(tmp_path)
# --------------------------------------------------------------------------- #
# 2. TOC page-number resolution
# --------------------------------------------------------------------------- #
def detect_pages(pdf_path, anchors):
"""Map each TOC key to the 1-based page where its anchor phrase first appears."""
with pdfplumber.open(pdf_path) as pdf:
page_texts = [_norm(pg.extract_text() or "") for pg in pdf.pages]
pages, missing, ambiguous = {}, [], []
for key, phrase in anchors.items():
if key.startswith("_"):
continue
target = _norm(phrase)
hits = [i + 1 for i, txt in enumerate(page_texts) if target in txt]
if not hits:
missing.append((key, phrase))
else:
pages[key] = hits[0]
if len(hits) > 1:
ambiguous.append((key, hits))
if missing:
lines = "\n".join(f" - {k!r}: {p!r}" for k, p in missing)
sys.exit(
"Could not locate these TOC anchors in the rendered PDF.\n"
"Use a UNIQUE phrase from the section BODY (not its title), and avoid\n"
"the first letter of a drop-cap paragraph:\n" + lines
)
for k, hits in ambiguous:
print(f" ! anchor {k!r} appears on pages {hits}; using the first ({hits[0]}). "
"Use a more specific phrase if that's wrong.")
return pages
# --------------------------------------------------------------------------- #
# 3. Footer stamping + 4. Metadata
# --------------------------------------------------------------------------- #
def register_footer_font(ttf_path):
if ttf_path and os.path.exists(ttf_path):
try:
pdfmetrics.registerFont(RLTTFont("FooterFont", ttf_path))
return "FooterFont"
except Exception:
pass
return "Helvetica"
def make_overlay(page_number, total_pages, fcfg, font_name, width_pt, height_pt):
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=(width_pt, height_pt))
left = 16 * mm
right_x = width_pt - 16 * mm
y_text = fcfg["margin_mm"] * mm
y_rule = y_text + 3.2 * mm
col = Color(*fcfg["color"])
if fcfg.get("rule", True):
c.setStrokeColor(Color(0.80, 0.83, 0.86))
c.setLineWidth(0.5)
c.line(left, y_rule, right_x, y_rule)
c.setFillColor(col)
c.setFont(font_name, float(fcfg["font_size_pt"]))
text = fcfg.get("text", "")
if text:
c.drawString(left, y_text, text)
num = fcfg.get("page_number_format", "{page}").format(page=page_number, pages=total_pages)
if num:
c.drawRightString(right_x, y_text, num)
c.showPage()
c.save()
buf.seek(0)
return pypdf.PdfReader(buf).pages[0]
def stamp_and_finalize(src_pdf, out_pdf, fcfg, font_name, metadata, width_pt, height_pt):
reader = pypdf.PdfReader(src_pdf)
writer = pypdf.PdfWriter()
total = len(reader.pages)
skip_first = fcfg.get("skip_first_page", True)
skip_pages = set(fcfg.get("skip_pages", []))
for idx, page in enumerate(reader.pages):
page_no = idx + 1
skip = (page_no in skip_pages) or (idx == 0 and skip_first)
if not skip:
page.merge_page(make_overlay(page_no, total, fcfg, font_name, width_pt, height_pt))
writer.add_page(page)
meta = {}
if metadata.get("title"): meta["/Title"] = metadata["title"]
if metadata.get("author"): meta["/Author"] = metadata["author"]
if metadata.get("subject"): meta["/Subject"] = metadata["subject"]
if metadata.get("author"): meta["/Creator"] = metadata["author"]
if metadata.get("keywords"): meta["/Keywords"] = metadata["keywords"]
if meta:
writer.add_metadata(meta)
with open(out_pdf, "wb") as fh:
writer.write(fh)
# --------------------------------------------------------------------------- #
# Post-build: warn if the design fonts didn't make it into the PDF
# --------------------------------------------------------------------------- #
# The design system is built on Caladea (serif display) + Carlito (sans body).
# If those aren't embedded, Chromium silently fell back to system fonts
# (Cambria/Calibri/Georgia/…) — it *looks close* but isn't the intended result,
# exactly the kind of regression that ships unnoticed. Flag it loudly.
DESIGN_FONTS = ("Caladea", "Carlito")
def report_embedded_fonts(pdf_path):
try:
reader = pypdf.PdfReader(pdf_path)
names = set()
for pg in reader.pages:
res = pg.get("/Resources")
fonts = res.get("/Font") if res else None
if not fonts:
continue
fobj = fonts.get_object()
for fk in fobj:
bf = fobj[fk].get_object().get("/BaseFont")
if bf:
names.add(str(bf).lstrip("/").split("+")[-1]) # drop subset prefix
except Exception:
return # a reporting step must never fail the build
present = sorted(names)
print(" fonts embedded:", ", ".join(present) if present else "(none)")
missing = [f for f in DESIGN_FONTS if not any(f in n for n in names)]
if missing:
print(" ⚠ design font(s) missing from the PDF: " + ", ".join(missing) + ".")
print(" Chromium fell back to system fonts, so the result looks")
print(" 'close but not identical' to the intended design. Ensure the")
print(" .ttf files sit next to the HTML (./fonts/) and the @font-face")
print(" url() paths resolve. See SKILL.md Fonts.")
# --------------------------------------------------------------------------- #
# Orchestration
# --------------------------------------------------------------------------- #
def main():
args = [a for a in sys.argv[1:]]
check_only = "--check" in args
args = [a for a in args if a != "--check"]
cfg_path = args[0] if args else "pdf.config.json"
cfg = load_config(cfg_path)
raw_html = pathlib.Path(cfg["input_html"]).read_text(encoding="utf-8") \
if os.path.exists(cfg["input_html"]) else ""
src_html = strip_comments(raw_html)
warnings = preflight(cfg, src_html)
for w in warnings:
print("" + w)
if check_only:
print("Preflight OK." + (" (with warnings)" if warnings else ""))
return
pw_format, width_pt, height_pt, _, _ = resolve_page_size(cfg["page_size"])
timeout_ms = int(cfg["render_timeout_ms"])
# render relative resources (fonts/images) against the document's folder
base_dir = os.path.dirname(os.path.abspath(cfg["input_html"])) or None
with tempfile.TemporaryDirectory() as td:
v1 = os.path.join(td, "v1.pdf")
v2 = os.path.join(td, "v2.pdf")
if any(not k.startswith("_") for k in cfg["toc"]):
print("Pass 1/2: resolving TOC page numbers…")
render(blank_tokens(src_html), v1, pw_format, width_pt, height_pt, timeout_ms, base_dir)
pages = detect_pages(v1, cfg["toc"])
print(" resolved:", pages)
html_final = fill_tokens(src_html, pages)
else:
html_final = blank_tokens(src_html)
print("Pass 2/2: rendering final document…")
render(html_final, v2, pw_format, width_pt, height_pt, timeout_ms, base_dir)
font_name = register_footer_font(cfg["fonts"].get("footer_ttf"))
stamp_and_finalize(v2, cfg["output_pdf"], cfg["footer"], font_name,
cfg["metadata"], width_pt, height_pt)
n = len(pypdf.PdfReader(cfg["output_pdf"]).pages)
print(f"✓ Wrote {cfg['output_pdf']} ({n} pages, {cfg['page_size']})")
report_embedded_fonts(cfg["output_pdf"])
if __name__ == "__main__":
main()