- tr { page-break-inside: avoid } stops issue rows from breaking mid-row
- Remove page-break-inside: avoid from .section (was causing blank half-pages
when Matterhorn table spilled just past a page boundary)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
432 lines
18 KiB
Python
432 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
HTML Report Generator — converts JSON accessibility results to standalone HTML.
|
|
|
|
Usage:
|
|
python report_generator.py --input results.json --output report.html
|
|
python report_generator.py --input results.json # prints to stdout
|
|
"""
|
|
|
|
import json
|
|
import argparse
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
|
|
def severity_color(severity: str) -> str:
|
|
return {
|
|
"CRITICAL": "#dc2626",
|
|
"ERROR": "#ef4444",
|
|
"WARNING": "#f59e0b",
|
|
"INFO": "#3b82f6",
|
|
"SUCCESS": "#10b981",
|
|
}.get(severity, "#6b7280")
|
|
|
|
|
|
def severity_icon(severity: str) -> str:
|
|
return {
|
|
"CRITICAL": "🚨",
|
|
"ERROR": "❌",
|
|
"WARNING": "⚠️",
|
|
"INFO": "ℹ️",
|
|
"SUCCESS": "✅",
|
|
}.get(severity, "")
|
|
|
|
|
|
def grade_from_score(score: int) -> str:
|
|
if score >= 90:
|
|
return "A"
|
|
if score >= 80:
|
|
return "B"
|
|
if score >= 70:
|
|
return "C"
|
|
if score >= 60:
|
|
return "D"
|
|
return "F"
|
|
|
|
|
|
def generate_html(data: dict) -> str:
|
|
"""Generate a standalone HTML report from JSON results."""
|
|
|
|
score = data.get("accessibility_score", 0)
|
|
grade = grade_from_score(score)
|
|
sc = data.get("severity_counts", {})
|
|
issues = data.get("issues", [])
|
|
checks = data.get("checks_performed", [])
|
|
filename = data.get("filename", "Unknown")
|
|
total_pages = data.get("total_pages", 0)
|
|
stats = data.get("stats", {})
|
|
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
|
|
# Score ring color
|
|
if score >= 80:
|
|
ring_color = "#10b981"
|
|
elif score >= 60:
|
|
ring_color = "#f59e0b"
|
|
else:
|
|
ring_color = "#ef4444"
|
|
|
|
# Build issue rows
|
|
issue_rows = []
|
|
for i, issue in enumerate(issues, 1):
|
|
sev = issue.get("severity", "INFO")
|
|
color = severity_color(sev)
|
|
icon = severity_icon(sev)
|
|
page = issue.get("page_number", "—")
|
|
wcag = issue.get("wcag_criterion", "")
|
|
rec = issue.get("recommendation", "")
|
|
issue_rows.append(f"""
|
|
<tr>
|
|
<td style="text-align:center;">{i}</td>
|
|
<td><span style="background:{color};color:#fff;padding:2px 8px;border-radius:12px;font-size:12px;font-weight:600;">{icon} {sev}</span></td>
|
|
<td>{issue.get('category', '')}</td>
|
|
<td>{issue.get('description', '')}</td>
|
|
<td style="text-align:center;">{page}</td>
|
|
<td><code>{wcag}</code></td>
|
|
<td style="font-size:13px;color:#555;">{rec}</td>
|
|
</tr>""")
|
|
|
|
issues_html = "\n".join(issue_rows) if issue_rows else '<tr><td colspan="7" style="text-align:center;padding:30px;color:#999;">No issues found</td></tr>'
|
|
|
|
# Build checks table
|
|
check_rows = []
|
|
for ch in checks:
|
|
status = "PASS" if ch.get("passed") else "FAIL"
|
|
status_color = "#10b981" if ch.get("passed") else "#ef4444"
|
|
dur = f"{ch.get('duration', 0):.2f}s"
|
|
check_rows.append(f"""
|
|
<tr>
|
|
<td>{ch.get('name', '')}</td>
|
|
<td style="text-align:center;"><span style="color:{status_color};font-weight:700;">{status}</span></td>
|
|
<td style="text-align:right;">{dur}</td>
|
|
</tr>""")
|
|
|
|
checks_html = "\n".join(check_rows) if check_rows else ""
|
|
|
|
duration = stats.get("duration", 0)
|
|
api_calls = stats.get("api_calls", 0)
|
|
cost = stats.get("total_cost_estimate", 0)
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Accessibility Report — {filename}</title>
|
|
<style>
|
|
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
|
body {{ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:#f8fafc; color:#1e293b; line-height:1.6; }}
|
|
.container {{ max-width:1100px; margin:0 auto; padding:20px; }}
|
|
header {{ background:linear-gradient(135deg,#1e3a5f,#2563eb); color:#fff; padding:30px 0; }}
|
|
header h1 {{ font-size:24px; margin-bottom:5px; }}
|
|
header p {{ opacity:0.85; font-size:14px; }}
|
|
.card {{ background:#fff; border-radius:12px; box-shadow:0 1px 3px rgba(0,0,0,0.1); padding:25px; margin-bottom:20px; }}
|
|
.score-section {{ display:flex; align-items:center; gap:30px; flex-wrap:wrap; }}
|
|
.score-ring {{ width:120px; height:120px; border-radius:50%; border:8px solid {ring_color}; display:flex; align-items:center; justify-content:center; flex-direction:column; flex-shrink:0; }}
|
|
.score-number {{ font-size:36px; font-weight:800; color:{ring_color}; }}
|
|
.score-grade {{ font-size:14px; color:#64748b; }}
|
|
.stats-grid {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(100px,1fr)); gap:12px; flex:1; }}
|
|
.stat {{ text-align:center; padding:12px; border-radius:8px; }}
|
|
.stat-num {{ font-size:24px; font-weight:700; }}
|
|
.stat-label {{ font-size:12px; color:#64748b; }}
|
|
.stat.critical {{ background:#fef2f2; color:#dc2626; }}
|
|
.stat.error {{ background:#fef2f2; color:#ef4444; }}
|
|
.stat.warning {{ background:#fffbeb; color:#f59e0b; }}
|
|
.stat.info {{ background:#eff6ff; color:#3b82f6; }}
|
|
.stat.success {{ background:#f0fdf4; color:#10b981; }}
|
|
h2 {{ font-size:18px; margin-bottom:15px; color:#1e293b; }}
|
|
table {{ width:100%; border-collapse:collapse; font-size:14px; }}
|
|
th {{ background:#f1f5f9; text-align:left; padding:10px 12px; font-weight:600; color:#475569; border-bottom:2px solid #e2e8f0; }}
|
|
td {{ padding:10px 12px; border-bottom:1px solid #f1f5f9; vertical-align:top; }}
|
|
tr:hover {{ background:#f8fafc; }}
|
|
code {{ background:#f1f5f9; padding:2px 6px; border-radius:4px; font-size:12px; }}
|
|
.meta {{ display:flex; gap:20px; flex-wrap:wrap; font-size:13px; color:#64748b; margin-top:10px; }}
|
|
.meta span {{ display:flex; align-items:center; gap:4px; }}
|
|
footer {{ text-align:center; padding:20px; color:#94a3b8; font-size:12px; }}
|
|
@media print {{ body {{ background:#fff; }} .card {{ box-shadow:none; border:1px solid #e2e8f0; }} header {{ background:#1e3a5f !important; -webkit-print-color-adjust:exact; print-color-adjust:exact; }} }}
|
|
@media (max-width:600px) {{ .score-section {{ flex-direction:column; align-items:stretch; }} .score-ring {{ margin:0 auto; }} }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="container">
|
|
<h1>PDF Accessibility Report</h1>
|
|
<p>{filename} — {total_pages} page{"s" if total_pages != 1 else ""} — Generated {now}</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="container">
|
|
|
|
<!-- Score -->
|
|
<div class="card">
|
|
<div class="score-section">
|
|
<div class="score-ring">
|
|
<div class="score-number">{score}</div>
|
|
<div class="score-grade">Grade {grade}</div>
|
|
</div>
|
|
<div class="stats-grid">
|
|
<div class="stat critical"><div class="stat-num">{sc.get('critical',0)}</div><div class="stat-label">Critical</div></div>
|
|
<div class="stat error"><div class="stat-num">{sc.get('error',0)}</div><div class="stat-label">Errors</div></div>
|
|
<div class="stat warning"><div class="stat-num">{sc.get('warning',0)}</div><div class="stat-label">Warnings</div></div>
|
|
<div class="stat info"><div class="stat-num">{sc.get('info',0)}</div><div class="stat-label">Info</div></div>
|
|
<div class="stat success"><div class="stat-num">{sc.get('success',0)}</div><div class="stat-label">Passed</div></div>
|
|
</div>
|
|
</div>
|
|
<div class="meta">
|
|
<span>Duration: {duration:.1f}s</span>
|
|
<span>API calls: {api_calls}</span>
|
|
<span>Estimated cost: ${cost:.2f}</span>
|
|
<span>Total issues: {len(issues)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Issues -->
|
|
<div class="card">
|
|
<h2>Issues & Recommendations ({len(issues)})</h2>
|
|
<div style="overflow-x:auto;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style="width:40px;">#</th>
|
|
<th style="width:100px;">Severity</th>
|
|
<th style="width:140px;">Category</th>
|
|
<th>Description</th>
|
|
<th style="width:50px;">Page</th>
|
|
<th style="width:80px;">WCAG</th>
|
|
<th style="width:200px;">Recommendation</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{issues_html}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Checks Performed -->
|
|
{"" if not checks_html else f'''<div class="card">
|
|
<h2>Checks Performed ({len(checks)})</h2>
|
|
<table>
|
|
<thead><tr><th>Check</th><th style="text-align:center;width:80px;">Result</th><th style="text-align:right;width:80px;">Duration</th></tr></thead>
|
|
<tbody>{checks_html}</tbody>
|
|
</table>
|
|
</div>'''}
|
|
|
|
</div>
|
|
|
|
<footer>
|
|
Generated by Enterprise PDF Accessibility Checker — WCAG 2.1 Compliance Report
|
|
</footer>
|
|
|
|
</body>
|
|
</html>"""
|
|
|
|
return html
|
|
|
|
|
|
def generate_pdf(data: dict) -> bytes:
|
|
"""Generate a PAC-style PDF report using WeasyPrint."""
|
|
try:
|
|
from weasyprint import HTML, CSS
|
|
except ImportError:
|
|
raise RuntimeError("WeasyPrint not installed. Run: pip install weasyprint>=60.0")
|
|
|
|
score = data.get("accessibility_score", 0)
|
|
grade = grade_from_score(score)
|
|
sc = data.get("severity_counts", {})
|
|
issues = data.get("issues", [])
|
|
checks = data.get("checks_performed", [])
|
|
filename = data.get("filename", "Unknown")
|
|
total_pages = data.get("total_pages", 0)
|
|
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
|
|
matterhorn = data.get("matterhorn_summary", {})
|
|
breakdown = data.get("score_breakdown", {})
|
|
|
|
score_color = "#059669" if score >= 80 else "#d97706" if score >= 60 else "#dc2626"
|
|
|
|
sections_html = ""
|
|
|
|
# Matterhorn checkpoints table
|
|
if matterhorn and matterhorn.get("checkpoints"):
|
|
mh_rows = ""
|
|
for cp in matterhorn["checkpoints"]:
|
|
status = cp["status"]
|
|
if status == "PASS":
|
|
status_cell = '<td class="pass">PASS</td>'
|
|
elif status == "FAIL":
|
|
status_cell = '<td class="fail">FAIL</td>'
|
|
else:
|
|
status_cell = '<td class="not-tested">—</td>'
|
|
mh_rows += f'<tr><td>CP{cp["id"]} {cp["name"]}</td><td class="center">{cp["how"]}</td>{status_cell}</tr>'
|
|
|
|
overall = "FULFILLED" if matterhorn.get("overall_passed") else "NOT FULFILLED"
|
|
overall_cls = "pass" if matterhorn.get("overall_passed") else "fail"
|
|
sections_html += f"""
|
|
<div class="section">
|
|
<h2>Matterhorn Protocol — PDF/UA-1</h2>
|
|
<p class="banner {overall_cls}">PDF/UA-1 requirements: {overall}</p>
|
|
<table>
|
|
<thead><tr><th>Checkpoint</th><th>How</th><th>Status</th></tr></thead>
|
|
<tbody>{mh_rows}</tbody>
|
|
</table>
|
|
</div>"""
|
|
|
|
# Issues table
|
|
if issues:
|
|
issue_rows = ""
|
|
for iss in issues:
|
|
sev = iss.get("severity", "INFO")
|
|
issue_rows += f"""<tr>
|
|
<td class="{sev.lower()}">{sev}</td>
|
|
<td>{iss.get("category", "")}</td>
|
|
<td>{iss.get("page_number") or "—"}</td>
|
|
<td>{iss.get("description", "")}</td>
|
|
</tr>"""
|
|
sections_html += f"""
|
|
<div class="section">
|
|
<h2>Issues ({len(issues)})</h2>
|
|
<table>
|
|
<thead><tr><th>Severity</th><th>Category</th><th>Page</th><th>Description</th></tr></thead>
|
|
<tbody>{issue_rows}</tbody>
|
|
</table>
|
|
</div>"""
|
|
|
|
html_content = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;600;700;800&display=swap');
|
|
@page {{
|
|
size: A4;
|
|
margin: 20mm 15mm;
|
|
@bottom-center {{
|
|
content: "Page " counter(page) " of " counter(pages);
|
|
font-family: 'Montserrat', sans-serif;
|
|
font-size: 9pt;
|
|
color: #666;
|
|
}}
|
|
}}
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{ font-family: 'Montserrat', sans-serif; font-size: 10pt; color: #1a1a1a; line-height: 1.5; }}
|
|
.header {{ background: #1a1a1a; color: white; padding: 20px 24px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; }}
|
|
.header h1 {{ font-size: 16pt; font-weight: 800; letter-spacing: -0.02em; }}
|
|
.header .accent {{ color: #FFC407; }}
|
|
.header .meta {{ font-size: 9pt; opacity: 0.7; margin-top: 4px; }}
|
|
.score-block {{ display: flex; align-items: center; gap: 20px; background: #1a1a1a; color: white; padding: 16px 24px; margin-bottom: 20px; border-left: 4px solid #FFC407; }}
|
|
.score-num {{ font-size: 48pt; font-weight: 800; color: {score_color}; letter-spacing: -0.04em; line-height: 1; }}
|
|
.score-info h2 {{ font-size: 13pt; font-weight: 700; color: #FFC407; }}
|
|
.score-info p {{ font-size: 9pt; color: #ccc; margin-top: 2px; }}
|
|
.stats {{ display: flex; gap: 12px; margin-bottom: 20px; }}
|
|
.stat {{ flex: 1; padding: 12px; border-radius: 6px; text-align: center; }}
|
|
.stat.critical {{ background: #fef2f2; border: 1px solid #fecaca; }}
|
|
.stat.error {{ background: #fef2f2; border: 1px solid #fecaca; }}
|
|
.stat.warning {{ background: #fffbeb; border: 1px solid #fde68a; }}
|
|
.stat.info {{ background: #eff6ff; border: 1px solid #bfdbfe; }}
|
|
.stat .num {{ font-size: 22pt; font-weight: 800; }}
|
|
.stat .lbl {{ font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #555; }}
|
|
.stat.critical .num, .stat.error .num {{ color: #dc2626; }}
|
|
.stat.warning .num {{ color: #d97706; }}
|
|
.stat.info .num {{ color: #3b82f6; }}
|
|
.section {{ margin-bottom: 24px; }}
|
|
.section h2 + table {{ page-break-before: avoid; }}
|
|
.section h2 {{ font-size: 13pt; font-weight: 700; border-bottom: 2px solid #FFC407; padding-bottom: 6px; margin-bottom: 12px; }}
|
|
table {{ width: 100%; border-collapse: collapse; font-size: 9pt; }}
|
|
th {{ background: #f5f4f1; padding: 6px 10px; text-align: left; font-weight: 700; font-size: 8pt; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 2px solid #ddd; }}
|
|
td {{ padding: 6px 10px; border-bottom: 1px solid #eee; vertical-align: top; }}
|
|
tr {{ page-break-inside: avoid; }}
|
|
.pass {{ color: #059669; font-weight: 700; }}
|
|
.fail {{ color: #dc2626; font-weight: 700; }}
|
|
.not-tested {{ color: #999; }}
|
|
.critical {{ color: #dc2626; font-weight: 700; }}
|
|
.warning {{ color: #d97706; font-weight: 600; }}
|
|
.info {{ color: #3b82f6; }}
|
|
.center {{ text-align: center; }}
|
|
.banner {{ padding: 10px 16px; border-radius: 4px; font-weight: 700; font-size: 11pt; margin-bottom: 12px; }}
|
|
.banner.pass {{ background: #d1fae5; color: #065f46; border-left: 4px solid #059669; }}
|
|
.banner.fail {{ background: #fee2e2; color: #991b1b; border-left: 4px solid #dc2626; }}
|
|
.footer {{ margin-top: 24px; padding-top: 12px; border-top: 1px solid #ddd; font-size: 8pt; color: #999; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div>
|
|
<h1>PDF <span class="accent">Accessibility</span> Report</h1>
|
|
<div class="meta">{filename} · {total_pages} pages · Generated {now}</div>
|
|
</div>
|
|
<div style="text-align:right;font-size:9pt;color:#ccc;">
|
|
WCAG 2.1 · PDF/UA-1<br>
|
|
<span style="color:#FFC407;font-weight:700;">Oliver Solutions</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="score-block">
|
|
<div class="score-num">{score}</div>
|
|
<div class="score-info">
|
|
<h2>Accessibility Score — Grade {grade}</h2>
|
|
<p>{sc.get('critical',0)} critical {sc.get('error',0)} errors {sc.get('warning',0)} warnings {sc.get('info',0)} info</p>
|
|
{f'<p>{breakdown.get("checks_passed",0)} of {breakdown.get("checks_total",0)} checks passed</p>' if breakdown else ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats">
|
|
<div class="stat critical"><div class="num">{sc.get('critical',0)}</div><div class="lbl">Critical</div></div>
|
|
<div class="stat error"><div class="num">{sc.get('error',0)}</div><div class="lbl">Errors</div></div>
|
|
<div class="stat warning"><div class="num">{sc.get('warning',0)}</div><div class="lbl">Warnings</div></div>
|
|
<div class="stat info"><div class="num">{sc.get('info',0)}</div><div class="lbl">Info</div></div>
|
|
</div>
|
|
|
|
{sections_html}
|
|
|
|
<div class="footer">
|
|
PDF Accessibility Checker · Enterprise Edition · Oliver Solutions · {now}
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
pdf_bytes = HTML(string=html_content).write_pdf()
|
|
return pdf_bytes
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="HTML Report Generator — converts JSON accessibility results to standalone HTML."
|
|
)
|
|
parser.add_argument("--input", "-i", required=True, help="Input JSON results file")
|
|
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
|
|
parser.add_argument("--format", "-f", choices=["html", "pdf"], default="html", help="Output format: html (default) or pdf")
|
|
args = parser.parse_args()
|
|
|
|
input_path = Path(args.input)
|
|
if not input_path.exists():
|
|
print(f"Error: {input_path} not found", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
with open(input_path) as f:
|
|
data = json.load(f)
|
|
|
|
if args.format == "pdf":
|
|
pdf_bytes = generate_pdf(data)
|
|
if args.output:
|
|
output_path = Path(args.output)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_bytes(pdf_bytes)
|
|
print(f"Report saved to {args.output}", file=sys.stderr)
|
|
else:
|
|
sys.stdout.buffer.write(pdf_bytes)
|
|
else:
|
|
html = generate_html(data)
|
|
if args.output:
|
|
output_path = Path(args.output)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_text(html, encoding="utf-8")
|
|
print(f"Report saved to {args.output}", file=sys.stderr)
|
|
else:
|
|
sys.stdout.write(html)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|