#!/usr/bin/env python3 """Build TRD.pdf from TRD.md with print-quality layout (Chromium headless).""" from __future__ import annotations import re import shutil import subprocess import sys import tempfile from pathlib import Path import markdown ROOT = Path(__file__).resolve().parent MD_PATH = ROOT / "TRD.md" PDF_PATH = ROOT / "TRD.pdf" BOX_CHARS = set("┌┐└┘│─┬┴├┤┼▼↑↓←→╭╮╯╰═║╔╗╚╝╠╣╦╩╬") PRINT_CSS = """ @page { size: A4; margin: 18mm 14mm 20mm 14mm; } * { box-sizing: border-box; } html { font-size: 10.5pt; } body { font-family: "Noto Sans", "Liberation Sans", Arial, sans-serif; color: #1e293b; line-height: 1.55; margin: 0; padding: 0; } h1 { font-size: 1.65rem; color: #312e81; border-bottom: 2px solid #6366f1; padding-bottom: 0.35rem; margin: 1.6rem 0 0.9rem; page-break-after: avoid; } h2 { font-size: 1.25rem; color: #1e3a5f; margin: 1.35rem 0 0.65rem; page-break-after: avoid; } h3 { font-size: 1.05rem; color: #334155; margin: 1.1rem 0 0.5rem; page-break-after: avoid; } h4, h5, h6 { font-size: 0.95rem; color: #475569; margin: 0.9rem 0 0.4rem; page-break-after: avoid; } p { margin: 0.45rem 0 0.65rem; text-align: left; line-height: 1.55; } strong { color: #0f172a; } blockquote { margin: 0.6rem 0; padding: 0.55rem 0.85rem; border-left: 3px solid #6366f1; background: #f8fafc; color: #334155; } table { width: 100%; border-collapse: collapse; margin: 0.75rem 0 1rem; font-size: 9pt; table-layout: auto; page-break-inside: auto; } thead { display: table-header-group; } th, td { border: 1px solid #cbd5e1; padding: 6px 8px; vertical-align: top; text-align: left; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; hyphens: manual; white-space: normal; } th { background: #f1f5f9; font-weight: 600; color: #0f172a; white-space: nowrap; } tr:nth-child(even) td { background: #fafafa; } pre { margin: 0.65rem 0 0.85rem; padding: 0; background: transparent; border: none; page-break-inside: avoid; } pre code, code { font-family: "CaskaydiaMono NFM", "DejaVu Sans Mono", "Liberation Mono", monospace; } pre code { display: block; white-space: pre; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 4px; padding: 8px 10px; line-height: 1.25; } pre.diagram code { background: #f1f5f9; border-color: #cbd5e1; padding: 10px 8px; } pre.code code { font-size: 8.5pt; white-space: pre-wrap; word-break: break-word; } p > code, li > code, td > code { font-size: 0.88em; background: #f1f5f9; padding: 1px 4px; border-radius: 3px; white-space: nowrap; } ul, ol { margin: 0.4rem 0 0.7rem 1.2rem; padding: 0; } li { margin: 0.2rem 0; } hr { border: none; border-top: 1px solid #e2e8f0; margin: 1.2rem 0; } """ def _is_diagram(text: str) -> bool: return any(ch in BOX_CHARS for ch in text) def _is_code(lang: str | None, text: str) -> bool: if lang in {"python", "json", "bash", "shell", "sql"}: return True stripped = text.strip() if stripped.startswith("{") or stripped.startswith("def "): return True return bool(re.search(r"\b(import |return |for |if |elif |else:)\b", text)) def _escape_html(text: str) -> str: return text.replace("&", "&").replace("<", "<").replace(">", ">") def _inline_format(text: str) -> str: text = _escape_html(text) text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) text = re.sub(r"`([^`]+)`", r"\1", text) return text def _split_table_row(cell_text: str, ncol: int, header_cols: list[str] | None = None) -> list[str]: """Split legacy single-cell row text into ncol columns.""" text = cell_text.strip() if not text: return [""] * ncol if "|" in text and text.count("|") >= ncol - 1: parts = [p.strip() for p in text.split("|")] while len(parts) < ncol: parts.append("") return parts[:ncol] # Input field rows (4 columns) if ncol == 4 and (header_cols and header_cols[0] == "Input Field"): m = re.match( r"^(.+?)\s+(Numeric|Categorical)\s+(Yes|No|Optional)\s+(.+)$", text, re.I, ) if m: return list(m.groups()) # Output field rows (2 columns) if ncol == 2 and (header_cols and header_cols[0] == "Output Field"): m = re.match(r"^(.+?)\s+(Historical/?\s*Formula output|Predicted|reference.*)$", text, re.I) if m: return [m.group(1).strip(), m.group(2).strip()] m = re.match(r"^(.+?)\s+(.+)$", text) if m: return [m.group(1).strip(), m.group(2).strip()] # API endpoint rows if ncol == 3 and (header_cols and header_cols[0] == "Endpoint"): m = re.match(r"^(/api/\S+)\s+(GET|POST)\s+(.+)$", text, re.I) if m: return list(m.groups()) # Finding rows (3 columns) if ncol == 3 and (header_cols and header_cols[0] == "Finding"): m = re.match(r"^(.+?)\s+([\d,.]+|~[\d.]+%?|Very strong.*)\s+(High|Medium|Low|Very High)$", text, re.I) if m: return [m.group(1), m.group(2), m.group(3)] # Term glossary (2 columns) — split on first long gap or first capitalized phrase end if ncol == 2 and (header_cols and header_cols[0] == "Term"): m = re.match(r"^(.+?)\s+(.+)$", text) if m: return [m.group(1).strip(), m.group(2).strip()] # Weave shrinkage (4 columns) if ncol == 4 and (header_cols and header_cols[0] == "Weave Key"): m = re.match(r"^(\S+(?:\s+\S+)?)\s+([\d.]+)\s+([\d.]+)\.?$", text) if m: return [m.group(1), m.group(2), m.group(3), ""] parts = [p.strip() for p in re.split(r"\s{2,}", text) if p.strip()] if len(parts) == ncol: return parts if len(parts) > ncol: return parts[: ncol - 1] + [" ".join(parts[ncol - 1 :])] if ncol == 3: m = re.match(r"^(.+?)\s+(.+?)\s+(.+)$", text) if m: return [m.group(1), m.group(2), m.group(3)] if ncol == 2: m = re.match(r"^(.+?)\s+(.+)$", text) if m: return [m.group(1), m.group(2)] while len(parts) < ncol: parts.append("") return parts[:ncol] if parts else [text] + [""] * (ncol - 1) KNOWN_TABLE_HEADERS: dict[str, list[str]] = { "input field type required notes": ["Input Field", "Type", "Required", "Notes"], "output field source": ["Output Field", "Source"], "finding observed value confidence": ["Finding", "Observed Value", "Confidence"], "endpoint method purpose": ["Endpoint", "Method", "Purpose"], "capability description": ["Capability", "Description"], "kpi meaning": ["KPI", "Meaning"], "metric value": ["Metric", "Value"], "insight observed": ["Insight", "Observed"], "term definition": ["Term", "Definition"], "weave key warp shrinkage weft shrinkage": [ "Weave Key", "Warp Shrinkage", "Weft Shrinkage", ], "endpoint input state output state": ["Endpoint", "Input State", "Output State"], "requirement class meaning change tolerance": [ "Requirement Class", "Meaning", "Change Tolerance", ], "ad hoc theme proposed direction": ["Ad Hoc Theme", "Proposed Direction"], "phase intelligence mode status": ["Phase", "Intelligence Mode", "Status"], "milestone scope exit criteria": ["Milestone", "Scope", "Exit Criteria"], "component requirement": ["Component", "Requirement"], "item required": ["Item", "Required"], "item status requirement": ["Item", "Status", "Requirement"], "layer role": ["Layer", "Role"], "function purpose": ["Function", "Purpose"], "signal source": ["Signal", "Source"], "step ux behavior": ["Step", "UX Behavior"], "role responsibility": ["Role", "Responsibility"], "stage focus": ["Stage", "Focus"], "pattern class used today": ["Pattern Class", "Used Today"], "governance control requirement": ["Governance Control", "Requirement"], "validation dimension intent": ["Validation Dimension", "Intent"], "condition decision aid": ["Condition", "Decision Aid"], "metric formula": ["Metric", "Formula"], "metric measurement method": ["Metric", "Measurement Method"], "dimension dimension why it matterswhy it matters": [ "Dimension", "Why It Matters", ], "approach type explainability deployment risk": [ "Approach Type", "Explainability", "Deployment Risk", ], "component minimum coverage goal": ["Component", "Minimum Coverage Goal"], "case idscenario expected result": ["Case ID", "Scenario", "Expected Result"], "requirement idrequirement verification method": [ "Requirement ID", "Requirement", "Verification Method", ], "risk idrisk impact mitigation": ["Risk ID", "Risk", "Impact", "Mitigation"], "ai risk planned mitigation": ["AI Risk", "Planned Mitigation"], "alert threshold": ["Alert", "Threshold"], "scenario historical track formula track expected behavior": [ "Scenario", "Historical Track", "Formula Track", "Expected Behavior", ], "confidence confidencereview modereview mode": [ "Confidence", "Review Mode", ], "check idcheck idcheck description check description severityseverity": [ "Check ID", "Check Description", "Severity", ], "business": ["Business Field", "XML Node/Attribute", "Mapping Notes"], "hypothesis id statement expected benefit": ["Hypothesis ID", "Statement", "Expected Benefit"], } def _norm_header_key(text: str) -> str: return re.sub(r"\s+", " ", text.strip().lower()) def _parse_header_columns(header_line: str) -> list[str]: text = re.sub(r"^\|\s*", "", header_line) text = re.sub(r"\s*\|$", "", text).strip() key = _norm_header_key(text) if key in KNOWN_TABLE_HEADERS: return KNOWN_TABLE_HEADERS[key] cols = [c.strip() for c in re.split(r"\s{2,}", text) if c.strip()] if len(cols) >= 2: return cols # Common 3-word headers: "Endpoint Method Purpose" m = re.match(r"^(.+?)\s+(Method|Mode|Type)\s+(.+)$", text, re.I) if m: return [m.group(1).strip(), m.group(2).strip(), m.group(3).strip()] words = text.split() if len(words) == 2: return words if len(words) == 3: return words if len(words) == 4: return words return [text] if text else [] def _md_table_row(cells: list[str]) -> str: return "| " + " | ".join(c.replace("|", "\\|") for c in cells) + " |" def fix_legacy_details_tables(md: str) -> str: """Convert broken single-column 'Details' tables to real multi-column markdown.""" lines = md.splitlines() out: list[str] = [] i = 0 while i < len(lines): if not re.match(r"^\|\s*Details\s*\|", lines[i]): out.append(lines[i]) i += 1 continue block: list[str] = [] while i < len(lines) and lines[i].strip().startswith("|"): block.append(lines[i]) i += 1 if len(block) < 3: out.extend(block) continue header_cols = _parse_header_columns(block[2]) ncol = len(header_cols) if ncol < 2: out.extend(block) continue sep = "| " + " | ".join(["---"] * ncol) + " |" out.append(_md_table_row(header_cols)) out.append(sep) for row_line in block[3:]: cell = re.sub(r"^\|\s*", "", row_line) cell = re.sub(r"\s*\|$", "", cell).strip() if not cell: continue cells = _split_table_row(cell, ncol, header_cols) out.append(_md_table_row(cells)) out.append("") return "\n".join(out) def preprocess_markdown(raw: str) -> str: fence_re = re.compile(r"```(\w*)\n(.*?)```", re.DOTALL) def repl(match: re.Match[str]) -> str: lang = match.group(1).strip().lower() or None body = match.group(2).rstrip("\n") if not body.strip(): return "" if lang == "text" or _is_diagram(body): size = _diagram_font_size(body) escaped = _escape_html(body) return ( f'
'
                f"{escaped}
