pdf-accessibility/report_generator.py
Vadym Samoilenko 112719b2c5 Add Docker stack, frontend redesign, and visual page inspector fix
- 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>
2026-02-25 18:12:44 +00:00

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": "&#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 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()