pdf-accessibility/report_generator.py
Vadym Samoilenko 97641ba56c Fix PDF report: prevent table rows splitting across pages, allow sections to flow
- 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>
2026-03-12 19:09:11 +00:00

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": "&#x1F6A8;",
"ERROR": "&#x274C;",
"WARNING": "&#x26A0;&#xFE0F;",
"INFO": "&#x2139;&#xFE0F;",
"SUCCESS": "&#x2705;",
}.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} &mdash; {total_pages} page{"s" if total_pages != 1 else ""} &mdash; 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 &amp; 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 &mdash; 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} &nbsp;·&nbsp; {total_pages} pages &nbsp;·&nbsp; Generated {now}</div>
</div>
<div style="text-align:right;font-size:9pt;color:#ccc;">
WCAG 2.1 &nbsp;·&nbsp; 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 &nbsp; {sc.get('error',0)} errors &nbsp; {sc.get('warning',0)} warnings &nbsp; {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 &nbsp;·&nbsp; Enterprise Edition &nbsp;·&nbsp; Oliver Solutions &nbsp;·&nbsp; {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()