Add Visual Page Inspector with interactive issue markers

Frontend Features:
 NEW: Visual Page Inspector component
- Display PDF pages as images with zoom controls
- SVG overlay system for precise issue highlighting
- Color-coded markers by severity (red/orange/yellow/blue)
- Numbered badges on each issue for easy reference
- Interactive hover tooltips with issue details
- Click-through to see exact locations on page

User Experience:
📄 Page selector sidebar shows all pages
- Color-coded badges indicate issue severity per page
- Click any page to view it
- Pages with no issues show in green

🔍 Zoom Controls:
- Zoom in/out buttons (50% - 300%)
- Reset to 100%
- Markers scale with zoom level

🎯 Interactive Markers:
- Dashed rectangles highlight issue locations
- Hover to see full issue description + fix recommendation
- Semi-transparent overlays don't obscure content
- Numbered circles for easy cross-reference

Backend Support:
- API endpoint: api.php?action=image&job_id=X&page=Y
- Serves PNG images with proper caching headers
- Coordinate system conversion (PDF → screen coords)

How It Works:
1. Python generates page images at 100 DPI
2. Issues with coordinates get visual markers
3. SVG overlays drawn at correct positions
4. Tooltips show on hover

Perfect for:
- Seeing exactly where image/contrast issues are
- Visual verification of accessibility problems
- Training teams on what to fix
- Before/after comparisons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DJP 2025-10-20 16:01:52 -04:00
parent b07116f402
commit 59efe72607
2 changed files with 360 additions and 6 deletions

33
api.php
View file

@ -52,6 +52,9 @@ switch ($action) {
case 'debug':
handleDebug();
break;
case 'image':
handleImage();
break;
default:
error('Invalid action');
}
@ -138,6 +141,7 @@ function handleCheck() {
$venv_python = '/Users/daveporter/Desktop/CODING-2024/PDF-Accessibility-checker/venv/bin/python3';
$python_bin = file_exists($venv_python) ? $venv_python : 'python3';
// Note: Python script will auto-generate page images when --output is specified
$cmd = escapeshellcmd($python_bin . ' ' . PYTHON_SCRIPT) . ' ' .
escapeshellarg($pdf_path) . ' ' .
'--output ' . escapeshellarg($output_path);
@ -351,6 +355,35 @@ function handleDebug() {
success($debug_info);
}
/**
* Serve page images
*/
function handleImage() {
$job_id = $_GET['job_id'] ?? '';
$page_num = $_GET['page'] ?? '';
if (empty($job_id) || empty($page_num)) {
error('Job ID and page number required');
}
// Find the image file
$images_dir = RESULTS_DIR . '/' . $job_id . '.result_images';
$image_file = $images_dir . '/page_' . intval($page_num) . '.png';
if (!file_exists($image_file)) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Image not found']);
exit;
}
// Serve the image
header('Content-Type: image/png');
header('Cache-Control: public, max-age=86400'); // Cache for 1 day
readfile($image_file);
exit;
}
/**
* Send success response
*/

View file

