From ac8aedf4a316f0fad29b6d59f325df3b96d6b499 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 12 Mar 2026 18:06:32 +0000 Subject: [PATCH] Implement QA report fixes: scoring, Matterhorn, dismiss, PDF report, UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api.php | 98 ++++++++++++- css/styles.css | 285 +++++++++++++++++++++++++++++++++++++- db/init.sql | 11 ++ db_manager.py | 34 +++++ enterprise_pdf_checker.py | 144 +++++++++++++++++-- index.html | 31 +++++ js/results.js | 154 +++++++++++++++++++- js/upload.js | 30 +++- pdf_remediation.py | 83 ++++++----- report_generator.py | 194 ++++++++++++++++++++++++-- requirements.txt | 1 + 11 files changed, 998 insertions(+), 67 deletions(-) diff --git a/api.php b/api.php index 4d5cbee..30b4fcd 100644 --- a/api.php +++ b/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 */ diff --git a/css/styles.css b/css/styles.css index 44a4c2e..b701693 100644 --- a/css/styles.css +++ b/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; } diff --git a/db/init.sql b/db/init.sql index e87d104..1e9788f 100644 --- a/db/init.sql +++ b/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); diff --git a/db_manager.py b/db_manager.py index 788c3fd..e82b525 100644 --- a/db_manager.py +++ b/db_manager.py @@ -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()] diff --git a/enterprise_pdf_checker.py b/enterprise_pdf_checker.py index 1b3e86d..1ce7c58 100644 --- a/enterprise_pdf_checker.py +++ b/enterprise_pdf_checker.py @@ -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, diff --git a/index.html b/index.html index f9d10ac..83239c0 100644 --- a/index.html +++ b/index.html @@ -63,6 +63,14 @@
Maximum file size: 50MB
+
+
+
+ + +
@@ -129,6 +138,7 @@
+
@@ -144,6 +154,22 @@ + + +
+ +
+ Review complete — check another document or export your report. + +
diff --git a/js/results.js b/js/results.js index 47bbf9d..609c2c9 100644 --- a/js/results.js +++ b/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) {

Document-Wide Issues (${documentWide.length})

-
${documentWide.map(i => createIssueCard(i, issueNumberMap.get(i))).join('')}
+
${documentWide.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}
`; } @@ -103,25 +107,33 @@ function displayIssues(issues) { ${warn > 0 ? `${warn} Warning${warn !== 1 ? 's' : ''}` : ''} -
${pi.map(i => createIssueCard(i, issueNumberMap.get(i))).join('')}
+
${pi.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}
`; }); 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 - ? `📍 #${issueNumber}` + ? `` : ''; - return `
+ const dismissBtn = isDismissed + ? `` + : ``; + + return `
${catIcon}${issue.category}${markerBadge}
- ${icon}${issue.severity} +
+ ${icon}${issue.severity} + ${dismissBtn} +
${issue.description}
${issue.wcag_criterion ? `
WCAG ${issue.wcag_criterion}
` : ''} @@ -223,3 +235,133 @@ async function applyFixes() { btn.innerHTML = 'Retry Auto-Fix'; } } + +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 = ` +
+ ${breakdown.checks_passed} of ${breakdown.checks_total} checks passed  ·  Base: ${breakdown.base_score}%  ·  Penalty: −${breakdown.penalty}  ·  Score: ${breakdown.final_score} + + + + ${breakdown.per_check.map(c => ` + + + + `).join('')} + +
CheckResult
${c.name} + ${c.passed ? '✓ Pass' : '✗ Fail'} +
+
`; +} + +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 + ? `
✅ PDF/UA-1 requirements fulfilled
` + : `
❌ PDF/UA-1 requirements NOT fulfilled
`; + + 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 += `${section.label}`; + section.ids.forEach(id => { + const cp = cpMap[id]; + if (!cp) return; + const statusHtml = cp.status === 'PASS' + ? `✓ PASS` + : cp.status === 'FAIL' + ? `✗ FAIL` + : `— Not tested`; + const howBadge = cp.how === 'M' + ? `M` + : `H`; + html += ` + CP${cp.id} ${cp.name} + ${howBadge} + ${statusHtml} + `; + }); + }); + 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; +} diff --git a/js/upload.js b/js/upload.js index 6e40b8c..77bc235 100644 --- a/js/upload.js +++ b/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; diff --git a/pdf_remediation.py b/pdf_remediation.py index d2011cb..d6f21b2 100755 --- a/pdf_remediation.py +++ b/pdf_remediation.py @@ -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__": diff --git a/report_generator.py b/report_generator.py index 20c24c2..40701bd 100644 --- a/report_generator.py +++ b/report_generator.py @@ -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 = 'PASS' + elif status == "FAIL": + status_cell = 'FAIL' + else: + status_cell = '—' + mh_rows += f'CP{cp["id"]} {cp["name"]}{cp["how"]}{status_cell}' + + overall = "FULFILLED" if matterhorn.get("overall_passed") else "NOT FULFILLED" + overall_cls = "pass" if matterhorn.get("overall_passed") else "fail" + sections_html += f""" +
+

Matterhorn Protocol — PDF/UA-1

+ + + + {mh_rows} +
CheckpointHowStatus
+
""" + + # Issues table + if issues: + issue_rows = "" + for iss in issues: + sev = iss.get("severity", "INFO") + issue_rows += f""" + {sev} + {iss.get("category", "")} + {iss.get("page_number") or "—"} + {iss.get("description", "")} + """ + sections_html += f""" +
+

Issues ({len(issues)})

+ + + {issue_rows} +
SeverityCategoryPageDescription
+
""" + + html_content = f""" + + + + + + +
+
+

PDF Accessibility Report

+
{filename}  ·  {total_pages} pages  ·  Generated {now}
+
+
+ WCAG 2.1  ·  PDF/UA-1
+ Oliver Solutions +
+
+ +
+
{score}
+
+

Accessibility Score — Grade {grade}

+

{sc.get('critical',0)} critical   {sc.get('error',0)} errors   {sc.get('warning',0)} warnings   {sc.get('info',0)} info

+ {f'

{breakdown.get("checks_passed",0)} of {breakdown.get("checks_total",0)} checks passed

' if breakdown else ''} +
+
+ +
+
{sc.get('critical',0)}
Critical
+
{sc.get('error',0)}
Errors
+
{sc.get('warning',0)}
Warnings
+
{sc.get('info',0)}
Info
+
+ + {sections_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__": diff --git a/requirements.txt b/requirements.txt index 803cc81..8802f1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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