Compare commits

..

2 Commits

5 changed files with 763 additions and 199 deletions

View File

@@ -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)

View File

@@ -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 &nbsp; v/r/e flag &nbsp; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escA(s) { return s.replace(/"/g,'&quot;'); }
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
View 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

View File

@@ -6,9 +6,11 @@ Outputs a JSON array of position objects.
import re
import json
from html.parser import HTMLParser
from pathlib import Path
INPUT_FILE = r"C:\Users\natha\IdeaProjects\AI-Prototype\Inputs\ocr-output\Calgary-Highlanders_War-Diary_Sep44_olmocr.md"
OUTPUT_FILE = r"C:\Users\natha\IdeaProjects\AI-Prototype\outputs\Calgary-Highlanders_Sep44_positions.json"
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
INPUT_FILE = _PROJECT_ROOT / "Inputs" / "ocr-output" / "Calgary-Highlanders_War-Diary_Sep44_olmocr.md"
OUTPUT_FILE = _PROJECT_ROOT / "outputs" / "Calgary-Highlanders_Sep44_positions.json"
# ── helpers ──────────────────────────────────────────────────────────────────
@@ -671,10 +673,8 @@ def assign_end_of_day(positions: list[dict]) -> list[dict]:
# ── run ───────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import pathlib
print("Reading source file …")
text = pathlib.Path(INPUT_FILE).read_text(encoding="utf-8")
text = INPUT_FILE.read_text(encoding="utf-8")
print("Extracting positions …")
positions = extract_positions(text)
@@ -691,8 +691,8 @@ if __name__ == "__main__":
print("Assigning end-of-day flags …")
unique = assign_end_of_day(unique)
pathlib.Path(OUTPUT_FILE).parent.mkdir(parents=True, exist_ok=True)
pathlib.Path(OUTPUT_FILE).write_text(
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_FILE.write_text(
json.dumps(unique, indent=2, ensure_ascii=False),
encoding="utf-8"
)
@@ -716,6 +716,8 @@ if __name__ == "__main__":

View File

@@ -10,7 +10,7 @@ Requirements:
Poppler must be on PATH (pdftoppm command must work).
Usage:
python ocr_wardiaries.py --input_dir "C:\path\to\pdfs" --output_dir "C:\path\to\output" --api_key "YOUR_KEY"
python ocr_wardiaries.py --input_dir "C:/path/to/pdfs" --output_dir "C:/path/to/output" --api_key "YOUR_KEY"
"""
import argparse