" ) if _is_code(lang, body): escaped = _escape_html(body) return f'
{escaped}
' paragraphs = [p.strip() for p in body.split("\n\n") if p.strip()] if not paragraphs: return "" # Multi-line key: value lists (e.g. input request block) lines = [ln.strip() for ln in body.splitlines() if ln.strip()] if len(lines) > 1 and all(":" in ln for ln in lines): items = "".join(f"
  • {_inline_format(ln)}
  • " for ln in lines) return f"" return "\n".join( f"

    {_inline_format(' '.join(ln.strip() for ln in p.splitlines()))}

    " for p in paragraphs ) return fence_re.sub(repl, raw) def _diagram_font_size(text: str) -> float: lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()] if not lines: return 7.5 max_len = max(len(ln) for ln in lines) if max_len <= 72: return 8.5 if max_len <= 85: return 7.5 if max_len <= 95: return 6.8 if max_len <= 110: return 6.0 return max(5.0, 95 * 6.0 / max_len) def _fix_heading_levels(raw: str) -> str: lines = raw.splitlines() out: list[str] = [] h1_seen = False for line in lines: if line.startswith("# ") and not line.startswith("## "): if h1_seen: line = "#" + line else: h1_seen = True out.append(line) return "\n".join(out) def build_html(md_text: str) -> str: md_text = _fix_heading_levels(md_text) md_text = fix_legacy_details_tables(md_text) md_text = preprocess_markdown(md_text) body = markdown.markdown( md_text, extensions=["tables", "fenced_code", "nl2br", "sane_lists"], ) return f""" PDC Technical Requirements Document v2.2 {body} """ def find_chromium() -> str: for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable"): path = shutil.which(name) if path: return path raise RuntimeError("Chromium/Chrome not found") def print_pdf(html_path: Path, pdf_path: Path) -> None: chrome = find_chromium() cmd = [ chrome, "--headless=new", "--disable-gpu", "--no-sandbox", "--run-all-compositor-stages-before-draw", "--virtual-time-budget=20000", f"--print-to-pdf={pdf_path}", "--no-pdf-header-footer", f"file://{html_path.resolve()}", ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(result.stderr or result.stdout or "Chromium PDF export failed") def _validate_pdf_text(pdf_path: Path) -> list[str]: """Flag vertical single-char column artifacts in pdftotext output.""" txt = subprocess.run( ["pdftotext", "-layout", str(pdf_path), "-"], capture_output=True, text=True, check=True, ).stdout issues: list[str] = [] lines = txt.splitlines() for idx, line in enumerate(lines): s = line.strip() if len(s) == 1 and s.isalpha() and idx > 0: prev = lines[idx - 1].strip() nxt = lines[idx + 1].strip() if idx + 1 < len(lines) else "" if len(prev) <= 2 and len(nxt) <= 2: issues.append(f"line {idx}: vertical char run near '{prev}|{s}|{nxt}'") return issues[:20] def main() -> int: if not MD_PATH.exists(): print(f"Missing {MD_PATH}", file=sys.stderr) return 1 raw = MD_PATH.read_text(encoding="utf-8") html = build_html(raw) with tempfile.TemporaryDirectory() as tmp: html_path = Path(tmp) / "TRD.html" html_path.write_text(html, encoding="utf-8") print_pdf(html_path, PDF_PATH) issues = _validate_pdf_text(PDF_PATH) if issues: print("WARN: possible layout issues:", file=sys.stderr) for issue in issues[:5]: print(f" {issue}", file=sys.stderr) print(f"Wrote {PDF_PATH} ({PDF_PATH.stat().st_size // 1024} KB)") return 0 if __name__ == "__main__": raise SystemExit(main())