PDC / PDC data /build_trd_pdf.py
borndeveloper's picture
fix: repair TRD.pdf table layout and legacy Details parsing
6259fe4
Raw
History Blame Contribute Delete
16.8 kB
#!/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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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())