From 7b059390bd94fa625dd8e2bcc32958d833a8cf82 Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 11 May 2026 10:24:18 -0400 Subject: [PATCH] removed highlighting text. Added in magnifying words left side, sync scrolling, keyboard shortcuts, editing text etc --- launch_viewer.py | 91 +++++- p44-ocr-viewer.html | 718 +++++++++++++++++++++++++++++++----------- prompts/OCR-Viewer.md | 135 ++++++++ 3 files changed, 753 insertions(+), 191 deletions(-) create mode 100644 prompts/OCR-Viewer.md diff --git a/launch_viewer.py b/launch_viewer.py index 5ff1c79..175831f 100644 --- a/launch_viewer.py +++ b/launch_viewer.py @@ -1,45 +1,112 @@ #!/usr/bin/env python3 """ -P44 OCR Viewer — launcher +P44 OCR Viewer launcher. Starts a local HTTP server at port 5044 and opens the viewer in the default browser. Run from any directory; the server root is always the project folder (this file's location). +If port 5044 is already in use (server already running), just opens the browser tab. """ import http.server import webbrowser import threading +import socket import os import sys +import json PORT = 5044 ROOT = os.path.dirname(os.path.abspath(__file__)) + class QuietHandler(http.server.SimpleHTTPRequestHandler): - """Serve files from ROOT, suppress request logs.""" + """Serve files from ROOT, suppress request logs, disable browser caching.""" def __init__(self, *args, **kwargs): super().__init__(*args, directory=ROOT, **kwargs) + def log_message(self, fmt, *args): pass # silence per-request noise + def end_headers(self): + self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') + self.send_header('Pragma', 'no-cache') + self.send_header('Expires', '0') + super().end_headers() + + def do_POST(self): + """Handle POST /save-flags and POST /save-corrections.""" + length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(length) + try: + data = json.loads(body) + except json.JSONDecodeError: + self.send_response(400); self.end_headers() + return + + if self.path == '/save-flags': + dest = os.path.join(ROOT, 'flags.json') + with open(dest, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(b'{"ok":true}') + + elif self.path == '/save-corrections': + dest = os.path.join(ROOT, 'corrections.json') + # Load existing, update single page entry, write back + existing = {} + if os.path.exists(dest): + try: + with open(dest, 'r', encoding='utf-8') as f: + existing = json.load(f) + except Exception: + pass + page = str(data.get('page', '')) + text = data.get('text', '') + if text.strip(): + existing[page] = text + else: + existing.pop(page, None) + with open(dest, 'w', encoding='utf-8') as f: + json.dump(existing, f, indent=2) + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(b'{"ok":true}') + + else: + self.send_response(404); self.end_headers() + + +def port_in_use(port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex(('localhost', port)) == 0 + + def start_server(): server = http.server.HTTPServer(('localhost', PORT), QuietHandler) server.serve_forever() + if __name__ == '__main__': - # Start the server in a background daemon thread - t = threading.Thread(target=start_server, daemon=True) - t.start() + url = 'http://localhost:{}/p44-ocr-viewer.html'.format(PORT) - url = f'http://localhost:{PORT}/p44-ocr-viewer.html' - print(f"\n ⚜ P44 OCR Viewer") - print(f" Serving: {ROOT}") - print(f" URL: {url}\n") - webbrowser.open(url) + if port_in_use(PORT): + print('\n P44 OCR Viewer (server already running)') + print(' URL: {}\n'.format(url)) + webbrowser.open(url) + else: + t = threading.Thread(target=start_server, daemon=True) + t.start() + print('\n P44 OCR Viewer') + print(' Serving : {}'.format(ROOT)) + print(' URL : {}\n'.format(url)) + webbrowser.open(url) - print("Press Enter (or Ctrl-C) to stop the server and exit.\n") + print('Press Enter (or Ctrl-C) to stop the server.\n') try: input() except KeyboardInterrupt: pass - print("Server stopped.") + print('Server stopped.') sys.exit(0) diff --git a/p44-ocr-viewer.html b/p44-ocr-viewer.html index 5bfa8c5..ac7d1e9 100644 --- a/p44-ocr-viewer.html +++ b/p44-ocr-viewer.html @@ -195,6 +195,7 @@ html, body { pointer-events: none; } +/* noinspection CssUnusedSymbol */ .word-box { position: absolute; border: 1px solid transparent; @@ -203,7 +204,9 @@ html, body { pointer-events: all; transition: background 0.07s, border-color 0.07s; } +/* noinspection CssUnusedSymbol */ .word-box:hover, +/* noinspection CssUnusedSymbol */ .word-box.highlight { background: rgba(255, 224, 102, 0.38); border-color: var(--highlight); @@ -284,20 +287,6 @@ html, body { #text-output li { margin: 2px 0; } #text-output em { color: var(--text-dim); } -/* word spans */ -#text-output .word-span { - display: inline; - border-radius: 2px; - padding: 0 1px; - cursor: pointer; - transition: background 0.07s; -} -#text-output .word-span:hover, -#text-output .word-span.highlight { - background: var(--highlight); - color: #1a1a2e; -} - /* ── Footer ───────────────────────────────────────────────────── */ #footer { display: flex; @@ -310,13 +299,63 @@ html, body { color: var(--text-dim); } #status-msg { flex: 1; font-style: italic; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -#word-tip { color: var(--gold-dim); letter-spacing: 0.04em; white-space: nowrap; } /* ── Scrollbars ───────────────────────────────────────────────── */ ::-webkit-scrollbar { width: 7px; height: 7px; } ::-webkit-scrollbar-track { background: var(--bg); } ::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--gold-dim); } + +/* ── Active toggle button state (sync-scroll, edit mode) ─────── */ +.tb-btn.active { border-color: var(--gold); color: var(--gold); } + +/* ── Zoom lens ────────────────────────────────────────────────── */ +#zoom-lens { + position: fixed; + width: 280px; height: 280px; + background: var(--surface); + border: 2px solid var(--gold); + border-radius: 4px; + box-shadow: 0 4px 16px rgba(0,0,0,0.6); + z-index: 100; + overflow: hidden; + display: none; +} +#zoom-close { + position: absolute; + top: 4px; right: 4px; + color: var(--gold); + font-size: 12px; + cursor: pointer; + z-index: 101; + line-height: 1; + background: var(--surface2); + border: 1px solid var(--gold-dim); + border-radius: 2px; + padding: 2px 6px; +} +#zoom-close:hover { color: var(--gold); border-color: var(--gold); } +#zoom-canvas { display: block; width: 100%; height: 100%; } + +/* ── Flag buttons ─────────────────────────────────────────────── */ +.flag-btn { font-size: 9px; padding: 2px 7px; } +.flag-btn.active { border-color: var(--gold); color: var(--gold); } +#correction-dot { + display: none; + width: 6px; height: 6px; + border-radius: 50%; + background: var(--gold); + flex-shrink: 0; +} +/* ── Footer extras ────────────────────────────────────────────── */ +#flags-summary { color: var(--gold-dim); letter-spacing: 0.04em; white-space: nowrap; font-size: 10px; } +#kbd-hint { color: var(--text-dim); font-size: 9px; white-space: nowrap; opacity: 0.7; } + +/* ── Edit mode text panel ─────────────────────────────────────── */ +#text-output[contenteditable="true"] { + outline: 1px solid var(--gold-dim); + caret-color: var(--gold); +} @@ -346,7 +385,7 @@ html, body {
SCALE - @@ -356,6 +395,8 @@ html, body {
+
+ @@ -367,12 +408,16 @@ html, body { Source Document — Page Scan
+
+ + +
@@ -392,6 +437,12 @@ html, body {
OCR Text Output +
+ + + + +
@@ -409,7 +460,8 @@ html, body {
@@ -550,6 +602,12 @@ const state = { imageNaturalW: 1, imageNaturalH: 1, rendering: false, + ocrPages: {}, // { pageNum: rawMarkdownString } — parsed from olmOCR output + ocrRawFull: '', // stored for plain-text (sample) mode + lastRenderTime: 0, // timestamp (ms) of the last completed render — used for debounce + flags: {}, // { pageNum: 'verified'|'review'|'error' } + corrections: {}, // { pageNum: correctedText } + editMode: false, // is the text panel currently editable? }; // ───────────────────────────────────────────────────────────────── @@ -580,7 +638,27 @@ const pageInput = document.getElementById('page-input'); const pageLabel = document.getElementById('page-label'); const ocrSyncLabel = document.getElementById('ocr-sync-label'); const statusMsg = document.getElementById('status-msg'); -const wordTip = document.getElementById('word-tip'); + +// Phase 2 — sync scroll +const btnSyncScroll = document.getElementById('btn-sync-scroll'); +const imagePanelBody = document.getElementById('image-panel-body'); +const textPanelBody = document.getElementById('text-panel-body'); + +// Phase 3 — zoom +const zoomLens = document.getElementById('zoom-lens'); +const zoomCanvas = document.getElementById('zoom-canvas'); +const zoomClose = document.getElementById('zoom-close'); + +// Phase 4 — flags +const btnFlagVerified = document.getElementById('btn-flag-verified'); +const btnFlagReview = document.getElementById('btn-flag-review'); +const btnFlagError = document.getElementById('btn-flag-error'); +const btnFlagClear = document.getElementById('btn-flag-clear'); +const flagsSummary = document.getElementById('flags-summary'); + +// Phase 5 — edit +const btnEdit = document.getElementById('btn-edit'); +const correctionDot = document.getElementById('correction-dot'); // ───────────────────────────────────────────────────────────────── // TOOLBAR BUTTON EVENTS @@ -693,8 +771,7 @@ async function loadCalgaryHighlanders() { // ───────────────────────────────────────────────────────────────── function loadSampleData() { state.mode = 'image'; - state.wordData = []; - const dataURL = buildSampleImageDataURL(); + state.wordData = SAMPLE_JSON; // set BEFORE any rendering so buildIdQueue sees the data const img = new Image(); img.onload = () => { state.imageNaturalW = img.naturalWidth; @@ -707,11 +784,22 @@ function loadSampleData() { pdfContainer.style.display = 'inline-block'; imagePlaceholder.style.display = 'none'; pageLabel.textContent = '/ 1'; btnPrev.disabled = true; btnNext.disabled = true; - loadWordDataJSON(SAMPLE_JSON); // triggers overlay render + + // Build overlay boxes first + const sx = pdfCanvas.width / state.imageNaturalW; + const sy = pdfCanvas.height / state.imageNaturalH; + overlay.innerHTML = ''; + overlay.style.width = pdfCanvas.width + 'px'; + overlay.style.height = pdfCanvas.height + 'px'; + SAMPLE_JSON.forEach(e => + overlay.appendChild(makeWordBox(e.id, e.bbox.x*sx, e.bbox.y*sy, e.bbox.w*sx, e.bbox.h*sy, e.word)) + ); + + // Render OCR text AFTER word data is in place — ensures IDs get assigned to spans + loadOCRText(SAMPLE_TEXT, false); + setStatus('Sample data loaded — hover any word on either side to see bidirectional highlighting.'); }; - img.src = dataURL; - loadOCRText(SAMPLE_TEXT, false); - setStatus('Sample data loaded — hover words to see bidirectional link highlighting.'); + img.src = buildSampleImageDataURL(); } // ───────────────────────────────────────────────────────────────── @@ -725,7 +813,8 @@ async function loadPDFBuffer(arrayBuffer) { state.currentPage = 1; state.wordData = []; // clear JSON sidecar — use PDF text layer pageLabel.textContent = '/ ' + state.totalPages; - pageInput.value = 1; + // Note: pageInput.value is set by renderCurrentPage() below. + // Setting it here would fire a change event → goToPage → duplicate render. btnPrev.disabled = true; btnNext.disabled = state.totalPages <= 1; pdfContainer.style.display = 'inline-block'; @@ -738,6 +827,9 @@ async function loadPDFBuffer(arrayBuffer) { async function renderCurrentPage() { if (!state.pdfDoc || state.rendering) return; + // Debounce: ignore spurious re-render calls (e.g. from resize events) that + // fire within 100 ms of a render that just completed. + if (Date.now() - state.lastRenderTime < 100) return; state.rendering = true; try { const page = await state.pdfDoc.getPage(state.currentPage); @@ -760,9 +852,26 @@ async function renderCurrentPage() { btnPrev.disabled = state.currentPage <= 1; btnNext.disabled = state.currentPage >= state.totalPages; - syncOCRToPage(state.currentPage); + // Sync right panel — wrapped so an OCR render failure never blocks PDF + try { + showOCRPage(state.currentPage); + } catch (e) { + setStatus('OCR panel error on page ' + state.currentPage + ': ' + e.message, true); + console.error(e); + } + + state.lastRenderTime = Date.now(); // debounce: record when this render finished } finally { state.rendering = false; + // If the user navigated while we were rendering, honour the queued page + if (_pendingPage !== null) { + const next = _pendingPage; + _pendingPage = null; + if (next !== state.currentPage) { + state.currentPage = next; + renderCurrentPage(); + } + } } } @@ -772,7 +881,18 @@ async function buildPDFWordOverlay(page, vp) { overlay.style.width = vp.width + 'px'; overlay.style.height = vp.height + 'px'; - // If a JSON sidecar is loaded (image mode), use it instead + // JSON sidecar in PDF mode — coords are PDF points (bottom-left origin) + if (state.mode === 'pdf' && state.wordData.length) { + state.wordData.forEach(e => { + const [cx, cy] = vp.convertToViewportPoint(e.bbox.x, e.bbox.y + e.bbox.h); + const cw = e.bbox.w * vp.scale; + const ch = e.bbox.h * vp.scale; + overlay.appendChild(makeWordBox(e.id, cx, cy, cw, ch, e.word)); + }); + return; + } + + // JSON sidecar in image mode — coords are image pixels scaled to canvas if (state.mode === 'image' && state.wordData.length) { const sx = vp.width / state.imageNaturalW; const sy = vp.height / state.imageNaturalH; @@ -781,42 +901,10 @@ async function buildPDFWordOverlay(page, vp) { makeWordBox(e.id, e.bbox.x*sx, e.bbox.y*sy, e.bbox.w*sx, e.bbox.h*sy, e.word) ); }); - return; } - // PDF text layer → word boxes - const textContent = await page.getTextContent(); - let counter = 0; - - textContent.items.forEach(item => { - const str = (item.str || '').trim(); - if (!str) return; - - // item.transform = [a, b, c, d, tx, ty] PDF user-space coords (bottom-left origin) - const transform = item.transform; - const fontH = Math.abs(transform[3]); // approximate glyph height in PDF units - const tx = transform[4], ty = transform[5]; - - // Convert bottom-left PDF point → canvas top-left - const [cx, cy] = vp.convertToViewportPoint(tx, ty); - const scaledH = fontH * vp.scale * 1.15; - const scaledW = (item.width || 0) * vp.scale; - - // Split multi-word runs into individual word boxes - const words = str.split(/\s+/); - const perCharW = scaledW / Math.max(str.replace(/\s/g,'').length, 1); - let xCursor = cx; - - words.forEach(word => { - if (!word) return; - const wW = word.length * perCharW; - const id = 'pf' + String(++counter).padStart(5, '0'); - overlay.appendChild( - makeWordBox(id, xCursor, cy - scaledH, wW, scaledH, word) - ); - xCursor += wW + perCharW; // approx space - }); - }); + // No JSON sidecar — overlay intentionally left blank (PDFs are scanned images + // with no embedded text layer; getTextContent() returns only shell labels). } function makeWordBox(id, x, y, w, h, title) { @@ -828,28 +916,12 @@ function makeWordBox(id, x, y, w, h, title) { div.style.width = Math.max(4, Math.round(w)) + 'px'; div.style.height = Math.max(4, Math.round(h)) + 'px'; if (title) div.title = title; - div.addEventListener('mouseenter', () => { - div.classList.add('highlight'); - const span = textOutput.querySelector('.word-span[data-word-id="' + id + '"]'); - if (span) { span.classList.add('highlight'); scrollIfNeeded(span, document.getElementById('text-panel-body')); } - wordTip.textContent = title ? '▸ ' + title : ''; - }); - div.addEventListener('mouseleave', () => { - div.classList.remove('highlight'); - const span = textOutput.querySelector('.word-span[data-word-id="' + id + '"]'); - if (span) span.classList.remove('highlight'); - wordTip.textContent = ''; - }); + div.addEventListener('mouseenter', () => { div.classList.add('highlight'); }); + div.addEventListener('mouseleave', () => { div.classList.remove('highlight'); }); return div; } -function goToPage(n) { - if (!state.pdfDoc) return; - n = Math.max(1, Math.min(state.totalPages, n)); - if (n === state.currentPage) return; - state.currentPage = n; - renderCurrentPage(); -} +let _pendingPage = null; // holds the last page requested while a render was in progress // ───────────────────────────────────────────────────────────────── // OCR TEXT RENDERING @@ -859,13 +931,92 @@ function loadOCRText(rawText, isHtmlMarkdown) { textOutput.style.display = 'block'; if (isHtmlMarkdown) { - textOutput.innerHTML = convertMarkdown(rawText); - addHoverSpansToTextNodes(textOutput); + // Parse the olmOCR markdown into a per-page dictionary so we can render + // exactly one page at a time — fast, accurate, and avoids the "highlight all" + // problem that comes from searching a giant single-document DOM. + state.ocrPages = parseOCRByPage(rawText); + state.ocrRawFull = ''; + // Do NOT call showOCRPage() here. If the PDF is already loaded, + // renderCurrentPage() will call showOCRPage() as part of its own flow. + // If the PDF isn't loaded yet (the common startup path where OCR arrives + // first), renderCurrentPage() will call showOCRPage() once it runs. + // Calling it here would build the OCR panel twice on every startup. + if (state.pdfDoc) { + showOCRPage(state.currentPage || 1); + } + // else: leave the panel blank — renderCurrentPage() will populate it. } else { - renderPlainTextWithLinks(rawText); + // Plain-text / sample mode — render as preformatted text + state.ocrPages = {}; + state.ocrRawFull = rawText; + textOutput.innerHTML = '
' + escH(rawText) + '
'; } } +// ── Parse olmOCR markdown into per-page dict ────────────────────────────── +// Each "## Page N" heading starts a new page entry. Content between two +// headings (tables, prose, weather notes) is stored under its page number. +function parseOCRByPage(rawText) { + const pages = {}; + const lines = rawText.split('\n'); + let pageNum = null; + let buf = []; + + for (const line of lines) { + const m = line.match(/^## Page (\d+)\s*$/); + if (m) { + if (pageNum !== null) pages[pageNum] = buf.join('\n'); + pageNum = parseInt(m[1], 10); + buf = [line]; // keep the heading at the top + } else if (pageNum !== null) { + buf.push(line); + } + } + if (pageNum !== null) pages[pageNum] = buf.join('\n'); + return pages; +} + +// ── Render the OCR panel for a single page ──────────────────────────────── +function showOCRPage(pageNum) { + // Always make the text panel visible (placeholder hidden) so navigation + // reliably switches panels even when called from renderCurrentPage. + textPlaceholder.style.display = 'none'; + textOutput.style.display = 'block'; + + if (!Object.keys(state.ocrPages).length) { + // OCR not loaded yet — show a polite notice and await the fetch + textOutput.innerHTML = + '

' + + 'OCR text not loaded yet. Use the ▶ Calgary Highlanders button or the OCR Override picker.

'; + ocrSyncLabel.textContent = ''; + return; + } + + const content = state.ocrPages[pageNum]; + if (!content) { + textOutput.innerHTML = + '

' + + 'No OCR text for page ' + pageNum + '.
' + + 'Pages with no diary entry (blank forms, maps) have no OCR content.

'; + ocrSyncLabel.textContent = 'page ' + pageNum + ' — no data'; + return; + } + + textOutput.innerHTML = convertMarkdown(content); + + // Apply saved correction if one exists for this page + if (state.corrections && state.corrections[pageNum]) { + textOutput.textContent = state.corrections[pageNum]; + } + + document.getElementById('text-panel-body').scrollTop = 0; + ocrSyncLabel.textContent = 'page ' + pageNum; + + // Phase 4/5 UI updates + updateFlagButtons(pageNum); + updateCorrectionIndicator(pageNum); +} + // Lightweight markdown + inline-HTML renderer (handles olmOCR .md format) function convertMarkdown(md) { const lines = md.split('\n'); @@ -905,94 +1056,19 @@ function inlineMd(s) { .replace(/\*(.+?)\*/g, '$1'); } -// Wrap text-node words in for hover -function addHoverSpansToTextNodes(root) { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { - acceptNode(node) { - const tag = node.parentElement && node.parentElement.tagName; - return ['TD','P','LI','STRONG','EM'].includes(tag) - ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; - } - }); - const nodes = []; - let n; - while ((n = walker.nextNode())) nodes.push(n); - - nodes.forEach(node => { - const frag = document.createDocumentFragment(); - node.textContent.split(/(\s+)/).forEach(tok => { - if (!tok) return; - if (/^\s+$/.test(tok)) { frag.appendChild(document.createTextNode(tok)); return; } - const span = document.createElement('span'); - span.className = 'word-span'; - span.textContent = tok; - span.addEventListener('mouseenter', () => { - span.style.background = 'var(--highlight)'; span.style.color = '#1a1a2e'; - wordTip.textContent = '▸ ' + tok; - }); - span.addEventListener('mouseleave', () => { - span.style.background = ''; span.style.color = ''; - wordTip.textContent = ''; - }); - frag.appendChild(span); - }); - node.parentElement.replaceChild(frag, node); - }); -} - -// Plain-text mode with exact word-id linking (sample data) -function renderPlainTextWithLinks(rawText) { - const queue = buildIdQueue(state.wordData); - let html = ''; - rawText.split(/(\s+)/).forEach(tok => { - if (/^\s+$/.test(tok)) { html += escH(tok); return; } - if (!tok) return; - const id = dequeueId(queue, normalise(tok)); - if (id) { - html += '' + escH(tok) + ''; - } else { - html += '' + escH(tok) + ''; - } - }); - textOutput.innerHTML = html; - - textOutput.querySelectorAll('.word-span[data-word-id]').forEach(span => { - span.addEventListener('mouseenter', () => { - span.classList.add('highlight'); - const box = overlay.querySelector('.word-box[data-word-id="' + span.dataset.wordId + '"]'); - if (box) { box.classList.add('highlight'); scrollIfNeeded(box, document.getElementById('image-panel-body')); } - wordTip.textContent = '▸ ' + (span.title || span.textContent); - }); - span.addEventListener('mouseleave', () => { - span.classList.remove('highlight'); - const box = overlay.querySelector('.word-box[data-word-id="' + span.dataset.wordId + '"]'); - if (box) box.classList.remove('highlight'); - wordTip.textContent = ''; - }); - }); -} - -// Scroll the OCR text panel to the matching page heading -function syncOCRToPage(pdfPageNum) { - if (textOutput.style.display === 'none') return; - const headings = textOutput.querySelectorAll('h2'); - for (const h of headings) { - const m = h.textContent.match(/Page\s+(\d+)/i); - if (m && parseInt(m[1], 10) === pdfPageNum) { - h.scrollIntoView({ behavior: 'smooth', block: 'start' }); - ocrSyncLabel.textContent = 'synced → page ' + pdfPageNum; - return; - } - } - ocrSyncLabel.textContent = ''; -} - // ───────────────────────────────────────────────────────────────── // WORD DATA (JSON SIDECAR) // ───────────────────────────────────────────────────────────────── function loadWordDataJSON(data) { state.wordData = data; - // In image mode: re-render overlay & re-link text spans + + // PDF mode: re-render so buildPDFWordOverlay picks up the new boxes + if (state.mode === 'pdf' && state.pdfDoc) { + renderCurrentPage(); + return; + } + + // Image mode: re-render overlay & re-link text spans (unchanged behaviour) if (state.mode === 'image' && pdfCanvas.width) { const sx = pdfCanvas.width / state.imageNaturalW; const sy = pdfCanvas.height / state.imageNaturalH; @@ -1008,19 +1084,7 @@ function loadWordDataJSON(data) { // ───────────────────────────────────────────────────────────────── // HELPERS // ───────────────────────────────────────────────────────────────── -function buildIdQueue(wordData) { - const m = {}; - wordData.forEach(e => { const k = normalise(e.word); if (!m[k]) m[k] = []; m[k].push(e.id); }); - return m; -} -function dequeueId(m, k) { return (m[k] && m[k].length) ? m[k].shift() : null; } -function normalise(s) { return s.replace(/[.,;:!?'")(—–\-]+$/g,'').toLowerCase(); } function escH(s) { return s.replace(/&/g,'&').replace(//g,'>'); } -function escA(s) { return s.replace(/"/g,'"'); } -function scrollIfNeeded(el, container) { - const er = el.getBoundingClientRect(), cr = container.getBoundingClientRect(); - if (er.top < cr.top || er.bottom > cr.bottom) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); -} function showImgSpinner(on) { imgSpinner.style.display = on ? 'inline-block' : 'none'; imgPhText.style.display = on ? 'none' : 'block'; @@ -1047,8 +1111,304 @@ function setStatus(msg, warn) { statusMsg.textContent = msg; statusMsg.style.color = warn ? '#e07070' : 'var(--text-dim)'; } + +// ───────────────────────────────────────────────────────────────── +// PHASE 2 — SYNCHRONIZED SCROLLING +// ───────────────────────────────────────────────────────────────── +let _syncScrolling = false; +let _syncActive = true; + +function onImgScroll() { + if (!_syncActive || _syncScrolling) return; + _syncScrolling = true; + const pct = imagePanelBody.scrollTop / + Math.max(1, imagePanelBody.scrollHeight - imagePanelBody.clientHeight); + textPanelBody.scrollTop = pct * + Math.max(1, textPanelBody.scrollHeight - textPanelBody.clientHeight); + requestAnimationFrame(() => { _syncScrolling = false; }); +} +function onTxtScroll() { + if (!_syncActive || _syncScrolling) return; + _syncScrolling = true; + const pct = textPanelBody.scrollTop / + Math.max(1, textPanelBody.scrollHeight - textPanelBody.clientHeight); + imagePanelBody.scrollTop = pct * + Math.max(1, imagePanelBody.scrollHeight - imagePanelBody.clientHeight); + requestAnimationFrame(() => { _syncScrolling = false; }); +} + +imagePanelBody.addEventListener('scroll', onImgScroll); +textPanelBody.addEventListener('scroll', onTxtScroll); + +btnSyncScroll.addEventListener('click', () => { + _syncActive = !_syncActive; + btnSyncScroll.classList.toggle('active', _syncActive); + setStatus('Synchronized scrolling ' + (_syncActive ? 'on' : 'off') + '.'); +}); + +// ───────────────────────────────────────────────────────────────── +// PHASE 3 — CLICK-TO-ZOOM ON LEFT PANEL +// ───────────────────────────────────────────────────────────────── +let _zoomClickX = -1, _zoomClickY = -1; + +function positionZoomLens() { + const panelRect = imagePanelBody.getBoundingClientRect(); + zoomLens.style.top = (panelRect.top + 8) + 'px'; + zoomLens.style.right = (window.innerWidth - panelRect.right + 8) + 'px'; + // 'left' must be unset so 'right' takes effect + zoomLens.style.left = 'auto'; +} + +pdfCanvas.addEventListener('click', e => { + const rect = pdfCanvas.getBoundingClientRect(); + const cx = (e.clientX - rect.left) * (pdfCanvas.width / rect.width); + const cy = (e.clientY - rect.top) * (pdfCanvas.height / rect.height); + + // Second click on same spot dismisses + if (zoomLens.style.display !== 'none' && + Math.abs(cx - _zoomClickX) < 10 && Math.abs(cy - _zoomClickY) < 10) { + zoomLens.style.display = 'none'; + return; + } + + _zoomClickX = cx; _zoomClickY = cy; + positionZoomLens(); + drawZoom(cx, cy); + zoomLens.style.display = 'block'; +}); + +// Keep lens anchored to panel corner if user scrolls or resizes +imagePanelBody.addEventListener('scroll', () => { + if (zoomLens.style.display !== 'none') positionZoomLens(); +}); +window.addEventListener('resize', () => { + if (zoomLens.style.display !== 'none') positionZoomLens(); +}); + +zoomLens.addEventListener('click', () => { zoomLens.style.display = 'none'; }); +zoomClose.addEventListener('click', e => { e.stopPropagation(); zoomLens.style.display = 'none'; }); + +function drawZoom(cx, cy) { + const CROP = 200; // fixed 200×200 source-pixel region, scaled 3× into 280×280 lens + const lensW = 280, lensH = 280; + zoomCanvas.width = lensW; + zoomCanvas.height = lensH; + + // Clamp crop region inside canvas bounds + const sx = Math.max(0, Math.min(cx - CROP / 2, pdfCanvas.width - CROP)); + const sy = Math.max(0, Math.min(cy - CROP / 2, pdfCanvas.height - CROP)); + + zoomCanvas.getContext('2d').drawImage(pdfCanvas, sx, sy, CROP, CROP, 0, 0, lensW, lensH); +} + +// ───────────────────────────────────────────────────────────────── +// PHASE 4 — FLAG / ANNOTATION SYSTEM +// ───────────────────────────────────────────────────────────────── +function updateFlagButtons(pageNum) { + const flag = state.flags[pageNum] || null; + btnFlagVerified.classList.toggle('active', flag === 'verified'); + btnFlagReview.classList.toggle('active', flag === 'review'); + btnFlagError.classList.toggle('active', flag === 'error'); +} + +function updateFlagsSummary() { + let v = 0, r = 0, er = 0; + Object.values(state.flags).forEach(f => { + if (f === 'verified') v++; + else if (f === 'review') r++; + else if (f === 'error') er++; + }); + flagsSummary.textContent = v || r || er + ? '✓ ' + v + ' ⚑ ' + r + ' ✗ ' + er + : ''; +} + +async function saveFlags() { + try { + const res = await fetch('/save-flags', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(state.flags), + }); + if (res.ok) return; + } catch (_) {} + // Server doesn't support it (or offline) — fall back to localStorage + try { localStorage.setItem('p44-flags', JSON.stringify(state.flags)); } catch (_) {} +} + +function setFlag(type) { + const p = state.currentPage; + if (type === null) { + delete state.flags[p]; + } else { + state.flags[p] = type; + } + updateFlagButtons(p); + updateFlagsSummary(); + saveFlags(); +} + +btnFlagVerified.addEventListener('click', () => setFlag('verified')); +btnFlagReview.addEventListener('click', () => setFlag('review')); +btnFlagError.addEventListener('click', () => setFlag('error')); +btnFlagClear.addEventListener('click', () => setFlag(null)); + +async function loadFlagsOnStartup() { + try { + const res = await fetch('/flags.json'); + if (res.ok) { + state.flags = await res.json(); + updateFlagsSummary(); + return; + } + } catch (_) {} + // Fallback: localStorage + try { + const stored = localStorage.getItem('p44-flags'); + if (stored) { state.flags = JSON.parse(stored); updateFlagsSummary(); } + } catch (_) {} +} + +// ───────────────────────────────────────────────────────────────── +// PHASE 5 — INLINE TEXT EDITING +// ───────────────────────────────────────────────────────────────── +function updateCorrectionIndicator(pageNum) { + correctionDot.style.display = state.corrections[pageNum] ? 'inline-block' : 'none'; +} + +function saveEditedPage() { + if (!state.editMode) return; + const pageNum = state.currentPage; + // Strip HTML tags to get plain text correction + const plain = textOutput.innerText || textOutput.textContent; + if (plain.trim()) { + state.corrections[pageNum] = plain; + } else { + delete state.corrections[pageNum]; + } + postCorrection(pageNum, plain); + updateCorrectionIndicator(pageNum); +} + +async function postCorrection(pageNum, text) { + try { + const res = await fetch('/save-corrections', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ page: pageNum, text }), + }); + if (res.ok) return; + } catch (_) {} + try { + const stored = JSON.parse(localStorage.getItem('p44-corrections') || '{}'); + stored[pageNum] = text; + localStorage.setItem('p44-corrections', JSON.stringify(stored)); + } catch (_) {} +} + +async function loadCorrectionsOnStartup() { + try { + const res = await fetch('/corrections.json'); + if (res.ok) { state.corrections = await res.json(); return; } + } catch (_) {} + try { + const stored = localStorage.getItem('p44-corrections'); + if (stored) state.corrections = JSON.parse(stored); + } catch (_) {} +} + +function enterEditMode() { + state.editMode = true; + textOutput.contentEditable = 'true'; + textOutput.focus(); + btnEdit.textContent = 'Editing'; + btnEdit.classList.add('active'); +} + +function exitEditMode() { + if (!state.editMode) return; + saveEditedPage(); + state.editMode = false; + textOutput.contentEditable = 'false'; + btnEdit.textContent = 'Edit'; + btnEdit.classList.remove('active'); +} + +btnEdit.addEventListener('click', () => { + if (state.editMode) exitEditMode(); + else enterEditMode(); +}); + +// ───────────────────────────────────────────────────────────────── +// PHASE 6 — KEYBOARD NAVIGATION +// ───────────────────────────────────────────────────────────────── +document.addEventListener('keydown', e => { + // Do not intercept keys while user is typing in the text panel or page-number input + if (state.editMode) return; + const tag = document.activeElement && document.activeElement.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement === textOutput) return; + + switch (e.key) { + case 'ArrowRight': case 'l': + e.preventDefault(); goToPage(state.currentPage + 1); break; + case 'ArrowLeft': case 'j': + e.preventDefault(); goToPage(state.currentPage - 1); break; + case 'v': + setFlag('verified'); break; + case 'r': + setFlag('review'); break; + case 'e': + setFlag('error'); break; + case 'Escape': + if (zoomLens.style.display !== 'none') { zoomLens.style.display = 'none'; } + else if (state.editMode) { exitEditMode(); } + break; + } +}); + +// ───────────────────────────────────────────────────────────────── +// HOOK INTO showOCRPage TO APPLY FLAGS/CORRECTIONS/EDIT STATE +// ───────────────────────────────────────────────────────────────── +// Override goToPage to save edits before navigating +function goToPage(n) { + if (state.editMode) exitEditMode(); + if (!state.pdfDoc) return; + n = Math.max(1, Math.min(state.totalPages, n)); + if (n === state.currentPage && !state.rendering) return; + if (state.rendering) { _pendingPage = n; return; } + state.currentPage = n; + renderCurrentPage(); +} + +// ───────────────────────────────────────────────────────────────── +// STARTUP — load persistent data +// ───────────────────────────────────────────────────────────────── +loadFlagsOnStartup(); +loadCorrectionsOnStartup(); + + + + + + + + + + + + + + + + + + + + + + diff --git a/prompts/OCR-Viewer.md b/prompts/OCR-Viewer.md new file mode 100644 index 0000000..ecc9034 --- /dev/null +++ b/prompts/OCR-Viewer.md @@ -0,0 +1,135 @@ +Here's your step-by-step guide. Give Copilot one phase at a time — do not paste them all at once. +PHASE 1 — CLEANUP: Remove all cross-highlighting code + +Paste this first: + +I need to clean up p44-ocr-viewer.html by removing all cross-panel word highlighting code that was never fully working. Do not change any CSS, layout, toolbar, PDF rendering, or OCR text rendering. Only remove the highlighting infrastructure. + +Remove the following: + + addHoverSpansToTextNodes() — the entire function + highlightBoxesByText() — the entire function + clearHighlightedBoxes() — the entire function + highlightSpansByText() — the entire function + clearHighlightedSpans() — the entire function + renderPlainTextWithLinks() — the entire function + buildIdQueue() — the entire function + dequeueId() — the entire function + The call to addHoverSpansToTextNodes(textOutput) inside showOCRPage() + The highlightSpansByText(title) fallback call inside makeWordBox() + The clearHighlightedSpans() call inside makeWordBox() + state.wordSequence if it was added + state.useJsonOverlay if it was added + All console.log statements added for debugging (the olmOCR word spans logs, PDF.js getTextContent logs) + +Also remove the .word-span CSS rules from the stylesheet — the #text-output .word-span block. + +After removal, verify: + + showOCRPage() still renders text correctly + makeWordBox() still builds overlay boxes (they just won't trigger any cross-panel effects) + The #word-tip footer element can stay but its content should never be set + Sample data / JSON sidecar path must still work + +PHASE 2 — Synchronized scrolling + +After Phase 1 is working, paste this: + +Add synchronized scrolling to p44-ocr-viewer.html. When the user scrolls either panel, the other panel scrolls to the same proportional vertical position. + +Implementation: + + Get references to both scroll containers: document.getElementById('image-panel-body') and document.getElementById('text-panel-body') + Add a scroll event listener to each. When one fires, calculate the scroll percentage: scrollTop / (scrollHeight - clientHeight). Apply that same percentage to the other panel's scrollTop. + Use a isSyncing boolean flag to prevent the two listeners from triggering each other in a loop. Set it true before programmatically scrolling, then false in a requestAnimationFrame callback after. + Add a toggle button to the toolbar labelled Sync Scroll with an active/inactive visual state (use the existing .tb-btn class). When inactive, the listeners are disconnected. Default state is active. + +Constraints: + + Do not change any existing scroll behaviour on page navigation + Do not change any CSS other than adding an .active state for the sync button that gives it border-color: var(--gold); color: var(--gold); + +PHASE 3 — Click-to-zoom on the left panel + +After Phase 2 is working, paste this: + +Add click-to-zoom to the left panel (the PDF scan) in p44-ocr-viewer.html. + +Implementation: + + When the user clicks anywhere on #pdf-canvas, calculate the click position as a percentage of the canvas width and height. + Create a zoom overlay div with id zoom-lens that appears in the top-right corner of the left panel (position absolute, 280px × 200px, dark border, z-index 10). It contains a second canvas #zoom-canvas. + On click, draw a 3× magnified crop of the clicked region onto #zoom-canvas. The crop region is centered on the click point and covers 1/3 of the canvas width and height. + Clicking the same spot again, or clicking #zoom-lens itself, dismisses the zoom overlay. + Add a small ✕ close button in the top-right corner of #zoom-lens. + #zoom-lens styling: background: var(--surface), border: 1px solid var(--gold), border-radius: 4px. Use existing CSS variables throughout. + +Constraints: + + Do not affect PDF rendering or page navigation + The zoom lens must not appear until after the first click + +PHASE 4 — Flag/annotation system + +After Phase 3 is working, paste this: + +Add a per-page flag/annotation system to p44-ocr-viewer.html. Reviewers can mark pages and the results persist to disk via the existing Python server. + +Implementation: + + Add three buttons to the right panel header (inside .panel-header, after the existing ocr-sync-label): ✓ Verified, ⚑ Needs Review, ✗ Error Found. Use the existing .tb-btn class. Add a fourth button Clear to remove a flag. + Store flags in a state.flags object keyed by page number: { 1: 'verified', 8: 'error', 12: 'review' }. + On page change, update the visual state of the three buttons to reflect the current page's flag (highlight the active one with border-color: var(--gold); color: var(--gold)). + Save flags by POSTing JSON to /save-flags on the local server. The payload is the entire state.flags object. If the POST fails silently (server doesn't support it yet), store in localStorage as a fallback with key p44-flags. + On startup, fetch /flags.json from the server. If it returns 200, load into state.flags. If it 404s, check localStorage fallback. If neither exists, start with empty flags. + Add a flags-summary display to the footer showing counts: e.g. ✓ 12 ⚑ 4 ✗ 2. + In launch_viewer.py, add a /save-flags POST handler that writes the received JSON to ./flags.json in the project root. Also serve ./flags.json as a static file (it already will be via SimpleHTTPRequestHandler). + +Constraints: + + Do not change PDF rendering, OCR rendering, or scroll sync + Flags must survive a browser refresh + +PHASE 5 — Inline text editing + +After Phase 4 is working, paste this: + +Make the OCR text panel editable in p44-ocr-viewer.html so reviewers can correct OCR errors inline. + +Implementation: + + Add a toggle button to the right panel header labelled Edit using the existing .tb-btn class. When active, it shows Editing with gold styling. + When edit mode is active, set #text-output to contenteditable="true" and add a subtle inset border: outline: 1px solid var(--gold-dim) on the element. + When edit mode is toggled off, read the current innerHTML of #text-output, strip all HTML tags to get plain text, and save it as the corrected text for this page in state.corrections[pageNum]. + Save corrections by POSTing to /save-corrections with payload { page: N, text: "..." }. If POST fails, store in localStorage under key p44-corrections. + On startup, fetch /corrections.json. If found, load into state.corrections. On page render, if state.corrections[pageNum] exists, display the corrected text instead of the raw olmOCR text. + In launch_viewer.py, add a /save-corrections POST handler that maintains a corrections.json file in the project root — a dict keyed by page number. + Add a visual indicator in the right panel header when the current page has a saved correction: a small gold dot or [edited] label next to the page number. + +Constraints: + + Switching pages while in edit mode should auto-save before navigating + Do not affect flags, scroll sync, zoom, or PDF rendering + +PHASE 6 — Keyboard navigation + +After Phase 5 is working, paste this: + +Add keyboard navigation to p44-ocr-viewer.html. + +Implementation: + + Add a keydown event listener on document. + Map the following keys — but only when focus is NOT inside #text-output (to avoid interfering with editing) and NOT inside any input element: + ArrowRight or l → next page (goToPage(state.currentPage + 1)) + ArrowLeft or j → previous page (goToPage(state.currentPage - 1)) + v → mark current page Verified + r → mark current page Needs Review + e → mark current page Error Found + Escape → close zoom lens if open; exit edit mode if active + Add a small keyboard shortcut hint to the footer, right-aligned, in var(--text-dim) colour: ← → navigate v/r/e flag esc close + +Constraints: + + No key should fire while the user is typing in the edit panel or the page number input + Do not change any existing event listeners