- Delete PHP API layer (api.php, auth.php) — replaced by FastAPI in Phase 2 - Delete MSAL/Azure AD JS files (app.js, app-history.js, api.js) - Delete GCP Cloud Build/Deploy infra (cloudbuild.yaml, deploy.sh, Dockerfiles) - Delete Oliver-specific docs (OLIVER_CUSTOMIZATION.md, DAVE_QUICK_SETUP.md, etc.) - Replace Oliver yellow #FFC407 with Aimpress indigo #6366F1 across CSS + reports - Replace Oliver Solutions footer in report_generator.py with Aimpress - Switch font from Montserrat to Inter in CSS - Replace GCS optical-pdf-images bucket with STORAGE_BUCKET env var - Rewrite README.md for Aimpress SaaS product Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
580 lines
26 KiB
Python
580 lines
26 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 = [i for i in data.get("issues", []) if not i.get("dismissed")]
|
|
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")
|
|
is_adjusted = data.get("score_breakdown", {}).get("adjusted", False)
|
|
|
|
# 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", "")
|
|
wcag_cell = f'<a href="https://www.w3.org/WAI/WCAG21/Understanding/" aria-label="WCAG criterion {wcag}"><code>{wcag}</code></a>' if wcag else '—'
|
|
issue_rows.append(f"""
|
|
<tr>
|
|
<td style="text-align:center;">{i}</td>
|
|
<td><span class="sev-badge sev-{sev}" aria-label="Severity: {sev}">{icon} {sev}</span></td>
|
|
<td>{issue.get('category', '')}</td>
|
|
<td>{issue.get('description', '')}</td>
|
|
<td style="text-align:center;">{page if page != '—' else '<span aria-label="document-wide">—</span>'}</td>
|
|
<td>{wcag_cell}</td>
|
|
<td style="font-size:13px;">{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:
|
|
if ch.get("manual"):
|
|
status = "Manual Pass"
|
|
status_color = "#d97706"
|
|
elif ch.get("passed"):
|
|
status = "PASS"
|
|
status_color = "#10b981"
|
|
else:
|
|
status = "FAIL"
|
|
status_color = "#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 ""
|
|
|
|
# WCAG compliance section
|
|
compliance = data.get('wcag_compliance', {})
|
|
if compliance:
|
|
a_pass = compliance.get('level_a', False)
|
|
aa_pass = compliance.get('level_aa', False)
|
|
a_icon = '✓' if a_pass else '✗'
|
|
aa_icon = '✓' if aa_pass else '✗'
|
|
a_color = '#059669' if a_pass else '#dc2626'
|
|
aa_color = '#059669' if aa_pass else '#dc2626'
|
|
a_bg = '#d1fae5' if a_pass else '#fee2e2'
|
|
aa_bg = '#d1fae5' if aa_pass else '#fee2e2'
|
|
a_fails = ', '.join(compliance.get('level_a_failures', []))
|
|
aa_fails = ', '.join(compliance.get('level_aa_failures', []))
|
|
compliance_html = f"""
|
|
<section class="card" aria-labelledby="compliance-heading">
|
|
<h2 id="compliance-heading">WCAG 2.1 Conformance</h2>
|
|
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
|
<div style="padding:12px 24px;border-radius:8px;background:{a_bg};border:2px solid {a_color};text-align:center;">
|
|
<div style="font-size:12px;font-weight:700;color:{a_color};">WCAG 2.1 Level A</div>
|
|
<div style="font-size:20px;font-weight:800;color:{a_color};">{a_icon} {'Pass' if a_pass else 'Fail'}</div>
|
|
</div>
|
|
<div style="padding:12px 24px;border-radius:8px;background:{aa_bg};border:2px solid {aa_color};text-align:center;">
|
|
<div style="font-size:12px;font-weight:700;color:{aa_color};">WCAG 2.1 Level AA</div>
|
|
<div style="font-size:20px;font-weight:800;color:{aa_color};">{aa_icon} {'Pass' if aa_pass else 'Fail'}</div>
|
|
</div>
|
|
</div>
|
|
{f'<p style="font-size:13px;color:#555;">Level A failing criteria: <strong>{a_fails}</strong></p>' if a_fails else ''}
|
|
{f'<p style="font-size:13px;color:#555;">Level AA failing criteria: <strong>{aa_fails}</strong></p>' if aa_fails and not a_fails else ''}
|
|
</section>"""
|
|
else:
|
|
compliance_html = ''
|
|
|
|
# Next steps section
|
|
next_steps = data.get('next_steps', [])
|
|
if next_steps:
|
|
priority_colors = {1: '#dc2626', 2: '#ef4444', 3: '#f59e0b'}
|
|
priority_labels = {1: 'Critical', 2: 'Error', 3: 'Warning'}
|
|
step_rows = ''
|
|
for i, s in enumerate(next_steps, 1):
|
|
pc = priority_colors.get(s.get('priority', 3), '#6b7280')
|
|
pl = priority_labels.get(s.get('priority', 3), '')
|
|
step_rows += f"""<tr>
|
|
<td style="text-align:center;font-weight:700;">{i}</td>
|
|
<td><span style="background:{pc};color:#fff;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:700;">{pl}</span></td>
|
|
<td>{s.get('category','')}</td>
|
|
<td>{s.get('action','')}</td>
|
|
<td><code>{s.get('wcag','')}</code></td>
|
|
</tr>"""
|
|
next_steps_html = f"""
|
|
<section class="card" aria-labelledby="nextsteps-heading">
|
|
<h2 id="nextsteps-heading">Recommended Next Steps</h2>
|
|
<table>
|
|
<caption>Prioritised accessibility remediation actions</caption>
|
|
<thead><tr>
|
|
<th scope="col" style="width:30px;">#</th>
|
|
<th scope="col" style="width:90px;">Priority</th>
|
|
<th scope="col" style="width:130px;">Category</th>
|
|
<th scope="col">Action</th>
|
|
<th scope="col" style="width:80px;">WCAG</th>
|
|
</tr></thead>
|
|
<tbody>{step_rows}</tbody>
|
|
</table>
|
|
</section>"""
|
|
else:
|
|
next_steps_html = ''
|
|
|
|
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">
|
|
<meta name="description" content="WCAG 2.1 accessibility report for {filename}">
|
|
<title>Accessibility Report — {filename}</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
|
body {{ font-family:'Montserrat',sans-serif; background:#f8fafc; color:#1e293b; line-height:1.6; }}
|
|
.skip-link {{ position:absolute; top:-100%; left:16px; background:#6366F1; color:#000; font-size:14px; font-weight:700; padding:10px 20px; border-radius:4px; text-decoration:none; z-index:9999; }}
|
|
.skip-link:focus {{ top:10px; }}
|
|
.container {{ max-width:1100px; margin:0 auto; padding:20px; }}
|
|
header {{ background:#1a1a1a; color:#fff; padding:30px 0; border-left:4px solid #6366F1; }}
|
|
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:#475569; font-weight:600; }}
|
|
.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; border:2px solid transparent; }}
|
|
.stat-num {{ font-size:24px; font-weight:700; }}
|
|
.stat-label {{ font-size:12px; font-weight:600; }}
|
|
.stat.critical {{ background:#fef2f2; color:#b91c1c; border-color:#fca5a5; }}
|
|
.stat.error {{ background:#fef2f2; color:#dc2626; border-color:#fca5a5; }}
|
|
.stat.warning {{ background:#fffbeb; color:#92400e; border-color:#fde68a; }}
|
|
.stat.info {{ background:#eff6ff; color:#1d4ed8; border-color:#bfdbfe; }}
|
|
.stat.success {{ background:#f0fdf4; color:#065f46; border-color:#a7f3d0; }}
|
|
h2 {{ font-size:18px; margin-bottom:15px; color:#1e293b; }}
|
|
h3 {{ font-size:15px; margin-bottom:10px; color:#334155; }}
|
|
table {{ width:100%; border-collapse:collapse; font-size:14px; }}
|
|
caption {{ text-align:left; font-size:14px; font-weight:600; color:#475569; padding:8px 0; }}
|
|
th {{ background:#f1f5f9; text-align:left; padding:10px 12px; font-weight:600; color:#334155; border-bottom:2px solid #cbd5e1; }}
|
|
td {{ padding:10px 12px; border-bottom:1px solid #f1f5f9; vertical-align:top; color:#1e293b; }}
|
|
tr:hover td {{ background:#f8fafc; }}
|
|
code {{ background:#f1f5f9; padding:2px 6px; border-radius:4px; font-size:12px; color:#475569; }}
|
|
.meta {{ display:flex; gap:20px; flex-wrap:wrap; font-size:13px; color:#475569; margin-top:10px; border-top:1px solid #e2e8f0; padding-top:10px; }}
|
|
.meta span {{ display:flex; align-items:center; gap:4px; }}
|
|
.sev-badge {{ display:inline-block; padding:2px 8px; border-radius:12px; font-size:12px; font-weight:700; color:#fff; }}
|
|
.sev-CRITICAL {{ background:#b91c1c; }}
|
|
.sev-ERROR {{ background:#dc2626; }}
|
|
.sev-WARNING {{ background:#d97706; color:#1a1a1a; }}
|
|
.sev-INFO {{ background:#2563eb; }}
|
|
.sev-SUCCESS {{ background:#059669; }}
|
|
footer {{ text-align:center; padding:20px; color:#64748b; font-size:12px; border-top:1px solid #e2e8f0; margin-top:10px; }}
|
|
a {{ color:#2563eb; }}
|
|
a:focus {{ outline:3px solid #2563eb; outline-offset:2px; border-radius:2px; }}
|
|
@media print {{ body {{ background:#fff; }} .card {{ box-shadow:none; border:1px solid #e2e8f0; }} header {{ background:#1a1a1a !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; }} }}
|
|
@media (prefers-reduced-motion:reduce) {{ * {{ transition:none !important; animation:none !important; }} }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
|
|
|
<header>
|
|
<div class="container">
|
|
<h1>PDF Accessibility Report</h1>
|
|
<p aria-label="Report details">{filename} — {total_pages} page{"s" if total_pages != 1 else ""} — Generated {now}</p>
|
|
</div>
|
|
</header>
|
|
|
|
<main id="main-content">
|
|
<div class="container">
|
|
|
|
<!-- Score -->
|
|
<section class="card" aria-labelledby="score-heading">
|
|
<h2 id="score-heading">Accessibility Score</h2>
|
|
<div class="score-section">
|
|
<div class="score-ring" role="img" aria-label="Score: {score} out of 100, Grade {grade}{' (Adjusted)' if is_adjusted else ''}">
|
|
<div class="score-number" aria-hidden="true">{score}</div>
|
|
<div class="score-grade" aria-hidden="true">Grade {grade}</div>
|
|
{'<div style="font-size:10px;color:#d97706;font-weight:600;margin-top:2px;">(Adjusted)</div>' if is_adjusted else ''}
|
|
</div>
|
|
<div class="stats-grid" role="group" aria-label="Issue counts by severity">
|
|
<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" aria-label="Report metadata">
|
|
<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>
|
|
</section>
|
|
|
|
{compliance_html}
|
|
|
|
{next_steps_html}
|
|
|
|
<!-- Issues -->
|
|
<section class="card" aria-labelledby="issues-heading">
|
|
<h2 id="issues-heading">Issues & Recommendations ({len(issues)})</h2>
|
|
<div style="overflow-x:auto;">
|
|
<table aria-labelledby="issues-heading">
|
|
<caption class="sr-only">Accessibility issues found in the document</caption>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col" style="width:40px;">#</th>
|
|
<th scope="col" style="width:110px;">Severity</th>
|
|
<th scope="col" style="width:140px;">Category</th>
|
|
<th scope="col">Description</th>
|
|
<th scope="col" style="width:50px;">Page</th>
|
|
<th scope="col" style="width:90px;">WCAG</th>
|
|
<th scope="col" style="width:200px;">Recommendation</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{issues_html}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Checks Performed -->
|
|
{"" if not checks_html else f'''<section class="card" aria-labelledby="checks-heading">
|
|
<h2 id="checks-heading">Checks Performed ({len(checks)})</h2>
|
|
<table aria-labelledby="checks-heading">
|
|
<caption class="sr-only">Individual WCAG check results and durations</caption>
|
|
<thead><tr><th scope="col">Check</th><th scope="col" style="text-align:center;width:80px;">Result</th><th scope="col" style="text-align:right;width:80px;">Duration</th></tr></thead>
|
|
<tbody>{checks_html}</tbody>
|
|
</table>
|
|
</section>'''}
|
|
|
|
</div>
|
|
</main>
|
|
|
|
<footer>
|
|
<div class="container">
|
|
Generated by Enterprise PDF Accessibility Checker — WCAG 2.1 Compliance Report
|
|
</div>
|
|
</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 = [i for i in data.get("issues", []) if not i.get("dismissed")]
|
|
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", {})
|
|
is_adjusted = breakdown.get("adjusted", False)
|
|
|
|
score_color = "#059669" if score >= 80 else "#d97706" if score >= 60 else "#dc2626"
|
|
|
|
sections_html = ""
|
|
|
|
# Build accessible Matterhorn table with scope attrs
|
|
if matterhorn and matterhorn.get("checkpoints"):
|
|
mh_rows = ""
|
|
for cp in matterhorn["checkpoints"]:
|
|
status = cp["status"]
|
|
if status == "PASS" and cp.get("manual"):
|
|
status_cell = '<td class="manual-pass center">Manual Pass</td>'
|
|
elif status == "PASS":
|
|
status_cell = '<td class="pass center">PASS</td>'
|
|
elif status == "FAIL":
|
|
status_cell = '<td class="fail center">FAIL</td>'
|
|
else:
|
|
status_cell = '<td class="not-tested center">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"""
|
|
<section>
|
|
<h2>Matterhorn Protocol — PDF/UA-1</h2>
|
|
<p class="banner {overall_cls}" role="status">PDF/UA-1 requirements: {overall}</p>
|
|
<table>
|
|
<caption>Matterhorn Protocol checkpoint results</caption>
|
|
<thead><tr><th scope="col">Checkpoint</th><th scope="col">How</th><th scope="col">Status</th></tr></thead>
|
|
<tbody>{mh_rows}</tbody>
|
|
</table>
|
|
</section>"""
|
|
|
|
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"""
|
|
<section>
|
|
<h2>Issues ({len(issues)})</h2>
|
|
<table>
|
|
<caption>Accessibility issues found in the document</caption>
|
|
<thead><tr><th scope="col">Severity</th><th scope="col">Category</th><th scope="col">Page</th><th scope="col">Description</th></tr></thead>
|
|
<tbody>{issue_rows}</tbody>
|
|
</table>
|
|
</section>"""
|
|
|
|
# Compliance section for PDF
|
|
compliance = data.get('wcag_compliance', {})
|
|
if compliance:
|
|
a_pass = compliance.get('level_a', False)
|
|
aa_pass = compliance.get('level_aa', False)
|
|
a_cls = 'pass' if a_pass else 'fail'
|
|
aa_cls = 'pass' if aa_pass else 'fail'
|
|
a_text = '✓ Pass' if a_pass else '✗ Fail'
|
|
aa_text = '✓ Pass' if aa_pass else '✗ Fail'
|
|
sections_html += f"""
|
|
<section>
|
|
<h2>WCAG 2.1 Conformance</h2>
|
|
<div style="display:flex;gap:12px;margin-bottom:10px;">
|
|
<div class="banner {a_cls}" style="flex:1;text-align:center;">WCAG 2.1 Level A: {a_text}</div>
|
|
<div class="banner {aa_cls}" style="flex:1;text-align:center;">WCAG 2.1 Level AA: {aa_text}</div>
|
|
</div>
|
|
</section>"""
|
|
|
|
next_steps = data.get('next_steps', [])
|
|
if next_steps:
|
|
ns_rows = ''
|
|
for i, s in enumerate(next_steps, 1):
|
|
pl = {1: 'Critical', 2: 'Error', 3: 'Warning'}.get(s.get('priority', 3), '')
|
|
ns_rows += f'<tr><td style="text-align:center;">{i}</td><td class="{pl.lower()}">{pl}</td><td>{s.get("category","")}</td><td>{s.get("action","")}</td></tr>'
|
|
sections_html += f"""
|
|
<section>
|
|
<h2>Recommended Next Steps</h2>
|
|
<table>
|
|
<caption>Prioritised remediation actions</caption>
|
|
<thead><tr><th scope="col">#</th><th scope="col">Priority</th><th scope="col">Category</th><th scope="col">Action</th></tr></thead>
|
|
<tbody>{ns_rows}</tbody>
|
|
</table>
|
|
</section>"""
|
|
|
|
html_content = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Accessibility Report — {filename}</title>
|
|
<meta name="description" content="WCAG 2.1 and PDF/UA-1 accessibility report for {filename}">
|
|
<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: #6366F1; }}
|
|
.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 #6366F1; }}
|
|
.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: #6366F1; }}
|
|
.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 #6366F1; 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; }}
|
|
.manual-pass {{ color: #d97706; 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>
|
|
<header class="header" role="banner">
|
|
<div>
|
|
<h1>PDF <span class="accent">Accessibility</span> Report</h1>
|
|
<p class="meta">{filename} · {total_pages} pages · Generated {now}</p>
|
|
</div>
|
|
<div style="text-align:right;font-size:9pt;color:#ccc;">
|
|
WCAG 2.1 · PDF/UA-1<br>
|
|
<span style="color:#6366F1;font-weight:700;">Aimpress</span>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<div class="score-block" role="img" aria-label="Accessibility score: {score} out of 100, Grade {grade}{' (Adjusted)' if is_adjusted else ''}">
|
|
<div class="score-num" aria-hidden="true">{score}</div>
|
|
<div class="score-info">
|
|
<h2>Accessibility Score — Grade {grade}{' <span style="font-size:10pt;color:#6366F1;">(Adjusted)</span>' if is_adjusted else ''}</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" role="group" aria-label="Issue severity summary">
|
|
<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}
|
|
</main>
|
|
|
|
<footer class="footer">
|
|
PDF Accessibility SaaS · Aimpress · {now}
|
|
</footer>
|
|
</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()
|