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 @@
| Check | Result |
|---|---|
| ${c.name} | ++ ${c.passed ? '✓ Pass' : '✗ Fail'} + | +
| Checkpoint | How | Status |
|---|
| Severity | Category | Page | Description |
|---|
{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 ''} +