pdf-accessibility/js/results.js
Vadym Samoilenko a5cd1af982 Fix color contrast false positives; table caption INFO; dismiss button more visible
Color contrast:
- Sample pixels 8px apart vertically instead of adjacent horizontal pixels
- Filter out near-uniform pairs (|Δlum| < 0.08) — eliminates photo/gradient noise
- ERROR threshold: >60% of significant edges fail (was 15% of all pixels)
- WARNING threshold: >30% (was 5%)
- Returns early with 'image-only page' if <20 significant edges found

Tables:
- Caption warning downgraded WARNING → INFO (table may have visible title nearby)
- Does not count toward check pass/fail anymore

Dismiss button:
- Renamed 'Dismiss' → '✕ False Positive' (clearer intent)
- Added background color so it's visible against card
- font-size 11→12px, padding increased

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 19:15:28 +00:00

366 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Results display — score, stats, issues, filters, remediation */
let currentFilter = 'all';
let allIssues = [];
let dismissedIndices = new Set();
function displayResults(data) {
document.getElementById('uploadSection').style.display = 'none';
document.getElementById('resultsSection').style.display = 'block';
document.getElementById('scoreNumber').textContent = data.accessibility_score;
const statsGrid = document.getElementById('statsGrid');
const sc = data.severity_counts;
statsGrid.innerHTML = `
<div class="stat-card critical"><div class="stat-number">${sc.critical}</div><div class="stat-label">Critical</div></div>
<div class="stat-card error"><div class="stat-number">${sc.error}</div><div class="stat-label">Errors</div></div>
<div class="stat-card warning"><div class="stat-number">${sc.warning}</div><div class="stat-label">Warnings</div></div>
<div class="stat-card info"><div class="stat-number">${sc.info}</div><div class="stat-label">Info</div></div>
<div class="stat-card success"><div class="stat-number">${sc.success}</div><div class="stat-label">Success</div></div>
`;
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) {
const issuesList = document.getElementById('issuesList');
if (issues.length === 0) {
issuesList.innerHTML = '<p style="text-align:center;color:var(--text-light);padding:40px;">No issues to display</p>';
return;
}
const pageGroups = {};
const documentWide = [];
issues.forEach(issue => {
if (issue.page_number) {
if (!pageGroups[issue.page_number]) pageGroups[issue.page_number] = [];
pageGroups[issue.page_number].push(issue);
} else {
documentWide.push(issue);
}
});
// Assign issue numbers for coordinate-based issues
let counter = 0;
const issueNumberMap = new Map();
issues.forEach(issue => {
if (issue.coordinates && issue.page_number) {
counter++;
issueNumberMap.set(issue, counter);
}
});
const pageNumbers = Object.keys(pageGroups).map(Number).sort((a, b) => a - b);
// Page overview
let html = '';
if (pageNumbers.length > 0) {
html += '<div style="background:var(--surface);padding:15px;border-radius:8px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.1);">';
html += '<h3 style="margin-bottom:10px;font-size:16px;font-weight:600;">Page Overview</h3>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(55px,1fr));gap:8px;">';
pageNumbers.forEach(pn => {
const pi = pageGroups[pn];
const crit = pi.filter(i => i.severity === 'CRITICAL').length;
const err = pi.filter(i => i.severity === 'ERROR').length;
const warn = pi.filter(i => i.severity === 'WARNING').length;
let bg = '#10b981';
if (crit > 0) bg = '#dc2626'; else if (err > 0) bg = '#ef4444'; else if (warn > 0) bg = '#f59e0b';
html += `<div onclick="scrollToPage(${pn})" style="cursor:pointer;background:${bg};color:${warn > 0 && !crit && !err ? 'black' : 'white'};padding:10px 8px;border-radius:6px;text-align:center;font-weight:600;" aria-label="Page ${pn}, ${pi.length} issues">
<div style="font-size:10px;opacity:0.9;">Page</div>
<div style="font-size:18px;">${pn}</div>
<div style="font-size:10px;margin-top:3px;">${pi.length} issue${pi.length !== 1 ? 's' : ''}</div>
</div>`;
});
html += '</div></div>';
}
// Document-wide issues
if (documentWide.length > 0) {
html += `<div id="page-document" style="margin-bottom:30px;">
<h3 style="font-size:18px;margin-bottom:10px;padding:10px 12px;background:var(--surface-alt);border-radius:6px;cursor:pointer;" onclick="togglePageSection('document')" aria-expanded="true">
Document-Wide Issues (${documentWide.length}) <span id="toggle-document" style="float:right;">&#9660;</span>
</h3>
<div id="section-document" class="issues-grid">${documentWide.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}</div>
</div>`;
}
// Page-specific issues
pageNumbers.forEach(pn => {
const pi = pageGroups[pn];
const crit = pi.filter(i => i.severity === 'CRITICAL').length;
const err = pi.filter(i => i.severity === 'ERROR').length;
const warn = pi.filter(i => i.severity === 'WARNING').length;
html += `<div id="page-${pn}" style="margin-bottom:20px;">
<h3 style="font-size:18px;margin-bottom:10px;padding:10px 12px;background:var(--surface-alt);border-radius:6px;cursor:pointer;" onclick="togglePageSection(${pn})" aria-expanded="true">
Page ${pn} - ${pi.length} Issue${pi.length !== 1 ? 's' : ''}
${crit > 0 ? `<span style="background:#dc2626;color:white;padding:2px 6px;border-radius:10px;font-size:11px;margin-left:8px;">${crit} Critical</span>` : ''}
${err > 0 ? `<span style="background:#ef4444;color:white;padding:2px 6px;border-radius:10px;font-size:11px;margin-left:8px;">${err} Error${err !== 1 ? 's' : ''}</span>` : ''}
${warn > 0 ? `<span style="background:#f59e0b;color:white;padding:2px 6px;border-radius:10px;font-size:11px;margin-left:8px;">${warn} Warning${warn !== 1 ? 's' : ''}</span>` : ''}
<span id="toggle-${pn}" style="float:right;">&#9660;</span>
</h3>
<div id="section-${pn}" class="issues-grid">${pi.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}</div>
</div>`;
});
issuesList.innerHTML = html;
}
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
? `<button onclick="viewOnPage(${issue.page_number}, ${issueNumber})" class="btn-dismiss" style="background:var(--accent);color:var(--accent-text);border:none;" title="View on page">&#x1F4CD; #${issueNumber}</button>`
: '';
const dismissBtn = isDismissed
? `<button class="btn-undismiss" onclick="undismissIssue(${globalIndex})" title="Restore this issue">&#x21A9; Restore</button>`
: `<button class="btn-dismiss" onclick="dismissIssue(${globalIndex})" title="Mark as false positive / reviewed">&#x2715; False Positive</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>
<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>` : ''}
${issue.recommendation ? `<div class="issue-recommendation"><strong>Tip:</strong> ${issue.recommendation}</div>` : ''}
</div>`;
}
function togglePageSection(pageNum) {
const section = document.getElementById(`section-${pageNum}`);
const toggle = document.getElementById(`toggle-${pageNum}`);
const header = toggle.closest('h3');
if (section.style.display === 'none') {
section.style.display = 'grid';
toggle.innerHTML = '&#9660;';
if (header) header.setAttribute('aria-expanded', 'true');
} else {
section.style.display = 'none';
toggle.innerHTML = '&#9654;';
if (header) header.setAttribute('aria-expanded', 'false');
}
}
function scrollToPage(pageNum) {
const el = document.getElementById(`page-${pageNum}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
el.style.background = 'var(--accent-subtle)';
setTimeout(() => { el.style.background = ''; }, 1000);
}
}
function filterIssues(severity) {
currentFilter = severity;
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
if (event && event.target) event.target.classList.add('active');
const filtered = severity === 'all' ? allIssues : allIssues.filter(i => i.severity === severity);
displayIssues(filtered);
}
/* Remediation */
function displayRemediationOptions(data) {
if (!data.remediation_suggestions || data.auto_fixable_count === 0) return;
document.getElementById('remediationCard').style.display = 'block';
document.getElementById('fixableCount').textContent = data.auto_fixable_count;
const fixesList = document.getElementById('fixesList');
let html = '<div style="background:var(--success-bg);padding:12px;border-radius:6px;border-left:3px solid var(--success);">';
for (const [, fixes] of Object.entries(data.remediation_suggestions)) {
fixes.filter(f => f.auto_fixable).forEach(fix => {
const ic = { ERROR: '\u274C', WARNING: '\u26A0\uFE0F', INFO: '\u2139\uFE0F', CRITICAL: '\u{1F6A8}' };
html += `<div style="margin-bottom:8px;display:flex;align-items:start;gap:8px;">
<span style="font-size:16px;">${ic[fix.severity] || '\u{1F527}'}</span>
<div style="flex:1;"><div style="font-weight:600;font-size:13px;">${fix.description}</div>
<div style="font-size:12px;color:var(--text-light);margin-top:2px;">Will set: ${fix.suggestion}</div></div>
</div>`;
});
}
html += '</div>';
fixesList.innerHTML = html;
}
async function applyFixes() {
const btn = document.getElementById('applyFixesBtn');
const resultDiv = document.getElementById('fixResult');
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span> Applying fixes...';
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div style="padding:10px;background:var(--info-bg);border-radius:6px;color:var(--text);">Applying automatic fixes to PDF...</div>';
try {
const result = await remediatePdf(currentJobId);
if (result.success) {
resultDiv.innerHTML = `<div style="padding:15px;background:var(--success-bg);border-radius:6px;border-left:3px solid var(--success);">
<div style="font-weight:600;margin-bottom:8px;color:var(--success);">${result.data.fixes_applied} issue(s) automatically fixed!</div>
<div style="font-size:14px;margin-bottom:12px;color:var(--text);">Your remediated PDF is ready for download.</div>
<a href="${result.data.download_url}" class="btn btn-primary" download style="text-decoration:none;display:inline-block;">Download Fixed PDF</a>
<div style="margin-top:10px;font-size:12px;color:var(--text-light);">Filename: ${result.data.original_filename.replace('.pdf', '_fixed.pdf')}</div>
</div>`;
btn.style.display = 'none';
} else {
resultDiv.innerHTML = `<div style="padding:15px;background:var(--error-bg);border-radius:6px;border-left:3px solid var(--error);">
<div style="font-weight:600;color:var(--error);">Remediation failed</div>
<div style="font-size:13px;margin-top:5px;">${result.error}</div>
</div>`;
btn.disabled = false;
btn.innerHTML = '<span>Retry Auto-Fix</span>';
}
} catch (error) {
resultDiv.innerHTML = `<div style="padding:15px;background:var(--error-bg);border-radius:6px;border-left:3px solid var(--error);">
<div style="font-weight:600;color:var(--error);">Error</div>
<div style="font-size:13px;margin-top:5px;">${error.message}</div>
</div>`;
btn.disabled = false;
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, markerNum);
}
function displayScoreBreakdown(breakdown) {
const el = document.getElementById('scoreBreakdown');
if (!el || !breakdown) return;
const pct = breakdown.checks_total > 0
? Math.round(100 * breakdown.checks_passed / breakdown.checks_total)
: 0;
el.innerHTML = `
<details class="score-breakdown">
<summary>${breakdown.checks_passed} of ${breakdown.checks_total} checks passed &nbsp;·&nbsp; Base: ${breakdown.base_score}% &nbsp;·&nbsp; Penalty: ${breakdown.penalty} &nbsp;·&nbsp; Score: ${breakdown.final_score}</summary>
<table class="score-breakdown-table">
<thead><tr><th>Check</th><th>Result</th></tr></thead>
<tbody>
${breakdown.per_check.map(c => `
<tr>
<td>${c.name}</td>
<td style="color:${c.passed ? 'var(--success)' : 'var(--error)'}; font-weight:700;">
${c.passed ? '✓ Pass' : '✗ Fail'}
</td>
</tr>`).join('')}
</tbody>
</table>
</details>`;
}
function displayMatterhorn(summary) {
const card = document.getElementById('matterhornCard');
const banner = document.getElementById('matterhornBanner');
const body = document.getElementById('matterhornBody');
if (!card || !summary) return;
card.style.display = 'block';
banner.innerHTML = summary.overall_passed
? `<div class="matterhorn-banner pass">&#x2705; PDF/UA-1 requirements fulfilled</div>`
: `<div class="matterhorn-banner fail">&#x274C; PDF/UA-1 requirements NOT fulfilled</div>`;
const sections = [
{ label: 'Basic Requirements', ids: ['01','02','03','04','05','06','07','08'] },
{ label: 'Logical Structure', ids: ['09','10','11','12','13','14','15','16','17','18','19','20'] },
{ label: 'Document Elements', ids: ['21','22','23','24','25','26','27','28','29','30','31'] },
];
const cpMap = {};
summary.checkpoints.forEach(cp => { cpMap[cp.id] = cp; });
let html = '';
sections.forEach(section => {
html += `<tr class="section-header"><td colspan="3">${section.label}</td></tr>`;
section.ids.forEach(id => {
const cp = cpMap[id];
if (!cp) return;
const statusHtml = cp.status === 'PASS'
? `<span class="mh-pass">&#x2713; PASS</span>`
: cp.status === 'FAIL'
? `<span class="mh-fail">&#x2717; FAIL</span>`
: `<span class="mh-not-tested">— Not tested</span>`;
const howBadge = cp.how === 'M'
? `<span class="badge-m">M</span>`
: `<span class="badge-h">H</span>`;
html += `<tr>
<td><strong>CP${cp.id}</strong> ${cp.name}</td>
<td>${howBadge}</td>
<td>${statusHtml}</td>
</tr>`;
});
});
body.innerHTML = html;
}
async function dismissIssue(globalIndex) {
try {
const resp = await fetch('api.php?action=dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId, issue_index: globalIndex })
});
const result = await resp.json();
if (result.success) {
dismissedIndices.add(globalIndex);
const el = document.getElementById('issue-g' + globalIndex);
if (el) {
el.classList.add('dismissed');
el.querySelector('.issue-description').style.textDecoration = 'line-through';
const btn = el.querySelector('.btn-dismiss');
if (btn) { btn.className = 'btn-undismiss'; btn.textContent = 'Restore'; btn.setAttribute('onclick', `undismissIssue(${globalIndex})`); }
}
updateDismissCount();
}
} catch(e) { console.error('Dismiss failed:', e); }
}
async function undismissIssue(globalIndex) {
try {
const resp = await fetch('api.php?action=undismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId, issue_index: globalIndex })
});
const result = await resp.json();
if (result.success) {
dismissedIndices.delete(globalIndex);
const el = document.getElementById('issue-g' + globalIndex);
if (el) {
el.classList.remove('dismissed');
el.querySelector('.issue-description').style.textDecoration = '';
const btn = el.querySelector('.btn-undismiss');
if (btn) { btn.className = 'btn-dismiss'; btn.textContent = 'Dismiss'; btn.setAttribute('onclick', `dismissIssue(${globalIndex})`); }
}
updateDismissCount();
}
} catch(e) { console.error('Undismiss failed:', e); }
}
function updateDismissCount() {
const countEl = document.getElementById('dismissedCount');
if (countEl) countEl.textContent = dismissedIndices.size;
}