- Redesigned frontend with Outfit/Figtree typography, coral accent palette, noise texture, glassmorphism header, and staggered animations - Split monolithic index.html into modular JS (app, api, upload, batch, results, page-viewer, utils) and extracted CSS - Fixed worker.py to generate page images for Visual Page Inspector - Added Docker Compose stack (web, worker, redis, postgres) - Added batch upload, HTML report export, rate limiting, and Redis queue - Extended test suite with checker, remediation, worker, and DB tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
254 lines
9.3 KiB
Python
254 lines
9.3 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 main():
|
|
parser = argparse.ArgumentParser(description="Generate HTML accessibility report")
|
|
parser.add_argument("--input", "-i", required=True, help="Input JSON results file")
|
|
parser.add_argument("--output", "-o", help="Output HTML file (default: stdout)")
|
|
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)
|
|
|
|
html = generate_html(data)
|
|
|
|
if args.output:
|
|
with open(args.output, "w") as f:
|
|
f.write(html)
|
|
print(f"Report saved to {args.output}", file=sys.stderr)
|
|
else:
|
|
print(html)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|