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>
366 lines
18 KiB
JavaScript
366 lines
18 KiB
JavaScript
/* 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;">▼</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;">▼</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">📍 #${issueNumber}</button>`
|
||
: '';
|
||
|
||
const dismissBtn = isDismissed
|
||
? `<button class="btn-undismiss" onclick="undismissIssue(${globalIndex})" title="Restore this issue">↩ Restore</button>`
|
||
: `<button class="btn-dismiss" onclick="dismissIssue(${globalIndex})" title="Mark as false positive / reviewed">✕ 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 = '▼';
|
||
if (header) header.setAttribute('aria-expanded', 'true');
|
||
} else {
|
||
section.style.display = 'none';
|
||
toggle.innerHTML = '▶';
|
||
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 · 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;
|
||
}
|