pdf-accessibility/index.html
DJP fcd329ada8 Compact UI and fix zoom bug
UX Improvements:
 Multi-column grid layout for issues (2-3 columns on wide screens)
- Issues now display in columns using CSS Grid
- Reduces scrolling by 50-70% on large reports
- Automatically responsive (1 column on mobile)

📏 Reduced white space throughout:
- Issue cards: 20px → 10px padding
- Card margins: 30px → 20px
- Section headers: 20px → 10px padding
- Smaller fonts and tighter spacing
- Page overview cards more compact

🔍 Fixed zoom bug:
- Wrapped image + SVG in zoomContainer
- Apply transform to container, not just image
- SVG markers now scale perfectly with zoom
- No redrawing needed - automatic scaling!

Before: ~40px per issue → Now: ~25px per issue
Result: 20 issues fit in ~500px vs ~800px

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 17:46:27 -04:00

1514 lines
60 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enterprise PDF Accessibility Checker</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #FFC407;
--primary-dark: #e6b006;
--primary-darker: #cc9d05;
--black: #000000;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--critical: #dc2626;
--info: #3b82f6;
--bg: #fafafa;
--surface: #ffffff;
--text: #000000;
--text-light: #4a4a4a;
--border: #e0e0e0;
}
body {
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
background: var(--black);
border-bottom: 3px solid var(--primary);
padding: 30px 0;
margin-bottom: 40px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
h1 {
font-size: 32px;
font-weight: 700;
color: var(--primary);
margin-bottom: 10px;
}
.subtitle {
font-size: 16px;
color: #ffffff;
}
.card {
background: var(--surface);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.card h2 {
font-size: 20px;
margin-bottom: 15px;
color: var(--text);
}
/* Upload Area */
.upload-area {
border: 3px dashed var(--border);
border-radius: 12px;
padding: 60px 40px;
text-align: center;
transition: all 0.3s;
cursor: pointer;
}
.upload-area.dragover {
border-color: var(--primary);
background: rgba(37, 99, 235, 0.05);
}
.upload-area input[type="file"] {
display: none;
}
.upload-icon {
font-size: 64px;
margin-bottom: 20px;
color: var(--black);
}
.upload-text {
font-size: 18px;
margin-bottom: 10px;
color: var(--text);
}
.upload-hint {
font-size: 14px;
color: var(--text-light);
}
/* Buttons */
.btn {
display: inline-block;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
}
.btn-primary {
background: var(--black);
color: var(--primary);
border: 2px solid var(--primary);
}
.btn-primary:hover {
background: var(--primary);
color: var(--black);
}
.btn-secondary {
background: var(--border);
color: var(--text);
}
.btn-secondary:hover {
background: #cbd5e1;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Progress */
.progress-container {
display: none;
padding: 20px;
background: #f1f5f9;
border-radius: 8px;
margin-top: 20px;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.progress-text {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.progress-percent {
font-size: 20px;
font-weight: 700;
color: var(--primary);
}
.progress-bar {
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
margin-bottom: 20px;
}
.progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.3s;
}
.progress-log {
background: white;
border: 2px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.log-header {
background: var(--black);
color: var(--primary);
padding: 12px 16px;
font-weight: 700;
font-size: 14px;
border-bottom: 2px solid var(--primary);
}
.log-content {
padding: 16px;
max-height: 300px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.8;
}
.log-entry {
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 4px;
background: #f8f9fa;
border-left: 3px solid #ddd;
animation: slideIn 0.3s ease-out;
}
.log-entry.success {
background: rgba(16, 185, 129, 0.1);
border-left-color: var(--success);
color: #065f46;
}
.log-entry.warning {
background: rgba(245, 158, 11, 0.1);
border-left-color: var(--warning);
color: #92400e;
}
.log-entry.error {
background: rgba(239, 68, 68, 0.1);
border-left-color: var(--error);
color: #991b1b;
}
.log-entry.info {
background: rgba(59, 130, 246, 0.1);
border-left-color: var(--info);
color: #1e40af;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Results */
.results {
display: none;
}
.score-display {
text-align: center;
padding: 40px;
background: linear-gradient(135deg, var(--black) 0%, #1a1a1a 50%, var(--black) 100%);
border: 3px solid var(--primary);
border-radius: 12px;
color: white;
margin-bottom: 30px;
position: relative;
overflow: hidden;
}
.score-display::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--primary);
}
.score-number {
font-size: 72px;
font-weight: 700;
margin-bottom: 10px;
}
.score-label {
font-size: 20px;
opacity: 0.9;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
padding: 20px;
border-radius: 8px;
text-align: center;
}
.stat-card.critical {
background: rgba(220, 38, 38, 0.1);
border: 2px solid var(--critical);
}
.stat-card.error {
background: rgba(239, 68, 68, 0.1);
border: 2px solid var(--error);
}
.stat-card.warning {
background: rgba(245, 158, 11, 0.1);
border: 2px solid var(--warning);
}
.stat-card.info {
background: rgba(59, 130, 246, 0.1);
border: 2px solid var(--info);
}
.stat-card.success {
background: rgba(16, 185, 129, 0.1);
border: 2px solid var(--success);
}
.stat-number {
font-size: 36px;
font-weight: 700;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Issues */
.issue {
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 6px;
border-left: 3px solid;
}
/* Multi-column layout for issues */
.issues-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(480px, 1fr));
gap: 10px;
}
@media (max-width: 1200px) {
.issues-grid {
grid-template-columns: 1fr;
}
}
.issue.CRITICAL {
background: rgba(220, 38, 38, 0.05);
border-left-color: var(--critical);
}
.issue.ERROR {
background: rgba(239, 68, 68, 0.05);
border-left-color: var(--error);
}
.issue.WARNING {
background: rgba(245, 158, 11, 0.05);
border-left-color: var(--warning);
}
.issue.INFO {
background: rgba(59, 130, 246, 0.05);
border-left-color: var(--info);
}
.issue.SUCCESS {
background: rgba(16, 185, 129, 0.05);
border-left-color: var(--success);
}
.issue-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 10px;
}
.issue-category {
font-weight: 700;
font-size: 16px;
color: var(--text);
}
.issue-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.issue-badge.CRITICAL { background: var(--critical); color: white; }
.issue-badge.ERROR { background: var(--error); color: white; }
.issue-badge.WARNING { background: var(--warning); color: white; }
.issue-badge.INFO { background: var(--info); color: white; }
.issue-badge.SUCCESS { background: var(--success); color: white; }
.issue-description {
color: var(--text);
margin-bottom: 6px;
line-height: 1.4;
font-size: 14px;
}
.issue-meta {
display: flex;
gap: 15px;
font-size: 12px;
color: var(--text-light);
margin-bottom: 6px;
}
.issue-recommendation {
background: #f0fdf4;
padding: 8px 10px;
border-radius: 4px;
border-left: 2px solid var(--success);
font-size: 13px;
color: var(--text);
margin-top: 6px;
}
.issue-recommendation strong {
color: var(--success);
}
/* Filters */
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-btn {
padding: 8px 16px;
border: 2px solid var(--border);
border-radius: 6px;
background: white;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.filter-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.filter-btn:hover {
border-color: var(--primary);
}
/* Loading */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 10px;
}
h1 {
font-size: 24px;
}
.card {
padding: 20px;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
}
.hidden {
display: none !important;
}
.api-config {
margin-top: 20px;
padding: 20px;
background: #f1f5f9;
border-radius: 8px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
}
.help-text {
font-size: 12px;
color: var(--text-light);
margin-top: 5px;
}
</style>
</head>
<body>
<header>
<div class="container">
<h1>🔍 Enterprise PDF Accessibility Checker</h1>
<p class="subtitle">Comprehensive WCAG 2.1 compliance validation with AI-powered analysis</p>
</div>
</header>
<div class="container">
<!-- Upload Section -->
<div class="card" id="uploadSection">
<h2>Upload PDF Document</h2>
<div class="upload-area" id="uploadArea">
<div class="upload-icon">📄</div>
<div class="upload-text">Drop your PDF here or click to browse</div>
<div class="upload-hint">Maximum file size: 50MB</div>
<input type="file" id="fileInput" accept=".pdf">
</div>
<div class="api-config">
<h3 style="margin-bottom: 15px;">Check Options</h3>
<div class="form-group" style="display: flex; align-items: center; gap: 10px; margin-bottom: 20px;">
<input type="checkbox" id="quickMode" style="width: auto; height: 18px; cursor: pointer;">
<label for="quickMode" style="cursor: pointer; margin: 0; font-weight: 600;">
⚡ Quick Mode (Skip AI analysis, OCR, and color contrast)
</label>
</div>
<div class="help-text" style="margin-top: -15px; margin-bottom: 20px;">
Quick mode runs basic checks only - great for initial scans. Completes in ~10 seconds vs ~2 minutes.
</div>
<div style="background: #f0f9ff; border-left: 3px solid #3b82f6; padding: 15px; border-radius: 6px; margin-top: 20px;">
<div style="font-weight: 600; margin-bottom: 5px;">🔑 API Keys</div>
<div style="font-size: 14px; color: #4a4a4a;">
API keys are configured in the <code style="background: white; padding: 2px 6px; border-radius: 3px;">.env</code> file on the server.
<br>Edit <code style="background: white; padding: 2px 6px; border-radius: 3px;">.env</code> to add your Anthropic and Google API keys.
</div>
</div>
</div>
<div class="progress-container" id="progressContainer">
<div class="progress-header">
<div class="progress-text" id="progressText">Uploading...</div>
<div class="progress-percent" id="progressPercent">0%</div>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill" style="width: 0%"></div>
</div>
<!-- Debug/Progress Log -->
<div class="progress-log" id="progressLog">
<div class="log-header">🔍 Processing Details</div>
<div class="log-content" id="logContent">
<div class="log-entry">⏳ Initializing...</div>
</div>
</div>
</div>
</div>
<!-- Results Section -->
<div class="results" id="resultsSection">
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>Accessibility Report</h2>
<button class="btn btn-secondary" onclick="resetCheck()">Check Another PDF</button>
</div>
<div class="score-display">
<div class="score-number" id="scoreNumber">--</div>
<div class="score-label">Accessibility Score</div>
</div>
<div class="stats-grid" id="statsGrid">
<!-- 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 id="zoomContainer" style="position: relative; display: inline-block; transform-origin: top left;">
<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>
<button class="filter-btn" onclick="filterIssues('ERROR')">Errors</button>
<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>
</div>
</div>
</div>
<script>
let currentJobId = null;
let currentFilter = 'all';
let allIssues = [];
let pollInterval = null;
let pollCount = 0;
// Logging functions
function addLog(message, type = 'info') {
const logContent = document.getElementById('logContent');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
const timestamp = new Date().toLocaleTimeString();
entry.innerHTML = `<strong>${timestamp}</strong> ${message}`;
logContent.appendChild(entry);
logContent.scrollTop = logContent.scrollHeight;
}
function clearLog() {
const logContent = document.getElementById('logContent');
logContent.innerHTML = '<div class="log-entry">⏳ Initializing...</div>';
}
function updateProgress(percent, message) {
document.getElementById('progressFill').style.width = percent + '%';
document.getElementById('progressPercent').textContent = percent + '%';
document.getElementById('progressText').textContent = message;
}
// Upload area drag and drop
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
async function handleFile(file) {
if (!file.name.toLowerCase().endsWith('.pdf')) {
alert('Please select a PDF file');
return;
}
// Show progress
clearLog();
document.getElementById('progressContainer').style.display = 'block';
updateProgress(0, 'Preparing upload...');
addLog('📄 File selected: ' + file.name + ' (' + (file.size / 1024 / 1024).toFixed(2) + ' MB)', 'info');
// Upload file
const formData = new FormData();
formData.append('pdf', file);
formData.append('action', 'upload');
try {
updateProgress(10, 'Uploading file...');
addLog('⬆️ Uploading to server...', 'info');
const response = await fetch('api.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
currentJobId = result.data.job_id;
updateProgress(20, 'Upload complete');
addLog('✅ Upload successful - Job ID: ' + currentJobId, 'success');
addLog('📊 File size: ' + (file.size / 1024 / 1024).toFixed(2) + ' MB', 'info');
// Small delay for visual feedback
await new Promise(resolve => setTimeout(resolve, 500));
startCheck();
} else {
addLog('❌ Upload failed: ' + result.error, 'error');
alert('Upload failed: ' + result.error);
document.getElementById('progressContainer').style.display = 'none';
}
} catch (error) {
addLog('❌ Upload error: ' + error.message, 'error');
alert('Upload failed: ' + error.message);
document.getElementById('progressContainer').style.display = 'none';
}
}
async function startCheck() {
updateProgress(25, 'Initializing accessibility check...');
addLog('🔧 Preparing accessibility analysis...', 'info');
const formData = new FormData();
formData.append('action', 'check');
formData.append('job_id', currentJobId);
// Add quick mode flag
const quickMode = document.getElementById('quickMode').checked;
if (quickMode) {
formData.append('quick_mode', '1');
addLog('⚡ Quick mode enabled - skipping expensive checks', 'info');
}
// API keys are read from .env file on the server
addLog('🔑 Using API keys from server .env file', 'info');
try {
updateProgress(30, 'Starting analysis...');
addLog('🚀 Launching Python checker with venv...', 'info');
const response = await fetch('api.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
updateProgress(35, 'Analysis started');
addLog('✅ Python process started successfully', 'success');
addLog('⏱️ Estimated time: 2-5 minutes depending on document complexity', 'info');
pollStatus();
} else {
addLog('❌ Check failed: ' + result.error, 'error');
alert('Check failed: ' + result.error);
document.getElementById('progressContainer').style.display = 'none';
}
} catch (error) {
addLog('❌ Check error: ' + error.message, 'error');
alert('Check failed: ' + error.message);
document.getElementById('progressContainer').style.display = 'none';
}
}
async function pollStatus() {
pollCount = 0;
const checkStages = [
{ percent: 40, message: 'Loading PDF...', log: '📖 Reading PDF structure and metadata' },
{ percent: 45, message: 'Extracting text...', log: '📝 Extracting text from all pages' },
{ percent: 50, message: 'Analyzing document structure...', log: '🏗️ Checking PDF tagging and structure' },
{ percent: 55, message: 'Checking metadata...', log: '📋 Validating title, author, language' },
{ percent: 60, message: 'Analyzing images...', log: '🖼️ Processing images with AI (this may take a while)' },
{ percent: 65, message: 'Running OCR quality check...', log: '🔍 Analyzing text clarity and OCR confidence' },
{ percent: 70, message: 'Checking color contrast...', log: '🎨 Calculating WCAG contrast ratios' },
{ percent: 75, message: 'Analyzing readability...', log: '📚 Computing Flesch scores and grade levels' },
{ percent: 80, message: 'Validating links...', log: '🔗 Checking link text quality' },
{ percent: 85, message: 'Checking forms and headings...', log: '📄 Validating form fields and heading structure' },
{ percent: 90, message: 'Running final checks...', log: '✓ Font embedding, bookmarks, security' },
{ percent: 95, message: 'Compiling results...', log: '📊 Generating accessibility report' }
];
let stageIndex = 0;
const checkStatus = async () => {
pollCount++;
// Update simulated progress
if (stageIndex < checkStages.length && pollCount % 2 === 0) {
const stage = checkStages[stageIndex];
updateProgress(stage.percent, stage.message);
addLog(stage.log, 'info');
stageIndex++;
}
try {
const response = await fetch(`api.php?action=status&job_id=${currentJobId}`);
const result = await response.json();
if (result.success) {
if (result.data.status === 'completed') {
clearInterval(pollInterval);
updateProgress(98, 'Loading results...');
addLog('✅ Analysis complete! Loading results...', 'success');
addLog('⏱️ Total time: ' + Math.round(pollCount * 2) + ' seconds', 'info');
loadResults();
} else if (result.data.status === 'processing') {
// Still processing
if (pollCount === 1) {
addLog('⚙️ Python venv activated successfully', 'success');
addLog('🔬 Running comprehensive WCAG 2.1 analysis...', 'info');
}
if (pollCount > 60) {
addLog('⚠️ Analysis taking longer than expected (complex document)', 'warning');
}
// Timeout after 150 polls (5 minutes)
if (pollCount > 150) {
clearInterval(pollInterval);
addLog('❌ Analysis timed out after 5 minutes', 'error');
addLog('💡 Try using Quick Mode for faster results', 'info');
// Try to get debug info
try {
const debugResp = await fetch(`api.php?action=debug&job_id=${currentJobId}`);
const debugResult = await debugResp.json();
if (debugResult.success && debugResult.data.error_log) {
addLog('🔍 Error log: ' + debugResult.data.error_log.substring(0, 500), 'error');
}
} catch (e) {
console.error('Debug fetch failed:', e);
}
document.getElementById('progressContainer').style.display = 'none';
alert('Analysis timed out. Check the error log or try Quick Mode.');
}
} else if (result.data.status === 'failed' || result.data.status === 'error') {
clearInterval(pollInterval);
addLog('❌ Analysis failed', 'error');
if (result.data.error_log) {
addLog('🔍 Error: ' + result.data.error_log.substring(0, 500), 'error');
}
document.getElementById('progressContainer').style.display = 'none';
alert('Analysis failed. Check the error log for details.');
}
}
} catch (error) {
console.error('Status check failed:', error);
addLog('⚠️ Status check error (retrying...): ' + error.message, 'warning');
}
};
// Check immediately
checkStatus();
// Then check every 2 seconds
pollInterval = setInterval(checkStatus, 2000);
}
async function loadResults() {
updateProgress(100, 'Complete!');
addLog('📥 Fetching results from server...', 'info');
try {
const response = await fetch(`api.php?action=result&job_id=${currentJobId}`);
const result = await response.json();
if (result.success) {
addLog('✅ Results loaded successfully', 'success');
addLog('📊 Accessibility Score: ' + result.data.accessibility_score + '/100', 'success');
addLog('🔍 Total Issues Found: ' + result.data.total_issues, 'info');
addLog('📈 Critical: ' + result.data.severity_counts.critical +
' | Errors: ' + result.data.severity_counts.error +
' | Warnings: ' + result.data.severity_counts.warning, 'info');
// Wait a moment so user can see the final logs
await new Promise(resolve => setTimeout(resolve, 1000));
displayResults(result.data);
} else {
addLog('❌ Failed to load results: ' + result.error, 'error');
alert('Failed to load results: ' + result.error);
}
} catch (error) {
addLog('❌ Error loading results: ' + error.message, 'error');
alert('Failed to load results: ' + error.message);
}
}
function displayResults(data) {
// 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 = `
<div class="stat-card critical">
<div class="stat-number">${data.severity_counts.critical}</div>
<div class="stat-label">Critical</div>
</div>
<div class="stat-card error">
<div class="stat-number">${data.severity_counts.error}</div>
<div class="stat-label">Errors</div>
</div>
<div class="stat-card warning">
<div class="stat-number">${data.severity_counts.warning}</div>
<div class="stat-label">Warnings</div>
</div>
<div class="stat-card info">
<div class="stat-number">${data.severity_counts.info}</div>
<div class="stat-label">Info</div>
</div>
<div class="stat-card success">
<div class="stat-number">${data.severity_counts.success}</div>
<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) {
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;
}
// Group issues by page and category
const pageGroups = {};
const documentWideIssues = [];
issues.forEach(issue => {
if (issue.page_number) {
if (!pageGroups[issue.page_number]) {
pageGroups[issue.page_number] = [];
}
pageGroups[issue.page_number].push(issue);
} else {
documentWideIssues.push(issue);
}
});
// Create page overview map
const pageNumbers = Object.keys(pageGroups).map(Number).sort((a, b) => a - b);
const pageOverview = pageNumbers.length > 0 ? `
<div style="background: white; padding: 15px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h3 style="margin-bottom: 10px; font-size: 16px; font-weight: 600;">📄 Page Overview</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(55px, 1fr)); gap: 8px;">
${pageNumbers.map(pageNum => {
const pageIssues = pageGroups[pageNum];
const criticalCount = pageIssues.filter(i => i.severity === 'CRITICAL').length;
const errorCount = pageIssues.filter(i => i.severity === 'ERROR').length;
const warningCount = pageIssues.filter(i => i.severity === 'WARNING').length;
let bgColor = '#10b981'; // Success green
let iconColor = 'white';
if (criticalCount > 0) {
bgColor = '#dc2626'; // Critical red
} else if (errorCount > 0) {
bgColor = '#ef4444'; // Error red
} else if (warningCount > 0) {
bgColor = '#f59e0b'; // Warning yellow
iconColor = 'black';
}
return `
<div onclick="scrollToPage(${pageNum})" style="cursor: pointer; background: ${bgColor}; color: ${iconColor}; padding: 10px 8px; border-radius: 6px; text-align: center; transition: transform 0.2s; font-weight: 600;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
<div style="font-size: 10px; opacity: 0.9;">Page</div>
<div style="font-size: 18px;">${pageNum}</div>
<div style="font-size: 10px; margin-top: 3px;">${pageIssues.length} ${pageIssues.length === 1 ? 'issue' : 'issues'}</div>
</div>
`;
}).join('')}
</div>
</div>
` : '';
// Build the issues HTML
let issuesHTML = pageOverview;
// Document-wide issues first
if (documentWideIssues.length > 0) {
issuesHTML += `
<div id="page-document" style="margin-bottom: 30px;">
<h3 style="font-size: 18px; margin-bottom: 10px; padding: 10px 12px; background: #f8f9fa; border-radius: 6px; cursor: pointer;" onclick="togglePageSection('document')">
📋 Document-Wide Issues (${documentWideIssues.length})
<span id="toggle-document" style="float: right;">▼</span>
</h3>
<div id="section-document" class="issues-grid" style="display: block;">
${documentWideIssues.map(issue => createIssueCard(issue)).join('')}
</div>
</div>
`;
}
// Then page-specific issues
pageNumbers.forEach(pageNum => {
const pageIssues = pageGroups[pageNum];
const criticalCount = pageIssues.filter(i => i.severity === 'CRITICAL').length;
const errorCount = pageIssues.filter(i => i.severity === 'ERROR').length;
const warningCount = pageIssues.filter(i => i.severity === 'WARNING').length;
issuesHTML += `
<div id="page-${pageNum}" style="margin-bottom: 20px;">
<h3 style="font-size: 18px; margin-bottom: 10px; padding: 10px 12px; background: #f8f9fa; border-radius: 6px; cursor: pointer;" onclick="togglePageSection(${pageNum})">
📄 Page ${pageNum} - ${pageIssues.length} Issue${pageIssues.length !== 1 ? 's' : ''}
${criticalCount > 0 ? `<span style="background: #dc2626; color: white; padding: 2px 6px; border-radius: 10px; font-size: 11px; margin-left: 8px;">${criticalCount} Critical</span>` : ''}
${errorCount > 0 ? `<span style="background: #ef4444; color: white; padding: 2px 6px; border-radius: 10px; font-size: 11px; margin-left: 8px;">${errorCount} Error${errorCount !== 1 ? 's' : ''}</span>` : ''}
${warningCount > 0 ? `<span style="background: #f59e0b; color: white; padding: 2px 6px; border-radius: 10px; font-size: 11px; margin-left: 8px;">${warningCount} Warning${warningCount !== 1 ? 's' : ''}</span>` : ''}
<span id="toggle-${pageNum}" style="float: right;">▼</span>
</h3>
<div id="section-${pageNum}" class="issues-grid" style="display: block;">
${pageIssues.map(issue => createIssueCard(issue)).join('')}
</div>
</div>
`;
});
issuesList.innerHTML = issuesHTML;
}
function createIssueCard(issue) {
const iconMap = {
'CRITICAL': '🚨',
'ERROR': '❌',
'WARNING': '⚠️',
'INFO': '',
'SUCCESS': '✅'
};
const categoryIconMap = {
'Document Structure': '🏗️',
'Metadata': '📋',
'Language': '🌐',
'Text Accessibility': '📝',
'Images': '🖼️',
'Color Contrast': '🎨',
'Readability': '📚',
'Link Text': '🔗',
'Forms': '📄',
'Tables': '📊',
'Headings': '📑',
'Navigation': '🧭',
'Fonts': '🔤',
'Security': '🔒',
'OCR Quality': '🔍'
};
const icon = iconMap[issue.severity] || '•';
const categoryIcon = Object.keys(categoryIconMap).find(key => issue.category.includes(key))
? categoryIconMap[Object.keys(categoryIconMap).find(key => issue.category.includes(key))]
: '📌';
return `
<div class="issue ${issue.severity}">
<div class="issue-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<div class="issue-category" style="display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600;">
<span style="font-size: 16px;">${categoryIcon}</span>
<span>${issue.category}</span>
</div>
<span class="issue-badge ${issue.severity}" style="display: flex; align-items: center; gap: 4px; font-size: 10px; padding: 3px 8px;">
<span>${icon}</span>
<span>${issue.severity}</span>
</span>
</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>💡</strong> ${issue.recommendation}</div>` : ''}
</div>
`;
}
function togglePageSection(pageNum) {
const section = document.getElementById(`section-${pageNum}`);
const toggle = document.getElementById(`toggle-${pageNum}`);
if (section.style.display === 'none') {
section.style.display = 'block';
toggle.textContent = '▼';
} else {
section.style.display = 'none';
toggle.textContent = '▶';
}
}
function scrollToPage(pageNum) {
const element = document.getElementById(`page-${pageNum}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Briefly highlight the section
element.style.background = '#fff3cd';
setTimeout(() => {
element.style.background = '';
}, 1000);
}
}
function filterIssues(severity) {
currentFilter = severity;
// Update filter buttons
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
});
if (event && event.target) {
event.target.classList.add('active');
}
// Filter issues
const filtered = severity === 'all'
? allIssues
: allIssues.filter(issue => issue.severity === severity);
displayIssues(filtered);
}
function resetCheck() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
pollCount = 0;
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;
// Calculate scaling factor
// PDF coordinates are in points (72 DPI), images are rendered at specified DPI
const imageDPI = currentPageData.page_image_dpi || 150;
const scaleFactor = imageDPI / 72.0;
console.log(`DPI: ${imageDPI}, Scale factor: ${scaleFactor}, Image size: ${imgWidth}x${imgHeight}`);
// 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;
// Scale coordinates from PDF points to image pixels
// pdfplumber uses top-left origin (0,0 = top-left), same as SVG
// So NO Y-flipping needed, just scale!
const x0 = coords.x0 * scaleFactor;
const y0 = coords.y0 * scaleFactor; // coords.y0 is 'top' from pdfplumber
const x1 = coords.x1 * scaleFactor;
const y1 = coords.y1 * scaleFactor; // coords.y1 is 'bottom' from pdfplumber
const width = x1 - x0;
const height = y1 - y0;
console.log(`Issue ${index + 1}: PDF (${coords.x0}, ${coords.y0}, ${coords.x1}, ${coords.y1}) → Pixels (${x0.toFixed(0)}, ${y0.toFixed(0)}, ${x1.toFixed(0)}, ${y1.toFixed(0)}), size: ${width.toFixed(0)}x${height.toFixed(0)}`);
// 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() {
// Scale the entire container (image + SVG together)
const zoomContainer = document.getElementById('zoomContainer');
zoomContainer.style.transform = `scale(${currentZoom})`;
document.getElementById('zoomLevel').textContent = `${Math.round(currentZoom * 100)}%`;
// No need to redraw markers - they scale with the container automatically!
}
</script>
</body>
</html>