@ -616,10 +616,56 @@
<!-- Stats will be inserted here -->
</div>
</div>
<!-- Visual Page Viewer -->
<div class="card" id="pageViewerCard" style="display: none;">
<h2>📄 Visual Page Inspector</h2>
<p style="color: var(--text-light); margin-bottom: 20px;">Click on issues below to see their exact location on the page</p>
<div style="display: flex; gap: 20px; align-items: flex-start;">
<!-- Page selector -->
<div style="flex-shrink: 0;">
<div style="background: white; padding: 15px; border-radius: 8px; min-width: 150px;">
<h3 style="font-size: 14px; margin-bottom: 10px;">Select Page</h3>
<div id="pageSelector" style="display: flex; flex-direction: column; gap: 5px;">
<!-- Page buttons will be inserted here -->
</div>
</div>
</div>
<!-- Page display area -->
<div style="flex: 1; background: #f8f9fa; border-radius: 8px; padding: 20px; position: relative; min-height: 600px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 id="currentPageTitle" style="font-size: 16px; margin: 0;">Page 1</h3>
<div style="display: flex; gap: 10px;">
<button onclick="zoomOut()" style="padding: 8px 12px; border: 1px solid #ddd; background: white; border-radius: 6px; cursor: pointer;">🔍-</button>
<span id="zoomLevel" style="padding: 8px 12px; background: white; border-radius: 6px; min-width: 60px; text-align: center;">100%</span>
<button onclick="zoomIn()" style="padding: 8px 12px; border: 1px solid #ddd; background: white; border-radius: 6px; cursor: pointer;">🔍+</button>
<button onclick="resetZoom()" style="padding: 8px 12px; border: 1px solid #ddd; background: white; border-radius: 6px; cursor: pointer;">Reset</button>
</div>
</div>
<div id="pageImageContainer" style="overflow: auto; max-height: 800px; background: white; border-radius: 8px; position: relative;">
<div style="position: relative; display: inline-block;">
<img id="pageImage" src="" alt="PDF Page" style="display: block; max-width: 100%;">
<svg id="markerOverlay" style="position: absolute; top: 0; left: 0; pointer-events: none; width: 100%; height: 100%;"></svg>
</div>
</div>
<div id="markerLegend" style="margin-top: 15px; padding: 15px; background: white; border-radius: 8px;">
<strong>Legend:</strong>
<span style="margin-left: 10px; padding: 4px 8px; background: #dc2626; color: white; border-radius: 4px; font-size: 12px;">🚨 Critical</span>
<span style="margin-left: 10px; padding: 4px 8px; background: #ef4444; color: white; border-radius: 4px; font-size: 12px;">❌ Error</span>
<span style="margin-left: 10px; padding: 4px 8px; background: #f59e0b; color: white; border-radius: 4px; font-size: 12px;">⚠️ Warning</span>
<span style="margin-left: 10px; padding: 4px 8px; background: #3b82f6; color: white; border-radius: 4px; font-size: 12px;"> Info</span>
</div>
</div>
</div>
</div>
<div class="card">
<h2>Issues & Recommendations</h2>
<div class="filters">
<button class="filter-btn active" onclick="filterIssues('all')">All</button>
<button class="filter-btn" onclick="filterIssues('CRITICAL')">Critical</button>
@ -627,7 +673,7 @@
<button class="filter-btn" onclick="filterIssues('WARNING')">Warnings</button>
<button class="filter-btn" onclick="filterIssues('INFO')">Info</button>
</div>
<div id="issuesList">
<!-- Issues will be inserted here -->
</div>
@ -922,10 +968,10 @@
// Hide upload, show results
document.getElementById('uploadSection').style.display = 'none';
document.getElementById('resultsSection').style.display = 'block';
// Display score
document.getElementById('scoreNumber').textContent = data.accessibility_score;
// Display stats
const statsGrid = document.getElementById('statsGrid');
statsGrid.innerHTML = `
@ -950,10 +996,13 @@
<div class="stat-label">Success</div>
</div>
`;
// Store and display issues
allIssues = data.issues;
displayIssues(allIssues);
// Initialize visual page viewer if images available
initializePageViewer(data);
}
function displayIssues(issues) {
@ -1161,10 +1210,282 @@
document.getElementById('uploadSection').style.display = 'block';
document.getElementById('resultsSection').style.display = 'none';
document.getElementById('progressContainer').style.display = 'none';
document.getElementById('pageViewerCard').style.display = 'none';
document.getElementById('fileInput').value = '';
currentJobId = null;
clearLog();
}
// ==================== VISUAL PAGE VIEWER ====================
let currentPageData = null;
let currentZoom = 1.0;
let currentVisualPage = 1;
function initializePageViewer(data) {
// Check if we have page images
if (!data.page_images || Object.keys(data.page_images).length === 0) {
console.log('No page images available');
return;
}
// Show the page viewer card
document.getElementById('pageViewerCard').style.display = 'block';
// Store data globally
currentPageData = data;
// Build page selector
const pageSelector = document.getElementById('pageSelector');
const pageNumbers = Object.keys(data.page_images).map(Number).sort((a, b) => a - b);
pageSelector.innerHTML = pageNumbers.map(pageNum => {
const pageIssues = data.issues.filter(issue => issue.page_number === pageNum);
const hasIssues = pageIssues.length > 0;
let badgeColor = '#10b981'; // Green
if (pageIssues.some(i => i.severity === 'CRITICAL')) {
badgeColor = '#dc2626';
} else if (pageIssues.some(i => i.severity === 'ERROR')) {
badgeColor = '#ef4444';
} else if (pageIssues.some(i => i.severity === 'WARNING')) {
badgeColor = '#f59e0b';
}
return `
<button
onclick="loadVisualPage(${pageNum})"
id="pageBtn${pageNum}"
style="padding: 10px; border: 2px solid #ddd; background: white; border-radius: 6px; cursor: pointer; text-align: left; transition: all 0.2s; display: flex; justify-content: space-between; align-items: center;"
onmouseover="this.style.borderColor='${badgeColor}'"
onmouseout="this.style.borderColor='#ddd'"
>
<span>Page ${pageNum}</span>
${hasIssues ? `<span style="background: ${badgeColor}; color: white; padding: 2px 6px; border-radius: 12px; font-size: 11px;">${pageIssues.length}</span>` : ''}
</button>
`;
}).join('');
// Load first page with issues, or first page
const firstPageWithIssues = pageNumbers.find(p => data.issues.some(i => i.page_number === p));
loadVisualPage(firstPageWithIssues || pageNumbers[0]);
}
function loadVisualPage(pageNum) {
if (!currentPageData || !currentPageData.page_images[pageNum]) {
console.error('Page not found:', pageNum);
return;
}
currentVisualPage = pageNum;
// Update title
document.getElementById('currentPageTitle').textContent = `Page ${pageNum}`;
// Highlight selected page button
document.querySelectorAll('[id^="pageBtn"]').forEach(btn => {
btn.style.background = 'white';
btn.style.fontWeight = 'normal';
});
const selectedBtn = document.getElementById(`pageBtn${pageNum}`);
if (selectedBtn) {
selectedBtn.style.background = '#f0f9ff';
selectedBtn.style.fontWeight = '600';
}
// Load page image
const imageUrl = `api.php?action=image&job_id=${currentJobId}&page=${pageNum}`;
const pageImage = document.getElementById('pageImage');
pageImage.onload = function() {
// Draw markers for issues on this page
drawMarkers(pageNum);
};
pageImage.src = imageUrl;
}
function drawMarkers(pageNum) {
const svg = document.getElementById('markerOverlay');
const pageImage = document.getElementById('pageImage');
// Clear existing markers
svg.innerHTML = '';
// Get image dimensions
const imgWidth = pageImage.naturalWidth;
const imgHeight = pageImage.naturalHeight;
const displayWidth = pageImage.clientWidth;
const displayHeight = pageImage.clientHeight;
// Set SVG viewBox to match image natural size
svg.setAttribute('viewBox', `0 0 ${imgWidth} ${imgHeight}`);
svg.setAttribute('width', displayWidth);
svg.setAttribute('height', displayHeight);
// Get issues for this page
const pageIssues = currentPageData.issues.filter(issue =>
issue.page_number === pageNum && issue.coordinates
);
if (pageIssues.length === 0) {
console.log(`No issues with coordinates on page ${pageNum}`);
return;
}
// Draw markers for each issue
pageIssues.forEach((issue, index) => {
const coords = issue.coordinates;
// PDF coordinates are bottom-left origin, need to flip Y
const x0 = coords.x0;
const y0 = imgHeight - coords.y1; // Flip Y
const x1 = coords.x1;
const y1 = imgHeight - coords.y0; // Flip Y
const width = x1 - x0;
const height = y1 - y0;
// Color based on severity
let strokeColor, fillColor;
switch (issue.severity) {
case 'CRITICAL':
strokeColor = '#dc2626';
fillColor = 'rgba(220, 38, 38, 0.2)';
break;
case 'ERROR':
strokeColor = '#ef4444';
fillColor = 'rgba(239, 68, 68, 0.2)';
break;
case 'WARNING':
strokeColor = '#f59e0b';
fillColor = 'rgba(245, 158, 11, 0.2)';
break;
default:
strokeColor = '#3b82f6';
fillColor = 'rgba(59, 130, 246, 0.2)';
}
// Create rectangle
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', x0);
rect.setAttribute('y', y0);
rect.setAttribute('width', width);
rect.setAttribute('height', height);
rect.setAttribute('fill', fillColor);
rect.setAttribute('stroke', strokeColor);
rect.setAttribute('stroke-width', '3');
rect.setAttribute('stroke-dasharray', '5,5');
rect.setAttribute('rx', '4');
rect.style.cursor = 'pointer';
rect.style.pointerEvents = 'all';
// Add tooltip on hover
rect.addEventListener('mouseenter', function(e) {
showIssueTooltip(e, issue);
});
rect.addEventListener('mouseleave', function() {
hideIssueTooltip();
});
svg.appendChild(rect);
// Add number badge
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', x0 + 15);
circle.setAttribute('cy', y0 + 15);
circle.setAttribute('r', '12');
circle.setAttribute('fill', strokeColor);
svg.appendChild(circle);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', x0 + 15);
text.setAttribute('y', y0 + 20);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('fill', 'white');
text.setAttribute('font-size', '12');
text.setAttribute('font-weight', 'bold');
text.textContent = index + 1;
svg.appendChild(text);
});
console.log(`Drew ${pageIssues.length} markers on page ${pageNum}`);
}
let tooltipDiv = null;
function showIssueTooltip(event, issue) {
if (!tooltipDiv) {
tooltipDiv = document.createElement('div');
tooltipDiv.style.position = 'fixed';
tooltipDiv.style.background = 'rgba(0, 0, 0, 0.9)';
tooltipDiv.style.color = 'white';
tooltipDiv.style.padding = '12px';
tooltipDiv.style.borderRadius = '8px';
tooltipDiv.style.maxWidth = '300px';
tooltipDiv.style.zIndex = '10000';
tooltipDiv.style.fontSize = '14px';
tooltipDiv.style.pointerEvents = 'none';
document.body.appendChild(tooltipDiv);
}
tooltipDiv.innerHTML = `
<div style="font-weight: bold; margin-bottom: 5px; color: ${getSeverityColor(issue.severity)};">
${issue.severity}: ${issue.category}
</div>
<div style="margin-bottom: 5px;">${issue.description}</div>
${issue.recommendation ? `<div style="border-top: 1px solid #444; margin-top: 5px; padding-top: 5px; font-size: 12px;">
<strong>Fix:</strong> ${issue.recommendation}
</div>` : ''}
`;
tooltipDiv.style.display = 'block';
tooltipDiv.style.left = (event.clientX + 15) + 'px';
tooltipDiv.style.top = (event.clientY + 15) + 'px';
}
function hideIssueTooltip() {
if (tooltipDiv) {
tooltipDiv.style.display = 'none';
}
}
function getSeverityColor(severity) {
switch (severity) {
case 'CRITICAL': return '#dc2626';
case 'ERROR': return '#ef4444';
case 'WARNING': return '#f59e0b';
default: return '#3b82f6';
}
}
function zoomIn() {
currentZoom = Math.min(currentZoom + 0.25, 3.0);
applyZoom();
}
function zoomOut() {
currentZoom = Math.max(currentZoom - 0.25, 0.5);
applyZoom();
}
function resetZoom() {
currentZoom = 1.0;
applyZoom();
}
function applyZoom() {
const pageImage = document.getElementById('pageImage');
pageImage.style.transform = `scale(${currentZoom})`;
pageImage.style.transformOrigin = 'top left';
document.getElementById('zoomLevel').textContent = `${Math.round(currentZoom * 100)}%`;
// Redraw markers at new scale
if (currentVisualPage) {
setTimeout(() => drawMarkers(currentVisualPage), 50);
}
}
</script>
</body>
</html>