CoolFace
Apppublic

RevolutionCrossroads/ocr_correction

sourceHugging Facemitupdated 8mo agoView on Hugging Face
0likes
index.html964 linesDownload Raw Back to root
1<!DOCTYPE html>2<html lang="en">3<head>4    <meta charset="UTF-8">5    <meta name="viewport" content="width=device-width, initial-scale=1.0">6    <title>Historical Newspaper OCR Viewer</title>7    8    <!-- PDF.js Library -->9    <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>10    11    <style>12        * {13            margin: 0;14            padding: 0;15            box-sizing: border-box;16        }17 18        body {19            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;20            height: 100vh;21            overflow: hidden;22            background-color: #f5f5f5;23        }24 25        .header {26            background-color: #2c3e50;27            color: white;28            padding: 15px 20px;29            box-shadow: 0 2px 4px rgba(0,0,0,0.1);30        }31 32        .header h1 {33            font-size: 24px;34            margin-bottom: 10px;35        }36 37        .controls {38            display: flex;39            gap: 15px;40            align-items: center;41            flex-wrap: wrap;42        }43 44        .select-container {45            flex: 1;46            max-width: 800px;47        }48 49        .select-container select {50            width: 100%;51            padding: 8px 12px;52            border: 1px solid #ddd;53            border-radius: 4px;54            font-size: 14px;55            background-color: white;56        }57 58        .metadata {59            background-color: #ecf0f1;60            padding: 15px 20px;61            border-bottom: 1px solid #bdc3c7;62            display: none;63        }64 65        .metadata.active {66            display: block;67        }68 69        .metadata-content {70            display: flex;71            gap: 20px;72            align-items: center;73            flex-wrap: wrap;74        }75 76        .metadata-item {77            font-size: 14px;78        }79 80        .metadata-item strong {81            color: #2c3e50;82        }83 84        .metadata-item a {85            color: #3498db;86            text-decoration: none;87        }88 89        .metadata-item a:hover {90            text-decoration: underline;91        }92 93        .main-container {94            display: flex;95            height: calc(100vh - 180px);96            background-color: white;97        }98 99        .pdf-panel, .text-panel {100            width: 50%;101            display: flex;102            flex-direction: column;103            border-right: 1px solid #ddd;104        }105 106        .text-panel {107            border-right: none;108        }109 110        .panel-header {111            background-color: #34495e;112            color: white;113            padding: 12px 20px;114            font-weight: 600;115            font-size: 14px;116            display: flex;117            justify-content: space-between;118            align-items: center;119        }120 121        /* Search Toolbar Styles */122        .search-toolbar {123            background-color: #ecf0f1;124            padding: 8px 15px;125            display: flex;126            gap: 8px;127            align-items: center;128            border-bottom: 1px solid #bdc3c7;129        }130 131        .search-toolbar input[type="text"] {132            padding: 4px 8px;133            border: 1px solid #bdc3c7;134            border-radius: 3px;135            font-size: 13px;136            width: 140px;137        }138 139        .search-toolbar button {140            padding: 4px 10px;141            background-color: #fff;142            border: 1px solid #bdc3c7;143            border-radius: 3px;144            cursor: pointer;145            font-size: 12px;146            color: #2c3e50;147            transition: all 0.2s;148        }149 150        .search-toolbar button:hover:not(:disabled) {151            background-color: #e8e8e8;152            border-color: #95a5a6;153        }154 155        .search-toolbar button:disabled {156            opacity: 0.5;157            cursor: not-allowed;158        }159 160        .search-toolbar label {161            display: flex;162            align-items: center;163            gap: 4px;164            font-size: 12px;165            color: #2c3e50;166            cursor: pointer;167            user-select: none;168        }169 170        .search-toolbar input[type="checkbox"] {171            cursor: pointer;172        }173 174        .search-msg {175            font-size: 12px;176            color: #7f8c8d;177            margin-left: auto;178        }179 180        .pdf-controls {181            display: flex;182            gap: 10px;183            align-items: center;184        }185 186        .zoom-btn {187            background-color: #2c3e50;188            color: white;189            border: none;190            padding: 5px 12px;191            border-radius: 4px;192            cursor: pointer;193            font-size: 16px;194            font-weight: bold;195            transition: background-color 0.2s;196            min-width: 32px;197        }198 199        .zoom-btn:hover:not(:disabled) {200            background-color: #1a252f;201        }202 203        .zoom-btn:disabled {204            background-color: #95a5a6;205            cursor: not-allowed;206            opacity: 0.5;207        }208 209        .zoom-level {210            font-size: 13px;211            min-width: 50px;212            text-align: center;213        }214 215        .ocr-controls {216            display: flex;217            gap: 15px;218            align-items: center;219        }220 221        .ocr-toggle {222            display: flex;223            gap: 10px;224            background-color: #2c3e50;225            padding: 5px;226            border-radius: 4px;227        }228 229        .ocr-toggle label {230            padding: 5px 12px;231            cursor: pointer;232            border-radius: 3px;233            font-size: 13px;234            transition: background-color 0.2s;235        }236 237        .ocr-toggle input[type="radio"] {238            display: none;239        }240 241        .ocr-toggle input[type="radio"]:checked + span {242            background-color: #3498db;243        }244 245        .ocr-toggle span {246            padding: 5px 12px;247            border-radius: 3px;248            font-size: 13px;249        }250 251        .export-btn {252            background-color: #27ae60;253            color: white;254            border: none;255            padding: 6px 16px;256            border-radius: 4px;257            cursor: pointer;258            font-size: 13px;259            font-weight: 600;260            transition: background-color 0.2s;261        }262 263        .export-btn:hover {264            background-color: #229954;265        }266 267        .export-btn:disabled {268            background-color: #95a5a6;269            cursor: not-allowed;270        }271 272        .panel-content {273            flex: 1;274            overflow: auto;275            position: relative;276        }277 278        .panel-content.grabbable {279            cursor: grab;280        }281 282        .panel-content.grabbing {283            cursor: grabbing;284        }285 286        #pdf-canvas {287            display: block;288            margin: 20px auto;289            box-shadow: 0 2px 8px rgba(0,0,0,0.1);290        }291 292        #ocr-text {293            width: 100%;294            height: 100%;295            border: none;296            padding: 20px;297            font-family: 'Courier New', monospace;298            font-size: 14px;299            line-height: 1.6;300            resize: none;301            outline: none;302        }303 304        .loading {305            position: absolute;306            top: 50%;307            left: 50%;308            transform: translate(-50%, -50%);309            text-align: center;310            color: #7f8c8d;311        }312 313        .loading-spinner {314            border: 4px solid #ecf0f1;315            border-top: 4px solid #3498db;316            border-radius: 50%;317            width: 40px;318            height: 40px;319            animation: spin 1s linear infinite;320            margin: 0 auto 10px;321        }322 323        @keyframes spin {324            0% { transform: rotate(0deg); }325            100% { transform: rotate(360deg); }326        }327 328        .error-message {329            color: #e74c3c;330            padding: 20px;331            text-align: center;332        }333 334        .empty-state {335            text-align: center;336            color: #95a5a6;337            padding: 40px;338            font-size: 16px;339        }340 341        .empty-state-icon {342            font-size: 48px;343            margin-bottom: 10px;344        }345    </style>346</head>347<body>348    <div class="header">349        <h1>Historical Newspaper OCR Viewer</h1>350        <div class="controls">351            <div class="select-container">352                <select id="record-select" disabled>353                    <option value="">Loading records...</option>354                </select>355            </div>356        </div>357    </div>358 359    <div class="metadata" id="metadata">360        <div class="metadata-content">361            <div class="metadata-item">362                <strong>Title:</strong> <span id="meta-title">-</span>363            </div>364            <div class="metadata-item">365                <strong>Date:</strong> <span id="meta-date">-</span>366            </div>367            <div class="metadata-item">368                <strong>Page:</strong> <span id="meta-page">-</span>369            </div>370            <div class="metadata-item">371                <a id="meta-link" href="#" target="_blank">View on Library of Congress →</a>372            </div>373        </div>374    </div>375 376    <div class="main-container">377        <div class="pdf-panel">378            <div class="panel-header">379                <span>PDF Viewer</span>380                <div class="pdf-controls">381                    <button class="zoom-btn" id="zoom-out-btn" disabled title="Zoom Out">−</button>382                    <span class="zoom-level" id="zoom-level">100%</span>383                    <button class="zoom-btn" id="zoom-in-btn" disabled title="Zoom In">+</button>384                    <button class="zoom-btn" id="zoom-reset-btn" disabled title="Reset Zoom">Reset</button>385                </div>386            </div>387            <div class="panel-content" id="pdf-container">388                <div class="empty-state">389                    <div class="empty-state-icon">📄</div>390                    <div>Select a record to view the PDF</div>391                </div>392            </div>393        </div>394 395        <div class="text-panel">396            <div class="panel-header">397                <span>OCR Text</span>398                <div class="ocr-controls">399                    <div class="ocr-toggle">400                        <label>401                            <input type="radio" name="ocr-source" value="textract" checked>402                            <span>New OCR</span>403                        </label>404                        <label>405                            <input type="radio" name="ocr-source" value="loc">406                            <span>Original OCR</span>407                        </label>408                    </div>409                    <button class="export-btn" id="export-btn" disabled>Export Text</button>410                </div>411            </div>412            413            <!-- Added Search Toolbar -->414            <div class="search-toolbar">415                <input type="text" id="find-input" placeholder="Find..." disabled>416                <input type="text" id="replace-input" placeholder="Replace with..." disabled>417                <button id="find-next-btn" disabled title="Find Next Occurrence">Find Next</button>418                <button id="replace-btn" disabled title="Replace Current Selection">Replace</button>419                <button id="replace-all-btn" disabled title="Replace All Occurrences">Replace All</button>420                <label>421                    <input type="checkbox" id="case-sensitive-cb" disabled>422                    Match Case423                </label>424                <span id="search-msg" class="search-msg"></span>425            </div>426 427            <div class="panel-content">428                <textarea 429                    id="ocr-text" 430                    placeholder="Select a record and OCR source to view text..."431                    disabled432                ></textarea>433            </div>434        </div>435    </div>436 437    <script>438        // Configure PDF.js worker439        pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';440 441        // Application state442        const state = {443            records: [],444            currentRecord: null,445            currentOcrSource: 'textract',446            originalOcrText: '',447            hasEdits: false,448            isLoading: false,449            currentPdf: null,450            zoomLevel: 1.0451        };452 453        // DOM elements454        const recordSelect = document.getElementById('record-select');455        const ocrText = document.getElementById('ocr-text');456        const exportBtn = document.getElementById('export-btn');457        const pdfContainer = document.getElementById('pdf-container');458        const metadata = document.getElementById('metadata');459        const ocrRadios = document.querySelectorAll('input[name="ocr-source"]');460        const zoomInBtn = document.getElementById('zoom-in-btn');461        const zoomOutBtn = document.getElementById('zoom-out-btn');462        const zoomResetBtn = document.getElementById('zoom-reset-btn');463        const zoomLevel = document.getElementById('zoom-level');464 465        // Search DOM elements466        const findInput = document.getElementById('find-input');467        const replaceInput = document.getElementById('replace-input');468        const findNextBtn = document.getElementById('find-next-btn');469        const replaceBtn = document.getElementById('replace-btn');470        const replaceAllBtn = document.getElementById('replace-all-btn');471        const caseSensitiveCb = document.getElementById('case-sensitive-cb');472        const searchMsg = document.getElementById('search-msg');473 474        // Initialize application475        async function init() {476            try {477                const response = await fetch('data.json');478                if (!response.ok) {479                    throw new Error('Failed to load data.json');480                }481                state.records = await response.json();482                483                populateRecordSelect();484                enableControls();485                setupEventListeners();486            } catch (error) {487                console.error('Initialization error:', error);488                recordSelect.innerHTML = '<option value="">Error loading records</option>';489                alert('Failed to load data.json. Please ensure the file exists in the same directory as index.html.');490            }491        }492 493        function enableControls() {494            recordSelect.disabled = false;495        }496 497        function populateRecordSelect() {498            recordSelect.innerHTML = '<option value="">Select a record...</option>';499            500            state.records.forEach(record => {501                const option = document.createElement('option');502                option.value = record.id;503                option.textContent = `${record.id} - ${record.newspaper_title} ${record.publication_date} Page ${record.page}`;504                recordSelect.appendChild(option);505            });506        }507 508        function setupEventListeners() {509            // Record selection510            recordSelect.addEventListener('change', (e) => {511                const recordId = e.target.value;512                if (recordId) {513                    const record = state.records.find(r => r.id === recordId);514                    loadRecord(record);515                }516            });517 518            // OCR source toggle519            ocrRadios.forEach(radio => {520                radio.addEventListener('change', (e) => {521                    if (state.hasEdits) {522                        const confirmed = confirm('You have unsaved edits. Switching OCR source will discard your changes. Continue?');523                        if (!confirmed) {524                            // Revert to previous selection525                            document.querySelector(`input[name="ocr-source"][value="${state.currentOcrSource}"]`).checked = true;526                            return;527                        }528                    }529                    state.currentOcrSource = e.target.value;530                    if (state.currentRecord) {531                        loadOcrText(state.currentRecord);532                    }533                });534            });535 536            // Track edits537            ocrText.addEventListener('input', () => {538                state.hasEdits = ocrText.value !== state.originalOcrText;539            });540 541            // Export button542            exportBtn.addEventListener('click', exportText);543 544            // Zoom controls545            zoomInBtn.addEventListener('click', () => zoomPdf(0.25));546            zoomOutBtn.addEventListener('click', () => zoomPdf(-0.25));547            zoomResetBtn.addEventListener('click', () => resetZoom());548 549            // Search controls550            findNextBtn.addEventListener('click', findNext);551            replaceBtn.addEventListener('click', replaceCurrent);552            replaceAllBtn.addEventListener('click', replaceAll);553            554            // Allow "Enter" key in find input to trigger Find Next555            findInput.addEventListener('keypress', (e) => {556                if (e.key === 'Enter') {557                    findNext();558                }559            });560 561            // Pan functionality562            setupPanControls();563        }564 565        // --- Search Functions ---566 567        function findNext() {568            const query = findInput.value;569            if (!query) return;570 571            const isCaseSensitive = caseSensitiveCb.checked;572            const text = ocrText.value;573            const startPos = ocrText.selectionEnd; // Start searching from after current cursor/selection574            575            let nextPos = -1;576            577            // Perform search based on case sensitivity578            if (isCaseSensitive) {579                nextPos = text.indexOf(query, startPos);580                if (nextPos === -1) {581                    nextPos = text.indexOf(query, 0); // Wrap582                    searchMsg.textContent = nextPos !== -1 ? "Wrapped to top" : "Not found";583                } else {584                    searchMsg.textContent = "";585                }586            } else {587                const lowerText = text.toLowerCase();588                const lowerQuery = query.toLowerCase();589                nextPos = lowerText.indexOf(lowerQuery, startPos);590                if (nextPos === -1) {591                    nextPos = lowerText.indexOf(lowerQuery, 0); // Wrap592                    searchMsg.textContent = nextPos !== -1 ? "Wrapped to top" : "Not found";593                } else {594                    searchMsg.textContent = "";595                }596            }597 598            if (nextPos !== -1) {599                // Select the found text600                ocrText.focus();601                ocrText.setSelectionRange(nextPos, nextPos + query.length);602                603                // Enhanced Scroll to selection logic604                // Calculate percentage position of the match605                const progress = nextPos / text.length;606                // Set scroll top based on percentage of total scroll height607                // Offset by half container height to try and center it608                const scrollTarget = (ocrText.scrollHeight * progress) - (ocrText.clientHeight / 2);609                610                ocrText.scrollTop = scrollTarget;611                612                // Fallback: Trigger blur/focus which forces browser to scroll to cursor613                // This handles edge cases where the calculation might be slightly off due to variable line wrapping614                // setTimeout ensures the scroll calculation happens first, then the browser creates final view615                setTimeout(() => {616                    ocrText.blur();617                    ocrText.focus();618                }, 10);619            }620        }621 622        function replaceCurrent() {623            const query = findInput.value;624            const replacement = replaceInput.value;625            if (!query) return;626 627            const isCaseSensitive = caseSensitiveCb.checked;628            629            // Check if current selection matches the find query630            const start = ocrText.selectionStart;631            const end = ocrText.selectionEnd;632            const selectedText = ocrText.value.substring(start, end);633            634            let match = false;635            if (isCaseSensitive) {636                match = selectedText === query;637            } else {638                match = selectedText.toLowerCase() === query.toLowerCase();639            }640 641            if (match) {642                // Perform replacement643                ocrText.setRangeText(replacement, start, end, 'end');644                state.hasEdits = true;645                searchMsg.textContent = "Replaced";646                647                // Automatically find next648                findNext();649            } else {650                // If text isn't selected or doesn't match, try to find next occurrence first651                findNext();652            }653        }654 655        function replaceAll() {656            const query = findInput.value;657            const replacement = replaceInput.value;658            if (!query) return;659 660            const isCaseSensitive = caseSensitiveCb.checked;661            const text = ocrText.value;662            663            // Escape special regex characters to perform a literal "Replace All"664            const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 665            const flags = isCaseSensitive ? 'g' : 'gi';666            const regex = new RegExp(escapedQuery, flags);667            668            const matchCount = (text.match(regex) || []).length;669            670            if (matchCount > 0) {671                const newText = text.replace(regex, replacement);672                ocrText.value = newText;673                state.hasEdits = true;674                searchMsg.textContent = `Replaced ${matchCount} occurrences`;675            } else {676                searchMsg.textContent = "0 matches found";677            }678        }679 680        function updateSearchControls(enabled) {681            findInput.disabled = !enabled;682            replaceInput.disabled = !enabled;683            findNextBtn.disabled = !enabled;684            replaceBtn.disabled = !enabled;685            replaceAllBtn.disabled = !enabled;686            caseSensitiveCb.disabled = !enabled;687            if (!enabled) {688                findInput.value = '';689                replaceInput.value = '';690                caseSensitiveCb.checked = false;691                searchMsg.textContent = '';692            }693        }694 695        // --- End Search Functions ---696 697        async function loadRecord(record) {698            state.currentRecord = record;699            state.hasEdits = false;700            701            // Update metadata702            metadata.classList.add('active');703            document.getElementById('meta-title').textContent = record.newspaper_title;704            document.getElementById('meta-date').textContent = record.publication_date;705            document.getElementById('meta-page').textContent = record.page;706            document.getElementById('meta-link').href = record.loc_record_url;707 708            // Load PDF and OCR text in parallel709            await Promise.all([710                loadPdf(record.pdf_path),711                loadOcrText(record)712            ]);713        }714 715        async function loadPdf(pdfPath) {716            pdfContainer.innerHTML = '<div class="loading"><div class="loading-spinner"></div><div>Loading PDF...</div></div>';717            718            try {719                const loadingTask = pdfjsLib.getDocument(pdfPath);720                const pdf = await loadingTask.promise;721                state.currentPdf = pdf;722                state.zoomLevel = 1.0;723                724                await renderPdf();725                726                // Enable zoom controls727                zoomInBtn.disabled = false;728                zoomOutBtn.disabled = false;729                zoomResetBtn.disabled = false;730                731                // Enable pan controls732                pdfContainer.classList.add('grabbable');733            } catch (error) {734                console.error('PDF loading error:', error);735                pdfContainer.innerHTML = '<div class="error-message">Failed to load PDF: ' + error.message + '</div>';736            }737        }738 739        async function renderPdf() {740            if (!state.currentPdf) return;741            742            pdfContainer.innerHTML = '<div class="loading"><div class="loading-spinner"></div><div>Rendering PDF...</div></div>';743            744            const page = await state.currentPdf.getPage(1);745            const baseScale = 1.5;746            const scale = baseScale * state.zoomLevel;747            const viewport = page.getViewport({ scale });748            749            const canvas = document.createElement('canvas');750            canvas.id = 'pdf-canvas';751            const context = canvas.getContext('2d');752            canvas.height = viewport.height;753            canvas.width = viewport.width;754            755            const renderContext = {756                canvasContext: context,757                viewport: viewport758            };759            760            await page.render(renderContext).promise;761            762            pdfContainer.innerHTML = '';763            pdfContainer.appendChild(canvas);764            765            // Update zoom level display766            zoomLevel.textContent = Math.round(state.zoomLevel * 100) + '%';767        }768 769        async function loadOcrText(record) {770            ocrText.value = '';771            ocrText.disabled = true;772            exportBtn.disabled = true;773            updateSearchControls(false); // Disable search controls while loading774            state.hasEdits = false;775            776            const loadingDiv = document.createElement('div');777            loadingDiv.className = 'loading';778            loadingDiv.innerHTML = '<div class="loading-spinner"></div><div>Loading OCR text...</div>';779            ocrText.parentElement.appendChild(loadingDiv);780            781            try {782                let text = '';783                784                if (state.currentOcrSource === 'textract') {785                    text = await fetchTextractOcr(record.textract_ocr_file);786                } else {787                    text = await fetchLocOcr(record.loc_altoxml_path);788                }789                790                state.originalOcrText = text;791                ocrText.value = text;792                ocrText.disabled = false;793                exportBtn.disabled = false;794                updateSearchControls(true); // Enable search controls795                loadingDiv.remove();796            } catch (error) {797                console.error('OCR loading error:', error);798                loadingDiv.innerHTML = '<div class="error-message">Failed to load OCR text: ' + error.message + '</div>';799                setTimeout(() => loadingDiv.remove(), 3000);800            }801        }802 803        async function fetchTextractOcr(filePath) {804            const response = await fetch(filePath);805            if (!response.ok) {806                throw new Error(`HTTP error! status: ${response.status}`);807            }808            return await response.text();809        }810 811        async function fetchLocOcr(xmlPath) {812            const response = await fetch(xmlPath);813            if (!response.ok) {814                throw new Error(`HTTP error! status: ${response.status}`);815            }816            const xmlText = await response.text();817            return parseAltoXml(xmlText);818        }819 820        function parseAltoXml(xmlText) {821            const parser = new DOMParser();822            const xmlDoc = parser.parseFromString(xmlText, 'text/xml');823            824            // Check for parsing errors825            const parserError = xmlDoc.querySelector('parsererror');826            if (parserError) {827                throw new Error('XML parsing error');828            }829            830            const strings = xmlDoc.getElementsByTagName('String');831            const textLines = [];832            let currentLine = [];833            let lastVPos = null;834            835            for (let i = 0; i < strings.length; i++) {836                const stringElement = strings[i];837                const content = stringElement.getAttribute('CONTENT');838                const vpos = stringElement.getAttribute('VPOS');839                840                if (content) {841                    // Check if we're on a new line (VPOS changed significantly)842                    if (lastVPos !== null && vpos !== null) {843                        const vposDiff = Math.abs(parseInt(vpos) - parseInt(lastVPos));844                        if (vposDiff > 50) { // Threshold for new line845                            if (currentLine.length > 0) {846                                textLines.push(currentLine.join(' '));847                                currentLine = [];848                            }849                        }850                    }851                    852                    currentLine.push(content);853                    lastVPos = vpos;854                }855            }856            857            // Add the last line858            if (currentLine.length > 0) {859                textLines.push(currentLine.join(' '));860            }861            862            return textLines.join('\n');863        }864 865        function exportText() {866            if (!state.currentRecord) return;867            868            const text = ocrText.value;869            const record = state.currentRecord;870            871            // Sanitize filename872            const sanitizedTitle = record.newspaper_title873                .replace(/[^a-z0-9]/gi, '_')874                .replace(/_+/g, '_')875                .toLowerCase();876            877            const filename = `${record.id}_${sanitizedTitle}_${record.publication_date}.txt`;878            879            // Create blob and download880            const blob = new Blob([text], { type: 'text/plain' });881            const url = URL.createObjectURL(blob);882            const a = document.createElement('a');883            a.href = url;884            a.download = filename;885            document.body.appendChild(a);886            a.click();887            document.body.removeChild(a);888            URL.revokeObjectURL(url);889        }890 891        function zoomPdf(delta) {892            // Store current scroll position as percentage893            const scrollLeftPercent = pdfContainer.scrollLeft / (pdfContainer.scrollWidth - pdfContainer.clientWidth);894            const scrollTopPercent = pdfContainer.scrollTop / (pdfContainer.scrollHeight - pdfContainer.clientHeight);895            896            // Update zoom level (limit between 0.5x and 5x)897            state.zoomLevel = Math.max(0.5, Math.min(5, state.zoomLevel + delta));898            899            // Re-render PDF900            renderPdf().then(() => {901                // Restore scroll position902                pdfContainer.scrollLeft = scrollLeftPercent * (pdfContainer.scrollWidth - pdfContainer.clientWidth);903                pdfContainer.scrollTop = scrollTopPercent * (pdfContainer.scrollHeight - pdfContainer.clientHeight);904            });905        }906 907        function resetZoom() {908            state.zoomLevel = 1.0;909            renderPdf();910        }911 912        function setupPanControls() {913            let isMouseDown = false;914            let startX, startY, scrollLeft, scrollTop;915 916            pdfContainer.addEventListener('mousedown', (e) => {917                // Only pan if we clicked on the container or canvas918                if (e.target === pdfContainer || e.target.id === 'pdf-canvas') {919                    isMouseDown = true;920                    pdfContainer.classList.remove('grabbable');921                    pdfContainer.classList.add('grabbing');922                    923                    startX = e.pageX - pdfContainer.offsetLeft;924                    startY = e.pageY - pdfContainer.offsetTop;925                    scrollLeft = pdfContainer.scrollLeft;926                    scrollTop = pdfContainer.scrollTop;927                }928            });929 930            pdfContainer.addEventListener('mouseleave', () => {931                if (isMouseDown) {932                    isMouseDown = false;933                    pdfContainer.classList.remove('grabbing');934                    pdfContainer.classList.add('grabbable');935                }936            });937 938            pdfContainer.addEventListener('mouseup', () => {939                if (isMouseDown) {940                    isMouseDown = false;941                    pdfContainer.classList.remove('grabbing');942                    pdfContainer.classList.add('grabbable');943                }944            });945 946            pdfContainer.addEventListener('mousemove', (e) => {947                if (!isMouseDown) return;948                e.preventDefault();949                950                const x = e.pageX - pdfContainer.offsetLeft;951                const y = e.pageY - pdfContainer.offsetTop;952                const walkX = (x - startX) * 1.5; // Multiply for faster scrolling953                const walkY = (y - startY) * 1.5;954                955                pdfContainer.scrollLeft = scrollLeft - walkX;956                pdfContainer.scrollTop = scrollTop - walkY;957            });958        }959 960        // Initialize on page load961        init();962    </script>963</body>964</html>