Implement QA report fixes: scoring, Matterhorn, dismiss, PDF report, UX

Part 1 — CSS/Contrast/Accessibility:
- Raise --text-muted contrast to WCAG AA (#696969 light, #9a9a9a dark)
- Add body font-size: 16px baseline
- Enlarge #themeToggle to 15px / 10px 20px padding

Part 2 — Start Button (user-controlled analysis):
- Upload no longer auto-starts check; shows ready state with filename/size
- New showReadyState() / removeFile() functions in upload.js
- beginCheck() now shows progress + hides ready state on click
- Add prominent "Check Another PDF" button at bottom of results

Part 3 — Scoring recalibration:
- Replace deduction formula with check-pass ratio + soft penalty (cap 20)
- Fix run_check() to only examine issues added by the current check
- Add score_breakdown (per-check table) to JSON output + results UI
- Downgrade readability ERROR → WARNING (advisory, not hard failure)

Part 4 — Auto-fix debugging:
- Remediation failure now returns up to 2000 chars of log (was 500)
- pdf_remediation.py: stderr output, sys.exit(0/1), output dir creation

Part 5 — Error location: View on Page button on each issue card

Part 6 — Matterhorn Protocol PDF/UA-1:
- _build_matterhorn_summary() maps 19 checks → 31 checkpoints
- Matterhorn card in index.html with grouped PASS/FAIL/Not-tested table
- Correct M/H badges per checkpoint

Part 7 — Dismiss / False Positive:
- dismissed_issues table in db/init.sql + dismiss/undismiss in db_manager.py
- api.php: dismiss/undismiss endpoints (file-backed), dismissed_indices
  injected into both handleStatus and handleResult responses
- results.js: dismissIssue/undismissIssue with visual strikethrough
- CSS: .dismissed, .btn-dismiss, .btn-undismiss styles

Part 8 — PDF Report (WeasyPrint):
- generate_pdf() in report_generator.py: PAC-style A4, Oliver branding
- api.php handleExport() supports format=pdf
- index.html: "PDF Report" download button in results header
- requirements.txt: weasyprint>=60.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-12 18:06:32 +00:00
parent 463b504d67
commit ac8aedf4a3
11 changed files with 998 additions and 67 deletions

98
api.php
View file

@ -155,6 +155,12 @@ switch ($action) {
case 'export':
handleExport();
break;
case 'dismiss':
handleDismiss();
break;
case 'undismiss':
handleUndismiss();
break;
default:
error('Invalid action');
}
@ -378,6 +384,11 @@ function handleStatus() {
}
}
$dismiss_file = RESULTS_DIR . '/' . $job_id . '.dismissed.json';
$job_data['dismissed_indices'] = file_exists($dismiss_file)
? array_map('intval', array_keys(json_decode(file_get_contents($dismiss_file), true) ?: []))
: [];
success($job_data);
}
@ -399,7 +410,13 @@ function handleResult() {
}
$result = json_decode(file_get_contents($result_file), true);
// Inject dismissed indices so frontend can restore dismiss state on reload
$dismiss_file = RESULTS_DIR . '/' . $job_id . '.dismissed.json';
$result['dismissed_indices'] = file_exists($dismiss_file)
? array_map('intval', array_keys(json_decode(file_get_contents($dismiss_file), true) ?: []))
: [];
success($result);
}
@ -591,7 +608,8 @@ function handleRemediate() {
// Check if remediation succeeded
if ($return_code !== 0 || !file_exists($remediated_pdf)) {
$log_content = file_exists($error_log) ? file_get_contents($error_log) : 'Unknown error';
error('Remediation failed: ' . substr($log_content, -500));
$truncated = strlen($log_content) > 2000 ? '...' . substr($log_content, -2000) : $log_content;
error('Remediation failed: ' . $truncated);
}
// Store remediated file info
@ -882,6 +900,32 @@ function handleExport() {
exit;
}
if ($format === 'pdf') {
// Generate PDF report via Python WeasyPrint
$venv_python = __DIR__ . '/venv/bin/python3';
$python_bin = file_exists($venv_python) ? $venv_python : 'python3';
$report_script = __DIR__ . '/report_generator.py';
$pdf_file = RESULTS_DIR . '/' . $job_id . '.report.pdf';
$cmd = escapeshellcmd($python_bin . ' ' . $report_script) .
' --input ' . escapeshellarg($result_file) .
' --output ' . escapeshellarg($pdf_file) .
' --format pdf';
exec($cmd . ' 2>&1', $output, $return_code);
if ($return_code !== 0 || !file_exists($pdf_file)) {
error('PDF report generation failed: ' . implode("\n", $output));
}
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="accessibility_report_' . $job_id . '.pdf"');
header('Content-Length: ' . filesize($pdf_file));
readfile($pdf_file);
exit;
}
// Default: JSON download
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="accessibility_report_' . $job_id . '.json"');
@ -889,6 +933,56 @@ function handleExport() {
exit;
}
/**
* Dismiss an issue (mark as false positive)
*/
function handleDismiss() {
$data = json_decode(file_get_contents('php://input'), true) ?: [];
$job_id = $data['job_id'] ?? '';
$issue_index = isset($data['issue_index']) ? (int)$data['issue_index'] : -1;
$reason = substr($data['reason'] ?? '', 0, 255);
if (empty($job_id) || $issue_index < 0) {
error('job_id and issue_index required');
}
$job_id = sanitizeJobId($job_id);
$meta_file = RESULTS_DIR . '/' . $job_id . '.meta.json';
if (!file_exists($meta_file)) {
error('Job not found');
}
$dismiss_file = RESULTS_DIR . '/' . $job_id . '.dismissed.json';
$dismissed = file_exists($dismiss_file) ? json_decode(file_get_contents($dismiss_file), true) : [];
$dismissed[$issue_index] = ['reason' => $reason, 'dismissed_at' => date('Y-m-d H:i:s')];
file_put_contents($dismiss_file, json_encode($dismissed));
success(['dismissed' => true, 'issue_index' => $issue_index]);
}
/**
* Undismiss an issue
*/
function handleUndismiss() {
$data = json_decode(file_get_contents('php://input'), true) ?: [];
$job_id = $data['job_id'] ?? '';
$issue_index = isset($data['issue_index']) ? (int)$data['issue_index'] : -1;
if (empty($job_id) || $issue_index < 0) {
error('job_id and issue_index required');
}
$job_id = sanitizeJobId($job_id);
$dismiss_file = RESULTS_DIR . '/' . $job_id . '.dismissed.json';
if (file_exists($dismiss_file)) {
$dismissed = json_decode(file_get_contents($dismiss_file), true);
unset($dismissed[$issue_index]);
file_put_contents($dismiss_file, json_encode($dismissed));
}
success(['undismissed' => true, 'issue_index' => $issue_index]);
}
/**
* Send success response
*/

View file

@ -43,7 +43,7 @@
--text: #1a1a1a;
--text-light: #4a4a4a;
--text-secondary: #555555;
--text-muted: #888888;
--text-muted: #696969;
--border: #e0ddd8;
--border-subtle: #eae8e4;
--divider: #d4d0ca;
@ -79,7 +79,7 @@
--text: #f0f0f0;
--text-light: #b0b0b0;
--text-secondary: #aaaaaa;
--text-muted: #777777;
--text-muted: #9a9a9a;
--border: #333333;
--border-subtle: #2a2a2a;
--divider: #303030;
@ -128,6 +128,7 @@ body {
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 16px;
overflow-x: hidden;
}
@ -234,6 +235,13 @@ h1::before {
background: var(--accent-subtle);
}
#themeToggle {
padding: 10px 20px;
font-size: 15px;
border-radius: var(--radius-md);
font-weight: 600;
}
.user-info {
color: var(--text-muted);
font-size: 13px;
@ -1018,3 +1026,276 @@ h1::before {
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ── Start-Ready State ── */
.upload-ready {
display: none;
padding: 24px;
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
margin-top: 20px;
animation: fadeUp 0.4s var(--ease-out);
}
.upload-ready .ready-filename {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.upload-ready .ready-filesize {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 20px;
}
.btn-start {
background: var(--accent);
color: var(--accent-text);
border: none;
font-weight: 700;
padding: 14px 32px;
font-size: 16px;
border-radius: var(--radius-md);
cursor: pointer;
font-family: var(--font-display);
transition: all 0.2s var(--ease-out);
display: inline-flex;
align-items: center;
gap: 10px;
}
.btn-start:hover {
background: var(--accent-hover);
box-shadow: 0 4px 20px var(--accent-glow);
transform: translateY(-1px);
}
.btn-remove {
background: none;
border: none;
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
margin-left: 16px;
text-decoration: underline;
font-family: var(--font-body);
}
.btn-remove:hover {
color: var(--error);
}
/* ── Score Breakdown ── */
.score-breakdown {
margin-top: 16px;
padding: 14px 18px;
background: var(--surface-alt);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
font-size: 13px;
}
.score-breakdown summary {
cursor: pointer;
font-weight: 600;
color: var(--text);
font-family: var(--font-display);
list-style: none;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.score-breakdown summary::before {
content: '▶';
font-size: 10px;
transition: transform 0.2s;
}
.score-breakdown[open] summary::before {
transform: rotate(90deg);
}
.score-breakdown-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 12px;
}
.score-breakdown-table th {
text-align: left;
padding: 6px 10px;
background: var(--bg-subtle);
color: var(--text-secondary);
font-weight: 600;
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 11px;
}
.score-breakdown-table td {
padding: 6px 10px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text);
}
/* ── Dismiss Feature ── */
.issue.dismissed {
opacity: 0.45;
filter: grayscale(0.6);
position: relative;
}
.issue.dismissed .issue-description {
text-decoration: line-through;
text-decoration-color: var(--text-muted);
}
.btn-dismiss {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
cursor: pointer;
padding: 3px 8px;
border-radius: var(--radius-sm);
font-family: var(--font-display);
font-weight: 600;
transition: all 0.15s;
margin-left: 8px;
}
.btn-dismiss:hover {
border-color: var(--error);
color: var(--error);
background: var(--error-bg);
}
.btn-undismiss {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 11px;
cursor: pointer;
padding: 3px 8px;
border-radius: var(--radius-sm);
font-family: var(--font-display);
font-weight: 600;
transition: all 0.15s;
margin-left: 8px;
}
.btn-undismiss:hover {
border-color: var(--success);
color: var(--success);
}
.dismiss-toggle-bar {
margin-bottom: 12px;
font-size: 13px;
color: var(--text-muted);
}
.dismiss-toggle-bar button {
background: none;
border: none;
color: var(--info);
cursor: pointer;
text-decoration: underline;
font-size: 13px;
font-family: var(--font-body);
padding: 0;
}
/* ── Matterhorn Table ── */
#matterhornCard table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-top: 16px;
}
#matterhornCard th {
text-align: left;
padding: 8px 12px;
background: var(--bg-subtle);
color: var(--text-secondary);
font-weight: 700;
font-family: var(--font-display);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 2px solid var(--border);
}
#matterhornCard td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text);
vertical-align: top;
}
#matterhornCard tr.section-header td {
background: var(--surface-alt);
font-weight: 700;
font-family: var(--font-display);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
padding: 10px 12px 6px;
}
.matterhorn-banner {
padding: 14px 20px;
border-radius: var(--radius-md);
font-weight: 700;
font-family: var(--font-display);
font-size: 15px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 10px;
}
.matterhorn-banner.pass {
background: var(--success-bg);
color: var(--success);
border: 1px solid rgba(5, 150, 105, 0.3);
}
.matterhorn-banner.fail {
background: var(--error-bg);
color: var(--error);
border: 1px solid rgba(239, 68, 68, 0.3);
}
:root[data-theme="dark"] .matterhorn-banner.pass { color: #6ee7b7; }
:root[data-theme="dark"] .matterhorn-banner.fail { color: #fca5a5; }
.badge-m, .badge-h {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
font-family: var(--font-display);
}
.badge-m { background: var(--info-bg); color: var(--info); }
.badge-h { background: var(--warning-bg); color: var(--warning); }
.mh-pass { color: var(--success); font-weight: 700; }
.mh-fail { color: var(--error); font-weight: 700; }
.mh-not-tested { color: var(--text-muted); }
:root[data-theme="dark"] .mh-pass { color: #6ee7b7; }
:root[data-theme="dark"] .mh-fail { color: #fca5a5; }

View file

@ -34,3 +34,14 @@ CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
CREATE INDEX IF NOT EXISTS idx_jobs_job_id ON jobs(job_id);
CREATE INDEX IF NOT EXISTS idx_audit_job ON audit_log(job_id);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
CREATE TABLE IF NOT EXISTS dismissed_issues (
id SERIAL PRIMARY KEY,
job_id VARCHAR(64) NOT NULL,
issue_index INTEGER NOT NULL,
reason VARCHAR(255),
dismissed_at TIMESTAMP DEFAULT NOW(),
UNIQUE(job_id, issue_index)
);
CREATE INDEX IF NOT EXISTS idx_dismissed_job ON dismissed_issues(job_id);

View file

@ -144,3 +144,37 @@ def get_stats() -> dict:
FROM jobs
""")
return dict(cur.fetchone())
def dismiss_issue(job_id: str, issue_index: int, reason: str = None):
"""Record a dismissed/false-positive issue."""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""INSERT INTO dismissed_issues (job_id, issue_index, reason)
VALUES (%s, %s, %s)
ON CONFLICT (job_id, issue_index) DO UPDATE
SET reason = EXCLUDED.reason, dismissed_at = NOW()""",
(job_id, issue_index, reason)
)
def undismiss_issue(job_id: str, issue_index: int):
"""Remove a dismissal record."""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM dismissed_issues WHERE job_id = %s AND issue_index = %s",
(job_id, issue_index)
)
def get_dismissed_indices(job_id: str) -> list:
"""Return list of dismissed issue indices for a job."""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT issue_index FROM dismissed_issues WHERE job_id = %s ORDER BY issue_index",
(job_id,)
)
return [row[0] for row in cur.fetchall()]

View file

@ -398,11 +398,13 @@ class EnterprisePDFChecker:
"""Run a check and record results"""
start_time = time.time()
result = CheckResult(check_name=check_name, passed=True)
issues_before = len(self.issues)
try:
check_func()
# Check passed if no critical/error issues added during check
critical_errors = [i for i in self.issues
# Check passed if no critical/error issues added by THIS check
new_issues = self.issues[issues_before:]
critical_errors = [i for i in new_issues
if i.severity in [Severity.CRITICAL, Severity.ERROR]]
result.passed = len(critical_errors) == 0
except Exception as e:
@ -1005,9 +1007,9 @@ Respond in JSON format:
if 'error' in analysis:
return
# Check Flesch Reading Ease
# Check Flesch Reading Ease — readability is advisory, cap at WARNING
if analysis['flesch_reading_ease'] < 60:
severity = Severity.ERROR if analysis['flesch_reading_ease'] < 30 else Severity.WARNING
severity = Severity.WARNING # never ERROR: readability is not a hard accessibility failure
self.add_issue(
severity,
"Readability",
@ -1511,7 +1513,110 @@ Respond in JSON format:
logger.warning(f"Could not generate page images: {str(e)}")
# ==================== REPORTING ====================
def _build_matterhorn_summary(self) -> dict:
"""Build Matterhorn Protocol PDF/UA-1 checkpoint summary."""
# Map check names to Matterhorn checkpoint IDs
CHECK_TO_MATTERHORN = {
"Document Structure": ["01", "02", "09"],
"Metadata": ["06", "07"],
"Language Declaration": ["11"],
"Text Extractability": ["01", "08"],
"OCR Quality": ["08"],
"Image Accessibility": ["13"],
"Color Contrast": ["04"],
"Content Readability": [],
"Link Quality": ["27", "28"],
"Heading Structure": ["14"],
"Tab Order": ["28"],
"Role Mapping": ["02"],
"Form Accessibility": ["24", "28"],
"Table Structure": ["15"],
"Reading Order": ["09"],
"Font Accessibility": ["31"],
"Security Settings": ["26"],
"Navigation Aids": ["27"],
"PDF/UA Structure (veraPDF)": [], # Covers all M conditions
}
# Checkpoint definitions: id, name, how (M=machine/H=human)
CHECKPOINTS = [
("01", "Real content tagged", "M"),
("02", "Role mapping", "M"),
("03", "Flickering content", "H"),
("04", "Color and contrast", "H"),
("05", "Sound content", "H"),
("06", "Metadata title", "M"),
("07", "Metadata language", "M"),
("08", "Text content", "M"),
("09", "Reading order", "M"),
("10", "Tab order", "M"),
("11", "Natural language", "M"),
("12", "Character encoding", "M"),
("13", "Graphics / alt text", "H"),
("14", "Headings", "M"),
("15", "Tables", "M"),
("16", "Lists", "M"),
("17", "Mathematical expressions", "H"),
("18", "Page headers / footers", "H"),
("19", "Notes / references", "H"),
("20", "Optional content", "M"),
("21", "Embedded files", "M"),
("22", "Article threads", "H"),
("23", "Digital signatures", "H"),
("24", "Non-interactive forms", "H"),
("25", "XFA forms", "M"),
("26", "Security", "M"),
("27", "Navigation", "M"),
("28", "Annotations", "M"),
("29", "Actions", "M"),
("30", "XObjects", "M"),
("31", "Fonts", "M"),
]
# Build a map: checkpoint_id -> pass/fail/not_tested from our check results
cp_status: dict = {} # id -> "PASS" | "FAIL" | "NOT_TESTED"
check_name_to_result = {cr.check_name: cr.passed for cr in self.check_results}
# Determine which checkpoints are covered and whether they passed
for check_name, cp_ids in CHECK_TO_MATTERHORN.items():
result_passed = check_name_to_result.get(check_name)
if result_passed is None:
continue
for cp_id in cp_ids:
if cp_id not in cp_status:
cp_status[cp_id] = "PASS" if result_passed else "FAIL"
elif not result_passed:
# Any failure overrides a pass
cp_status[cp_id] = "FAIL"
# Handle PDF/UA veraPDF: if it passed, mark all M checkpoints as PASS unless already FAIL
verapdf_passed = check_name_to_result.get("PDF/UA Structure (veraPDF)")
if verapdf_passed:
for cp_id, _, how in CHECKPOINTS:
if how == "M" and cp_id not in cp_status:
cp_status[cp_id] = "PASS"
checkpoints_out = []
any_fail = False
for cp_id, cp_name, cp_how in CHECKPOINTS:
status = cp_status.get(cp_id, "NOT_TESTED")
if status == "FAIL":
any_fail = True
checkpoints_out.append({
"id": cp_id,
"name": cp_name,
"how": cp_how,
"status": status,
})
return {
"standard": "PDF/UA-1",
"overall_passed": not any_fail,
"checkpoints": checkpoints_out,
}
def _generate_summary(self) -> Dict[str, Any]:
"""Generate comprehensive summary"""
severity_counts = {
@ -1522,13 +1627,14 @@ Respond in JSON format:
'success': len([i for i in self.issues if i.severity == Severity.SUCCESS])
}
# Calculate score
score = 100
score -= severity_counts['critical'] * 25
score -= severity_counts['error'] * 10
score -= severity_counts['warning'] * 5
score -= severity_counts['info'] * 2
score = max(0, min(100, score))
# Calculate score based on check-pass ratio
passed_checks = len([cr for cr in self.check_results if cr.passed])
total_checks = len(self.check_results)
base_score = round(100 * passed_checks / total_checks) if total_checks else 0
# Soft penalty for critical/error issues (capped at 20)
penalty = min(20, severity_counts['critical'] * 5 + severity_counts['error'] * 2)
score = max(0, base_score - penalty)
# Convert datetime objects to strings for JSON serialization
stats_serializable = {}
@ -1550,6 +1656,18 @@ Respond in JSON format:
'filename': self.pdf_path.name,
'total_pages': len(self.pdf_reader.pages),
'accessibility_score': score,
'score_breakdown': {
'checks_passed': passed_checks,
'checks_total': total_checks,
'base_score': base_score,
'penalty': penalty,
'final_score': score,
'per_check': [
{'name': cr.check_name, 'passed': cr.passed}
for cr in self.check_results
]
},
'matterhorn_summary': self._build_matterhorn_summary(),
'severity_counts': severity_counts,
'total_issues': len(self.issues),
'auto_fixable_count': auto_fixable_count,

View file

@ -63,6 +63,14 @@
<div class="upload-hint">Maximum file size: 50MB</div>
<input type="file" id="fileInput" accept=".pdf" aria-hidden="true">
</div>
<div class="upload-ready" id="uploadReadyState" aria-live="polite">
<div class="ready-filename" id="readyFilename"></div>
<div class="ready-filesize" id="readyFilesize"></div>
<button class="btn-start" onclick="beginCheck()" aria-label="Start accessibility check">
&#x25B6; Start Accessibility Check
</button>
<button class="btn-remove" onclick="removeFile()" aria-label="Remove file">Remove</button>
</div>
</div>
<div id="batchUploadArea" style="display:none;">
@ -119,6 +127,7 @@
<div style="display:flex;gap:10px;">
<button class="btn btn-secondary" onclick="exportReport('html')" id="exportHtmlBtn" title="Download HTML report">Export Report</button>
<button class="btn btn-secondary" onclick="exportReport('json')" id="exportJsonBtn" title="Download JSON data">Export JSON</button>
<button class="btn btn-secondary" onclick="exportReport('pdf')" id="exportPdfBtn" title="Download PDF report (PAC-style)">&#x1F4C4; PDF Report</button>
<button class="btn btn-secondary" onclick="resetCheck()">Check Another PDF</button>
</div>
</div>
@ -129,6 +138,7 @@
</div>
<div class="stats-grid" id="statsGrid" role="group" aria-label="Issue severity counts"></div>
<div id="scoreBreakdown"></div>
</div>
<!-- Auto-Fix Card -->
@ -144,6 +154,22 @@
<div id="fixResult" style="margin-top:15px;display:none;" role="alert"></div>
</div>
<!-- Matterhorn Protocol Card -->
<div class="card" id="matterhornCard" style="display:none;">
<h2>Matterhorn Protocol — PDF/UA-1</h2>
<div id="matterhornBanner"></div>
<table id="matterhornTable" aria-label="Matterhorn Protocol checkpoints">
<thead>
<tr>
<th>Checkpoint</th>
<th>How</th>
<th>Status</th>
</tr>
</thead>
<tbody id="matterhornBody"></tbody>
</table>
</div>
<!-- Visual Page Viewer -->
<div class="card" id="pageViewerCard" style="display:none;">
<h2>Visual Page Inspector</h2>
@ -198,6 +224,11 @@
</div>
<div id="issuesList" role="list"></div>
<div style="margin-top:28px;padding-top:20px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;">
<span style="font-size:13px;color:var(--text-muted);">Review complete — check another document or export your report.</span>
<button class="btn btn-primary" onclick="resetCheck()" style="padding:12px 28px;font-size:15px;">&#x2B06; Check Another PDF</button>
</div>
</div>
</div>
</div>

View file

@ -2,6 +2,7 @@
let currentFilter = 'all';
let allIssues = [];
let dismissedIndices = new Set();
function displayResults(data) {
document.getElementById('uploadSection').style.display = 'none';
@ -20,9 +21,12 @@ function displayResults(data) {
`;
allIssues = data.issues;
dismissedIndices = new Set(data.dismissed_indices || []);
displayScoreBreakdown(data.score_breakdown);
displayIssues(allIssues);
initializePageViewer(data);
displayRemediationOptions(data);
displayMatterhorn(data.matterhorn_summary);
}
function displayIssues(issues) {
@ -85,7 +89,7 @@ function displayIssues(issues) {
<h3 style="font-size:18px;margin-bottom:10px;padding:10px 12px;background:var(--surface-alt);border-radius:6px;cursor:pointer;" onclick="togglePageSection('document')" aria-expanded="true">
Document-Wide Issues (${documentWide.length}) <span id="toggle-document" style="float:right;">&#9660;</span>
</h3>
<div id="section-document" class="issues-grid">${documentWide.map(i => createIssueCard(i, issueNumberMap.get(i))).join('')}</div>
<div id="section-document" class="issues-grid">${documentWide.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}</div>
</div>`;
}
@ -103,25 +107,33 @@ function displayIssues(issues) {
${warn > 0 ? `<span style="background:#f59e0b;color:white;padding:2px 6px;border-radius:10px;font-size:11px;margin-left:8px;">${warn} Warning${warn !== 1 ? 's' : ''}</span>` : ''}
<span id="toggle-${pn}" style="float:right;">&#9660;</span>
</h3>
<div id="section-${pn}" class="issues-grid">${pi.map(i => createIssueCard(i, issueNumberMap.get(i))).join('')}</div>
<div id="section-${pn}" class="issues-grid">${pi.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}</div>
</div>`;
});
issuesList.innerHTML = html;
}
function createIssueCard(issue, issueNumber) {
function createIssueCard(issue, issueNumber, globalIndex) {
const icon = getSeverityIcon(issue.severity);
const catIcon = getCategoryIcon(issue.category);
const isDismissed = dismissedIndices.has(globalIndex);
const markerBadge = issue.coordinates && issueNumber !== undefined
? `<span onclick="loadVisualPage(${issue.page_number});setTimeout(()=>highlightMarker(${issueNumber}),100);" style="cursor:pointer;background:var(--accent);color:var(--accent-text);padding:3px 8px;border-radius:12px;font-size:11px;font-weight:700;margin-left:8px;">&#x1F4CD; #${issueNumber}</span>`
? `<button onclick="viewOnPage(${issue.page_number}, ${issueNumber})" class="btn-dismiss" style="background:var(--accent);color:var(--accent-text);border:none;" title="View on page">&#x1F4CD; #${issueNumber}</button>`
: '';
return `<div class="issue ${issue.severity}" id="issue-${issueNumber}">
const dismissBtn = isDismissed
? `<button class="btn-undismiss" onclick="undismissIssue(${globalIndex})" title="Mark as active">Restore</button>`
: `<button class="btn-dismiss" onclick="dismissIssue(${globalIndex})" title="Mark as false positive">Dismiss</button>`;
return `<div class="issue ${issue.severity}${isDismissed ? ' dismissed' : ''}" id="issue-g${globalIndex}">
<div class="issue-header">
<div class="issue-category"><span style="font-size:16px;">${catIcon}</span><span>${issue.category}</span>${markerBadge}</div>
<span class="issue-badge ${issue.severity}"><span>${icon}</span><span>${issue.severity}</span></span>
<div style="display:flex;align-items:center;gap:4px;">
<span class="issue-badge ${issue.severity}"><span>${icon}</span><span>${issue.severity}</span></span>
${dismissBtn}
</div>
</div>
<div class="issue-description">${issue.description}</div>
${issue.wcag_criterion ? `<div class="issue-meta"><span>WCAG ${issue.wcag_criterion}</span></div>` : ''}
@ -223,3 +235,133 @@ async function applyFixes() {
btn.innerHTML = '<span>Retry Auto-Fix</span>';
}
}
function viewOnPage(pageNum, markerNum) {
const card = document.getElementById('pageViewerCard');
if (card) {
card.style.display = 'block';
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
loadVisualPage(pageNum);
setTimeout(() => highlightMarker(markerNum), 300);
}
function displayScoreBreakdown(breakdown) {
const el = document.getElementById('scoreBreakdown');
if (!el || !breakdown) return;
const pct = breakdown.checks_total > 0
? Math.round(100 * breakdown.checks_passed / breakdown.checks_total)
: 0;
el.innerHTML = `
<details class="score-breakdown">
<summary>${breakdown.checks_passed} of ${breakdown.checks_total} checks passed &nbsp;·&nbsp; Base: ${breakdown.base_score}% &nbsp;·&nbsp; Penalty: ${breakdown.penalty} &nbsp;·&nbsp; Score: ${breakdown.final_score}</summary>
<table class="score-breakdown-table">
<thead><tr><th>Check</th><th>Result</th></tr></thead>
<tbody>
${breakdown.per_check.map(c => `
<tr>
<td>${c.name}</td>
<td style="color:${c.passed ? 'var(--success)' : 'var(--error)'}; font-weight:700;">
${c.passed ? '✓ Pass' : '✗ Fail'}
</td>
</tr>`).join('')}
</tbody>
</table>
</details>`;
}
function displayMatterhorn(summary) {
const card = document.getElementById('matterhornCard');
const banner = document.getElementById('matterhornBanner');
const body = document.getElementById('matterhornBody');
if (!card || !summary) return;
card.style.display = 'block';
banner.innerHTML = summary.overall_passed
? `<div class="matterhorn-banner pass">&#x2705; PDF/UA-1 requirements fulfilled</div>`
: `<div class="matterhorn-banner fail">&#x274C; PDF/UA-1 requirements NOT fulfilled</div>`;
const sections = [
{ label: 'Basic Requirements', ids: ['01','02','03','04','05','06','07','08'] },
{ label: 'Logical Structure', ids: ['09','10','11','12','13','14','15','16','17','18','19','20'] },
{ label: 'Document Elements', ids: ['21','22','23','24','25','26','27','28','29','30','31'] },
];
const cpMap = {};
summary.checkpoints.forEach(cp => { cpMap[cp.id] = cp; });
let html = '';
sections.forEach(section => {
html += `<tr class="section-header"><td colspan="3">${section.label}</td></tr>`;
section.ids.forEach(id => {
const cp = cpMap[id];
if (!cp) return;
const statusHtml = cp.status === 'PASS'
? `<span class="mh-pass">&#x2713; PASS</span>`
: cp.status === 'FAIL'
? `<span class="mh-fail">&#x2717; FAIL</span>`
: `<span class="mh-not-tested">— Not tested</span>`;
const howBadge = cp.how === 'M'
? `<span class="badge-m">M</span>`
: `<span class="badge-h">H</span>`;
html += `<tr>
<td><strong>CP${cp.id}</strong> ${cp.name}</td>
<td>${howBadge}</td>
<td>${statusHtml}</td>
</tr>`;
});
});
body.innerHTML = html;
}
async function dismissIssue(globalIndex) {
try {
const resp = await fetch('api.php?action=dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId, issue_index: globalIndex })
});
const result = await resp.json();
if (result.success) {
dismissedIndices.add(globalIndex);
const el = document.getElementById('issue-g' + globalIndex);
if (el) {
el.classList.add('dismissed');
el.querySelector('.issue-description').style.textDecoration = 'line-through';
const btn = el.querySelector('.btn-dismiss');
if (btn) { btn.className = 'btn-undismiss'; btn.textContent = 'Restore'; btn.setAttribute('onclick', `undismissIssue(${globalIndex})`); }
}
updateDismissCount();
}
} catch(e) { console.error('Dismiss failed:', e); }
}
async function undismissIssue(globalIndex) {
try {
const resp = await fetch('api.php?action=undismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId, issue_index: globalIndex })
});
const result = await resp.json();
if (result.success) {
dismissedIndices.delete(globalIndex);
const el = document.getElementById('issue-g' + globalIndex);
if (el) {
el.classList.remove('dismissed');
el.querySelector('.issue-description').style.textDecoration = '';
const btn = el.querySelector('.btn-undismiss');
if (btn) { btn.className = 'btn-dismiss'; btn.textContent = 'Dismiss'; btn.setAttribute('onclick', `dismissIssue(${globalIndex})`); }
}
updateDismissCount();
}
} catch(e) { console.error('Undismiss failed:', e); }
}
function updateDismissCount() {
const countEl = document.getElementById('dismissedCount');
if (countEl) countEl.textContent = dismissedIndices.size;
}

View file

@ -56,8 +56,8 @@ async function handleFile(file) {
currentJobId = result.data.job_id;
updateProgress(20, 'Upload complete');
addLog('Upload successful — Job ID: ' + currentJobId, 'success');
await new Promise(r => setTimeout(r, 500));
beginCheck();
document.getElementById('progressContainer').style.display = 'none';
showReadyState(file);
} else {
addLog('Upload failed: ' + result.error, 'error');
alert('Upload failed: ' + result.error);
@ -70,7 +70,29 @@ async function handleFile(file) {
}
}
function showReadyState(file) {
const readyDiv = document.getElementById('uploadReadyState');
if (!readyDiv) return;
document.getElementById('readyFilename').textContent = file.name;
document.getElementById('readyFilesize').textContent = (file.size / 1024 / 1024).toFixed(2) + ' MB';
readyDiv.style.display = 'block';
document.getElementById('singleUploadArea').querySelector('.upload-area').style.display = 'none';
}
function removeFile() {
currentJobId = null;
const readyDiv = document.getElementById('uploadReadyState');
if (readyDiv) readyDiv.style.display = 'none';
document.getElementById('singleUploadArea').querySelector('.upload-area').style.display = '';
document.getElementById('fileInput').value = '';
clearLog();
}
async function beginCheck() {
// Hide ready state, show progress
const readyDiv = document.getElementById('uploadReadyState');
if (readyDiv) readyDiv.style.display = 'none';
document.getElementById('progressContainer').style.display = 'block';
updateProgress(25, 'Initializing accessibility check...');
addLog('Preparing accessibility analysis...', 'info');
@ -186,6 +208,10 @@ function resetCheck() {
document.getElementById('progressContainer').style.display = 'none';
document.getElementById('pageViewerCard').style.display = 'none';
document.getElementById('fileInput').value = '';
var readyDiv = document.getElementById('uploadReadyState');
if (readyDiv) readyDiv.style.display = 'none';
var uploadArea = document.getElementById('singleUploadArea') && document.getElementById('singleUploadArea').querySelector('.upload-area');
if (uploadArea) uploadArea.style.display = '';
var remCard = document.getElementById('remediationCard');
if (remCard) remCard.style.display = 'none';
currentJobId = null;

View file

@ -347,30 +347,31 @@ def main():
args = parser.parse_args()
print(f"🔧 PDF Accessibility Remediation")
print(f"📄 File: {args.pdf_file}")
print(f"{'='*60}\n")
sys.stderr.write(f"PDF Accessibility Remediation\n")
sys.stderr.write(f"File: {args.pdf_file}\n")
sys.stderr.write(f"{'='*60}\n\n")
# Analyze
remediator = PDFRemediator(args.pdf_file)
suggestions = remediator.analyze_and_suggest_fixes()
print("📋 Analysis Complete")
print(f"{'='*60}")
sys.stderr.write("Analysis Complete\n")
sys.stderr.write(f"{'='*60}\n")
all_suggestions = []
for category, fixes in suggestions.items():
if fixes:
print(f"\n{category.upper()} Fixes Available:")
sys.stderr.write(f"\n{category.upper()} Fixes Available:\n")
for fix in fixes:
print(f" {'' if fix['auto_fixable'] else '⚠️ '} {fix['description']}")
print(f" Severity: {fix['severity']}")
print(f" Suggestion: {fix['suggestion']}")
fixable_marker = "[auto]" if fix['auto_fixable'] else "[manual]"
sys.stderr.write(f" {fixable_marker} {fix['description']}\n")
sys.stderr.write(f" Severity: {fix['severity']}\n")
sys.stderr.write(f" Suggestion: {fix['suggestion']}\n")
all_suggestions.append(fix['id'])
if not all_suggestions:
print("\n✅ No automatic fixes needed!")
return
sys.stderr.write("\nNo automatic fixes needed!\n")
sys.exit(0)
# Determine which fixes to apply
fixes_to_apply = []
@ -411,47 +412,63 @@ def main():
fixes_to_apply.append('mark_tagged')
if not fixes_to_apply:
print("\n⚠️ No fixes specified. Use --all or specify individual fixes.")
print(" Example: python pdf_remediation.py file.pdf --title 'My Document' --language en-US")
return
sys.stderr.write("\nNo fixes specified. Use --all or specify individual fixes.\n")
sys.stderr.write(" Example: python pdf_remediation.py file.pdf --title 'My Document' --language en-US\n")
sys.exit(1)
# Validate output path parent directory exists (or create it)
output_path = args.output
if output_path:
output_dir = Path(output_path).parent
if not output_dir.exists():
try:
output_dir.mkdir(parents=True, exist_ok=True)
sys.stderr.write(f"Created output directory: {output_dir}\n")
except OSError as e:
sys.stderr.write(f"Error: Cannot create output directory '{output_dir}': {e}\n")
sys.exit(1)
# Apply fixes
print(f"\n{'='*60}")
print("🔧 Applying Fixes...")
print(f"{'='*60}\n")
sys.stderr.write(f"\n{'='*60}\n")
sys.stderr.write("Applying Fixes...\n")
sys.stderr.write(f"{'='*60}\n\n")
result = remediator.apply_fixes(fixes_to_apply, args.output, custom_values)
result = remediator.apply_fixes(fixes_to_apply, output_path, custom_values)
if result['success']:
print("✅ Remediation Complete!")
print(f"\n📄 Output: {result['output_path']}")
print(f"\n🔧 Fixes Applied:")
sys.stderr.write("Remediation Complete!\n")
sys.stderr.write(f"\nOutput: {result['output_path']}\n")
sys.stderr.write("\nFixes Applied:\n")
for fix in result['fixes_applied']:
print(f"{fix}")
sys.stderr.write(f" - {fix}\n")
# Optionally run veraPDF validation on result
if os.isatty(sys.stdout.fileno()): # Only if running interactively (not from web)
print(f"\n{'='*60}")
print("🔍 Validating Remediated PDF with veraPDF...")
print(f"{'='*60}\n")
if os.isatty(sys.stderr.fileno()): # Only if running interactively (not from web)
sys.stderr.write(f"\n{'='*60}\n")
sys.stderr.write("Validating Remediated PDF with veraPDF...\n")
sys.stderr.write(f"{'='*60}\n\n")
validator = VeraPDFValidator()
validation = validator.validate(result['output_path'])
if 'error' not in validation:
print(f"PDF/UA Compliance: {'✅ PASS' if validation['compliant'] else '❌ FAIL'}")
print(f"Passed Rules: {validation['passed_rules']}")
print(f"Failed Rules: {validation['failed_rules']}")
compliant_str = "PASS" if validation['compliant'] else "FAIL"
sys.stderr.write(f"PDF/UA Compliance: {compliant_str}\n")
sys.stderr.write(f"Passed Rules: {validation['passed_rules']}\n")
sys.stderr.write(f"Failed Rules: {validation['failed_rules']}\n")
if validation['errors']:
print(f"\nRemaining Issues ({len(validation['errors'])}):")
sys.stderr.write(f"\nRemaining Issues ({len(validation['errors'])}):\n")
for i, error in enumerate(validation['errors'][:10], 1):
print(f" {i}. Clause {error['clause']}: {error['description'][:80]}...")
sys.stderr.write(f" {i}. Clause {error['clause']}: {error['description'][:80]}...\n")
if len(validation['errors']) > 10:
print(f" ... and {len(validation['errors']) - 10} more")
sys.stderr.write(f" ... and {len(validation['errors']) - 10} more\n")
sys.exit(0)
else:
print("❌ Remediation failed")
sys.stderr.write("Remediation failed\n")
sys.exit(1)
if __name__ == "__main__":

View file

@ -226,10 +226,176 @@ def generate_html(data: dict) -> str:
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; page-break-inside: 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; }}
.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="Generate HTML accessibility report")
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 HTML file (default: stdout)")
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)
@ -240,14 +406,24 @@ def main():
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)
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:
print(html)
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__":

View file

@ -30,3 +30,4 @@ python-dotenv>=1.0.0 # For environment variable management
# Infrastructure (Docker stack)
redis>=5.0.0
psycopg2-binary>=2.9.0
weasyprint>=60.0