removed highlighting text. Added in magnifying words left side, sync scrolling, keyboard shortcuts, editing text etc
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -346,7 +385,7 @@ html, body {
|
||||
|
||||
<div class="toolbar-group">
|
||||
<span class="lbl">SCALE</span>
|
||||
<select id="scale-select">
|
||||
<select id="scale-select" aria-label="Zoom scale">
|
||||
<option value="0.75">75%</option>
|
||||
<option value="1.0" selected>100%</option>
|
||||
<option value="1.25">125%</option>
|
||||
@@ -356,6 +395,8 @@ html, body {
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<button id="btn-example" class="tb-btn">Load sample</button>
|
||||
<div class="divider"></div>
|
||||
<button id="btn-sync-scroll" class="tb-btn active">Sync Scroll</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Panels ──────────────────────────────────────────────── -->
|
||||
@@ -367,12 +408,16 @@ html, body {
|
||||
<span class="ph-title">Source Document — Page Scan</span>
|
||||
<div class="page-nav">
|
||||
<button id="btn-prev" disabled>◀</button>
|
||||
<input type="number" id="page-input" value="1" min="1" />
|
||||
<input type="number" id="page-input" value="1" min="1" aria-label="Page number" />
|
||||
<button id="btn-next" disabled>▶</button>
|
||||
<span id="page-label">/ —</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body" id="image-panel-body">
|
||||
<div id="zoom-lens">
|
||||
<button id="zoom-close">✕</button>
|
||||
<canvas id="zoom-canvas"></canvas>
|
||||
</div>
|
||||
<div class="panel-placeholder" id="image-placeholder">
|
||||
<div class="spinner" id="img-spinner" style="display:none;"></div>
|
||||
<div id="img-ph-text">
|
||||
@@ -392,6 +437,12 @@ html, body {
|
||||
<div class="panel-header">
|
||||
<span class="ph-title">OCR Text Output</span>
|
||||
<span id="ocr-sync-label" style="font-size:9px;color:var(--gold-dim);"></span>
|
||||
<div id="correction-dot" title="This page has been edited"></div>
|
||||
<button id="btn-flag-verified" class="tb-btn flag-btn" title="Mark page as verified">✓ Verified</button>
|
||||
<button id="btn-flag-review" class="tb-btn flag-btn" title="Mark page for review">⚑ Review</button>
|
||||
<button id="btn-flag-error" class="tb-btn flag-btn" title="Mark page as having an error">✗ Error</button>
|
||||
<button id="btn-flag-clear" class="tb-btn flag-btn" title="Clear flag">Clear</button>
|
||||
<button id="btn-edit" class="tb-btn" title="Toggle edit mode">Edit</button>
|
||||
</div>
|
||||
<div class="panel-body" id="text-panel-body">
|
||||
<div class="panel-placeholder" id="text-placeholder">
|
||||
@@ -409,7 +460,8 @@ html, body {
|
||||
<!-- ── Footer ──────────────────────────────────────────────── -->
|
||||
<div id="footer">
|
||||
<span id="status-msg">Ready — run launch_viewer.py to auto-load Calgary Highlanders data.</span>
|
||||
<span id="word-tip"></span>
|
||||
<span id="flags-summary"></span>
|
||||
<span id="kbd-hint">← → navigate v/r/e flag esc close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 = '<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;line-height:1.75;color:var(--text);">' + escH(rawText) + '</pre>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 =
|
||||
'<p style="padding:24px;color:var(--text-dim);font-style:italic;text-align:center;">' +
|
||||
'OCR text not loaded yet. Use the ▶ Calgary Highlanders button or the OCR Override picker.</p>';
|
||||
ocrSyncLabel.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const content = state.ocrPages[pageNum];
|
||||
if (!content) {
|
||||
textOutput.innerHTML =
|
||||
'<p style="padding:24px;color:var(--text-dim);font-style:italic;text-align:center;">' +
|
||||
'No OCR text for page ' + pageNum + '.<br>' +
|
||||
'<small style="font-size:10px;">Pages with no diary entry (blank forms, maps) have no OCR content.</small></p>';
|
||||
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, '<em>$1</em>');
|
||||
}
|
||||
|
||||
// Wrap text-node words in <span class="word-span"> 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 += '<span class="word-span" data-word-id="' + id + '" title="' + escA(tok) + '">' + escH(tok) + '</span>';
|
||||
} else {
|
||||
html += '<span class="word-span">' + escH(tok) + '</span>';
|
||||
}
|
||||
});
|
||||
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,'<').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();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
135
prompts/OCR-Viewer.md
Normal file
135
prompts/OCR-Viewer.md
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user