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:
parent
463b504d67
commit
ac8aedf4a3
11 changed files with 998 additions and 67 deletions
98
api.php
98
api.php
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
285
css/styles.css
285
css/styles.css
|
|
@ -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; }
|
||||
|
|
|
|||
11
db/init.sql
11
db/init.sql
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
31
index.html
31
index.html
|
|
@ -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">
|
||||
▶ 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)">📄 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;">⬆ Check Another PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
154
js/results.js
154
js/results.js
|
|
@ -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;">▼</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;">▼</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;">📍 #${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">📍 #${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 · Base: ${breakdown.base_score}% · Penalty: −${breakdown.penalty} · 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">✅ PDF/UA-1 requirements fulfilled</div>`
|
||||
: `<div class="matterhorn-banner fail">❌ 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">✓ PASS</span>`
|
||||
: cp.status === 'FAIL'
|
||||
? `<span class="mh-fail">✗ 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;
|
||||
}
|
||||
|
|
|
|||
30
js/upload.js
30
js/upload.js
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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} · {total_pages} pages · Generated {now}</div>
|
||||
</div>
|
||||
<div style="text-align:right;font-size:9pt;color:#ccc;">
|
||||
WCAG 2.1 · 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 {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">
|
||||
<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 · Enterprise Edition · Oliver Solutions · {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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue