Spaces:
Sleeping
Sleeping
| #!/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"<strong>\1</strong>", text) | |
| text = re.sub(r"`([^`]+)`", r"<code>\1</code>", 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'<pre class="diagram" style="font-size:{size}pt">' | |
| f"<code>{escaped}</code></pre>" | |
| ) | |
| if _is_code(lang, body): | |
| escaped = _escape_html(body) | |
| return f'<pre class="code"><code>{escaped}</code></pre>' | |
| 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"<li>{_inline_format(ln)}</li>" for ln in lines) | |
| return f"<ul>{items}</ul>" | |
| return "\n".join( | |
| f"<p>{_inline_format(' '.join(ln.strip() for ln in p.splitlines()))}</p>" | |
| 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"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>PDC Technical Requirements Document v2.2</title> | |
| <style>{PRINT_CSS}</style> | |
| </head> | |
| <body> | |
| {body} | |
| </body> | |
| </html> | |
| """ | |
| 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()) | |