1415 lines
56 KiB
HTML
1415 lines
56 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>P44 OCR Viewer — Calgary Highlanders Sep 44</title>
|
||
<!-- PDF.js (CDN — needs internet access; served via launch_viewer.py) -->
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||
<style>
|
||
/* ── Reset & base ─────────────────────────────────────────────── */
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--bg: #1a1a2e;
|
||
--surface: #16213e;
|
||
--surface2: #0f3460;
|
||
--gold: #c8a84b;
|
||
--gold-dim: #8a6f2e;
|
||
--text: #ddd4bb;
|
||
--text-dim: #7a7260;
|
||
--highlight: #ffe066;
|
||
--border: #2e2e4e;
|
||
--toolbar-h: 52px;
|
||
--footer-h: 34px;
|
||
}
|
||
|
||
html, body {
|
||
height: 100%;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: 'Georgia', 'Times New Roman', serif;
|
||
font-size: 14px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── App shell ────────────────────────────────────────────────── */
|
||
#app {
|
||
display: grid;
|
||
grid-template-rows: var(--toolbar-h) 1fr var(--footer-h);
|
||
height: 100vh;
|
||
}
|
||
|
||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||
#toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 0 12px;
|
||
background: var(--surface);
|
||
border-bottom: 2px solid var(--gold-dim);
|
||
overflow: hidden;
|
||
flex-wrap: nowrap;
|
||
}
|
||
|
||
#toolbar h1 {
|
||
font-size: 13px;
|
||
font-weight: normal;
|
||
letter-spacing: 0.1em;
|
||
color: var(--gold);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.divider { width: 1px; height: 26px; background: var(--border); flex-shrink: 0; margin: 0 2px; }
|
||
|
||
.toolbar-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
.toolbar-group .lbl {
|
||
font-size: 9px;
|
||
color: var(--text-dim);
|
||
letter-spacing: 0.06em;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.file-btn {
|
||
display: inline-block;
|
||
padding: 3px 8px;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--gold-dim);
|
||
border-radius: 3px;
|
||
color: var(--text);
|
||
font-size: 10px;
|
||
cursor: pointer;
|
||
letter-spacing: 0.03em;
|
||
transition: border-color 0.15s, background 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.file-btn:hover { border-color: var(--gold); background: #1a2a50; }
|
||
.file-btn input[type="file"] { display: none; }
|
||
|
||
.tb-btn {
|
||
padding: 3px 9px;
|
||
background: transparent;
|
||
border: 1px solid var(--gold-dim);
|
||
border-radius: 3px;
|
||
color: var(--text-dim);
|
||
font-size: 10px;
|
||
font-family: inherit;
|
||
letter-spacing: 0.05em;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
.tb-btn:hover { border-color: var(--gold); color: var(--gold); background: rgba(200,168,75,0.08); }
|
||
|
||
#btn-load-ch { border-color: var(--gold); color: var(--gold); font-weight: bold; }
|
||
#btn-load-ch:hover { background: rgba(200,168,75,0.15); }
|
||
|
||
#scale-select {
|
||
background: var(--surface2);
|
||
border: 1px solid var(--gold-dim);
|
||
border-radius: 3px;
|
||
color: var(--text);
|
||
font-size: 10px;
|
||
font-family: inherit;
|
||
padding: 2px 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* ── Main panels ──────────────────────────────────────────────── */
|
||
#panels {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
border-right: 1px solid var(--border);
|
||
}
|
||
.panel:last-child { border-right: none; }
|
||
|
||
.panel-header {
|
||
padding: 5px 10px;
|
||
font-size: 9px;
|
||
letter-spacing: 0.12em;
|
||
color: var(--gold-dim);
|
||
text-transform: uppercase;
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.panel-header .ph-title { flex: 1; }
|
||
|
||
/* page navigation */
|
||
.page-nav { display: flex; align-items: center; gap: 4px; }
|
||
.page-nav button {
|
||
background: var(--surface2); border: 1px solid var(--gold-dim);
|
||
border-radius: 2px; color: var(--text); font-size: 10px;
|
||
padding: 1px 6px; cursor: pointer; line-height: 1.5;
|
||
}
|
||
.page-nav button:hover { border-color: var(--gold); color: var(--gold); }
|
||
.page-nav button:disabled { opacity: 0.3; cursor: default; }
|
||
.page-nav input[type="number"] {
|
||
width: 42px; text-align: center;
|
||
background: var(--surface2); border: 1px solid var(--gold-dim);
|
||
border-radius: 2px; color: var(--text); font-size: 9px; padding: 2px 3px;
|
||
}
|
||
#page-label { font-size: 9px; color: var(--text-dim); white-space: nowrap; }
|
||
|
||
.panel-body {
|
||
flex: 1;
|
||
overflow: auto;
|
||
position: relative;
|
||
}
|
||
|
||
/* ── Left panel: PDF canvas + overlay ────────────────────────── */
|
||
#pdf-container {
|
||
position: relative;
|
||
display: inline-block;
|
||
min-width: 100%;
|
||
}
|
||
|
||
#pdf-canvas {
|
||
display: block;
|
||
max-width: 100%;
|
||
height: auto;
|
||
user-select: none;
|
||
background: #e8e0cc;
|
||
}
|
||
|
||
#overlay {
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* noinspection CssUnusedSymbol */
|
||
.word-box {
|
||
position: absolute;
|
||
border: 1px solid transparent;
|
||
border-radius: 1px;
|
||
cursor: crosshair;
|
||
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);
|
||
}
|
||
|
||
.panel-placeholder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
gap: 12px;
|
||
color: var(--text-dim);
|
||
font-size: 13px;
|
||
font-style: italic;
|
||
padding: 40px;
|
||
text-align: center;
|
||
}
|
||
.panel-placeholder em {
|
||
color: var(--gold-dim);
|
||
font-size: 11px;
|
||
font-style: normal;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.spinner {
|
||
width: 22px; height: 22px;
|
||
border: 2px solid var(--surface2);
|
||
border-top-color: var(--gold);
|
||
border-radius: 50%;
|
||
animation: spin 0.7s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* ── Right panel: OCR text ────────────────────────────────────── */
|
||
#text-output {
|
||
padding: 16px 20px;
|
||
font-size: 12.5px;
|
||
color: var(--text);
|
||
line-height: 1.75;
|
||
}
|
||
|
||
/* olmOCR HTML tables */
|
||
#text-output table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 8px 0 16px;
|
||
font-size: 12px;
|
||
}
|
||
#text-output th, #text-output td {
|
||
border: 1px solid var(--border);
|
||
padding: 5px 8px;
|
||
vertical-align: top;
|
||
text-align: left;
|
||
}
|
||
#text-output th {
|
||
background: var(--surface);
|
||
color: var(--gold-dim);
|
||
font-size: 9px;
|
||
letter-spacing: 0.07em;
|
||
text-transform: uppercase;
|
||
font-weight: normal;
|
||
}
|
||
#text-output tr:nth-child(even) td { background: rgba(22,33,62,0.4); }
|
||
|
||
#text-output h2 {
|
||
font-size: 12px;
|
||
color: var(--gold);
|
||
letter-spacing: 0.1em;
|
||
margin: 18px 0 6px;
|
||
border-bottom: 1px solid var(--border);
|
||
padding-bottom: 3px;
|
||
}
|
||
#text-output h3 { font-size: 11px; color: var(--gold-dim); margin: 10px 0 4px; }
|
||
#text-output hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
|
||
#text-output p { margin: 4px 0; }
|
||
#text-output ol, #text-output ul { padding-left: 20px; margin: 4px 0; }
|
||
#text-output li { margin: 2px 0; }
|
||
#text-output em { color: var(--text-dim); }
|
||
|
||
/* ── Footer ───────────────────────────────────────────────────── */
|
||
#footer {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 12px;
|
||
gap: 10px;
|
||
background: var(--surface);
|
||
border-top: 1px solid var(--border);
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
}
|
||
#status-msg { flex: 1; font-style: italic; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||
|
||
/* ── 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>
|
||
<div id="app">
|
||
|
||
<!-- ── Toolbar ─────────────────────────────────────────────── -->
|
||
<div id="toolbar">
|
||
<h1>⚜ P44 OCR VIEWER</h1>
|
||
<div class="divider"></div>
|
||
|
||
<button id="btn-load-ch" class="tb-btn">▶ Calgary Highlanders — Sep 44</button>
|
||
<div class="divider"></div>
|
||
|
||
<div class="toolbar-group">
|
||
<span class="lbl">PDF</span>
|
||
<label class="file-btn">Override…<input type="file" id="input-pdf" accept=".pdf,application/pdf" /></label>
|
||
</div>
|
||
<div class="toolbar-group">
|
||
<span class="lbl">OCR</span>
|
||
<label class="file-btn">Override…<input type="file" id="input-text" accept=".txt,.md" /></label>
|
||
</div>
|
||
<div class="toolbar-group">
|
||
<span class="lbl">JSON</span>
|
||
<label class="file-btn">Override…<input type="file" id="input-json" accept=".json" /></label>
|
||
</div>
|
||
<div class="divider"></div>
|
||
|
||
<div class="toolbar-group">
|
||
<span class="lbl">SCALE</span>
|
||
<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>
|
||
<option value="1.5">150%</option>
|
||
<option value="2.0">200%</option>
|
||
</select>
|
||
</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 ──────────────────────────────────────────────── -->
|
||
<div id="panels">
|
||
|
||
<!-- Left: PDF / image -->
|
||
<div class="panel">
|
||
<div class="panel-header">
|
||
<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" 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">
|
||
War diary scan loads automatically.<br>
|
||
<em>Run launch_viewer.py — or click the button above</em>
|
||
</div>
|
||
</div>
|
||
<div id="pdf-container" style="display:none;">
|
||
<canvas id="pdf-canvas"></canvas>
|
||
<div id="overlay"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: OCR text -->
|
||
<div class="panel">
|
||
<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">
|
||
<div class="spinner" id="txt-spinner" style="display:none;"></div>
|
||
<div id="txt-ph-text">
|
||
OCR transcript loads automatically.<br>
|
||
<em>Run launch_viewer.py — or click the button above</em>
|
||
</div>
|
||
</div>
|
||
<div id="text-output" style="display:none;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Footer ──────────────────────────────────────────────── -->
|
||
<div id="footer">
|
||
<span id="status-msg">Ready — run launch_viewer.py to auto-load Calgary Highlanders data.</span>
|
||
<span id="flags-summary"></span>
|
||
<span id="kbd-hint">← → navigate v/r/e flag esc close</span>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
'use strict';
|
||
|
||
// ── PDF.js worker ────────────────────────────────────────────────
|
||
if (typeof pdfjsLib !== 'undefined') {
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||
}
|
||
|
||
// ── Data paths (only work when served by launch_viewer.py) ───────
|
||
const REAL_PDF_PATH = './Inputs/Calgary-Highlanders_War-Diary_Sep44.pdf';
|
||
const REAL_TEXT_PATH = './Inputs/ocr-output/Calgary-Highlanders_War-Diary_Sep44_olmocr.md';
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// SAMPLE DATA (canvas image + plain text + JSON sidecar)
|
||
// Used by "Load sample" button only — works without launch_viewer.py
|
||
// ─────────────────────────────────────────────────────────────────
|
||
function buildSampleImageDataURL() {
|
||
const W = 620, H = 440;
|
||
const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
|
||
const ctx = cv.getContext('2d');
|
||
ctx.fillStyle = '#f5eed8'; ctx.fillRect(0, 0, W, H);
|
||
ctx.strokeStyle = 'rgba(180,160,110,0.18)';
|
||
for (let y = 0; y < H; y += 6) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
|
||
ctx.strokeStyle = '#b8a070'; ctx.lineWidth = 3; ctx.strokeRect(14,14,W-28,H-28);
|
||
ctx.lineWidth = 1; ctx.strokeStyle = '#c8b880'; ctx.strokeRect(20,20,W-40,H-40);
|
||
ctx.fillStyle = '#2a1a05'; ctx.font = 'bold 18px Georgia,serif';
|
||
ctx.fillText('WAR DIARY — CALGARY HIGHLANDERS', 40, 58);
|
||
ctx.font = '13px Georgia,serif'; ctx.fillStyle = '#3a2a10';
|
||
ctx.fillText('September 1944 — Northwestern Europe', 40, 80);
|
||
ctx.strokeStyle = '#b8a070'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(40,92); ctx.lineTo(W-40,92); ctx.stroke();
|
||
ctx.font = '14px Georgia,serif'; ctx.fillStyle = '#1a0f00';
|
||
[
|
||
{y:118, t:'1 Sep 44 Unit moved forward to assembly area near DOUVRES.'},
|
||
{y:148, t:' Weather clear. Roads congested with transport.'},
|
||
{y:178, t:'2 Sep 44 Advance continued. Contact with enemy at MR 442891.'},
|
||
{y:208, t:' Artillery support provided by 5th Field Regt RCA.'},
|
||
{y:238, t:'3 Sep 44 Objective CANAL secured by 1400 hrs. Casualties: 3 OR.'},
|
||
{y:268, t:' Consolidation complete. Patrols sent to NORTH flank.'},
|
||
].forEach(l => ctx.fillText(l.t, 40, l.y));
|
||
ctx.save(); ctx.globalAlpha = 0.07; ctx.font = 'bold 52px Georgia,serif';
|
||
ctx.fillStyle = '#800000'; ctx.translate(W/2, H/2+30); ctx.rotate(-0.35);
|
||
ctx.fillText('CONFIDENTIAL', -120, 0); ctx.restore();
|
||
return cv.toDataURL('image/png');
|
||
}
|
||
|
||
const SAMPLE_TEXT =
|
||
`WAR DIARY — CALGARY HIGHLANDERS
|
||
September 1944 — Northwestern Europe
|
||
|
||
1 Sep 44 Unit moved forward to assembly area near DOUVRES.
|
||
Weather clear. Roads congested with transport.
|
||
2 Sep 44 Advance continued. Contact with enemy at MR 442891.
|
||
Artillery support provided by 5th Field Regt RCA.
|
||
3 Sep 44 Objective CANAL secured by 1400 hrs. Casualties: 3 OR.
|
||
Consolidation complete. Patrols sent to NORTH flank.`;
|
||
|
||
const SAMPLE_JSON = [
|
||
{"word":"WAR", "id":"w001","bbox":{"x":40, "y":100,"w":35, "h":18}},
|
||
{"word":"DIARY", "id":"w002","bbox":{"x":80, "y":100,"w":46, "h":18}},
|
||
{"word":"CALGARY", "id":"w003","bbox":{"x":140, "y":100,"w":62, "h":18}},
|
||
{"word":"HIGHLANDERS", "id":"w004","bbox":{"x":208, "y":100,"w":97, "h":18}},
|
||
{"word":"September", "id":"w005","bbox":{"x":40, "y":131,"w":73, "h":16}},
|
||
{"word":"1944", "id":"w006","bbox":{"x":118, "y":131,"w":38, "h":16}},
|
||
{"word":"Northwestern","id":"w007","bbox":{"x":164, "y":131,"w":98, "h":16}},
|
||
{"word":"Europe", "id":"w008","bbox":{"x":267, "y":131,"w":55, "h":16}},
|
||
{"word":"1", "id":"w009","bbox":{"x":40, "y":160,"w":10, "h":16}},
|
||
{"word":"Sep", "id":"w010","bbox":{"x":54, "y":160,"w":28, "h":16}},
|
||
{"word":"44", "id":"w011","bbox":{"x":86, "y":160,"w":20, "h":16}},
|
||
{"word":"Unit", "id":"w012","bbox":{"x":120, "y":160,"w":28, "h":16}},
|
||
{"word":"moved", "id":"w013","bbox":{"x":152, "y":160,"w":42, "h":16}},
|
||
{"word":"forward", "id":"w014","bbox":{"x":198, "y":160,"w":56, "h":16}},
|
||
{"word":"to", "id":"w015","bbox":{"x":258, "y":160,"w":16, "h":16}},
|
||
{"word":"assembly", "id":"w016","bbox":{"x":278, "y":160,"w":62, "h":16}},
|
||
{"word":"area", "id":"w017","bbox":{"x":344, "y":160,"w":32, "h":16}},
|
||
{"word":"near", "id":"w018","bbox":{"x":380, "y":160,"w":32, "h":16}},
|
||
{"word":"DOUVRES.", "id":"w019","bbox":{"x":416, "y":160,"w":70, "h":16}},
|
||
{"word":"Weather", "id":"w020","bbox":{"x":120, "y":188,"w":56, "h":16}},
|
||
{"word":"clear.", "id":"w021","bbox":{"x":180, "y":188,"w":40, "h":16}},
|
||
{"word":"Roads", "id":"w022","bbox":{"x":224, "y":188,"w":40, "h":16}},
|
||
{"word":"congested", "id":"w023","bbox":{"x":268, "y":188,"w":72, "h":16}},
|
||
{"word":"with", "id":"w024","bbox":{"x":344, "y":188,"w":30, "h":16}},
|
||
{"word":"transport.", "id":"w025","bbox":{"x":378, "y":188,"w":80, "h":16}},
|
||
{"word":"2", "id":"w026","bbox":{"x":40, "y":218,"w":10, "h":16}},
|
||
{"word":"Sep", "id":"w027","bbox":{"x":54, "y":218,"w":28, "h":16}},
|
||
{"word":"44", "id":"w028","bbox":{"x":86, "y":218,"w":20, "h":16}},
|
||
{"word":"Advance", "id":"w029","bbox":{"x":120, "y":218,"w":56, "h":16}},
|
||
{"word":"continued.", "id":"w030","bbox":{"x":180, "y":218,"w":72, "h":16}},
|
||
{"word":"Contact", "id":"w031","bbox":{"x":256, "y":218,"w":55, "h":16}},
|
||
{"word":"with", "id":"w032","bbox":{"x":315, "y":218,"w":30, "h":16}},
|
||
{"word":"enemy", "id":"w033","bbox":{"x":349, "y":218,"w":44, "h":16}},
|
||
{"word":"at", "id":"w034","bbox":{"x":397, "y":218,"w":16, "h":16}},
|
||
{"word":"MR", "id":"w035","bbox":{"x":417, "y":218,"w":22, "h":16}},
|
||
{"word":"442891.", "id":"w036","bbox":{"x":443, "y":218,"w":58, "h":16}},
|
||
{"word":"Artillery", "id":"w037","bbox":{"x":120, "y":248,"w":60, "h":16}},
|
||
{"word":"support", "id":"w038","bbox":{"x":184, "y":248,"w":52, "h":16}},
|
||
{"word":"provided", "id":"w039","bbox":{"x":240, "y":248,"w":60, "h":16}},
|
||
{"word":"by", "id":"w040","bbox":{"x":304, "y":248,"w":18, "h":16}},
|
||
{"word":"5th", "id":"w041","bbox":{"x":326, "y":248,"w":26, "h":16}},
|
||
{"word":"Field", "id":"w042","bbox":{"x":356, "y":248,"w":36, "h":16}},
|
||
{"word":"Regt", "id":"w043","bbox":{"x":396, "y":248,"w":32, "h":16}},
|
||
{"word":"RCA.", "id":"w044","bbox":{"x":432, "y":248,"w":34, "h":16}},
|
||
{"word":"3", "id":"w045","bbox":{"x":40, "y":278,"w":10, "h":16}},
|
||
{"word":"Sep", "id":"w046","bbox":{"x":54, "y":278,"w":28, "h":16}},
|
||
{"word":"44", "id":"w047","bbox":{"x":86, "y":278,"w":20, "h":16}},
|
||
{"word":"Objective", "id":"w048","bbox":{"x":120, "y":278,"w":70, "h":16}},
|
||
{"word":"CANAL", "id":"w049","bbox":{"x":194, "y":278,"w":46, "h":16}},
|
||
{"word":"secured", "id":"w050","bbox":{"x":244, "y":278,"w":52, "h":16}},
|
||
{"word":"by", "id":"w051","bbox":{"x":300, "y":278,"w":18, "h":16}},
|
||
{"word":"1400", "id":"w052","bbox":{"x":322, "y":278,"w":34, "h":16}},
|
||
{"word":"hrs.", "id":"w053","bbox":{"x":360, "y":278,"w":28, "h":16}},
|
||
{"word":"Casualties:", "id":"w054","bbox":{"x":392, "y":278,"w":76, "h":16}},
|
||
{"word":"3", "id":"w055","bbox":{"x":472, "y":278,"w":10, "h":16}},
|
||
{"word":"OR.", "id":"w056","bbox":{"x":486, "y":278,"w":26, "h":16}},
|
||
{"word":"Consolidation","id":"w057","bbox":{"x":120,"y":308,"w":94, "h":16}},
|
||
{"word":"complete.", "id":"w058","bbox":{"x":218, "y":308,"w":68, "h":16}},
|
||
{"word":"Patrols", "id":"w059","bbox":{"x":290, "y":308,"w":52, "h":16}},
|
||
{"word":"sent", "id":"w060","bbox":{"x":346, "y":308,"w":34, "h":16}},
|
||
{"word":"to", "id":"w061","bbox":{"x":384, "y":308,"w":16, "h":16}},
|
||
{"word":"NORTH", "id":"w062","bbox":{"x":404, "y":308,"w":46, "h":16}},
|
||
{"word":"flank.", "id":"w063","bbox":{"x":454, "y":308,"w":40, "h":16}},
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// STATE
|
||
// ─────────────────────────────────────────────────────────────────
|
||
const state = {
|
||
mode: 'none', // 'pdf' | 'image' | 'none'
|
||
pdfDoc: null,
|
||
currentPage: 1,
|
||
totalPages: 0,
|
||
scale: 1.0,
|
||
wordData: [],
|
||
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?
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// DOM REFS
|
||
// ─────────────────────────────────────────────────────────────────
|
||
const btnLoadCH = document.getElementById('btn-load-ch');
|
||
const btnExample = document.getElementById('btn-example');
|
||
const inputPdf = document.getElementById('input-pdf');
|
||
const inputText = document.getElementById('input-text');
|
||
const inputJson = document.getElementById('input-json');
|
||
const scaleSelect = document.getElementById('scale-select');
|
||
|
||
const imagePlaceholder = document.getElementById('image-placeholder');
|
||
const imgSpinner = document.getElementById('img-spinner');
|
||
const imgPhText = document.getElementById('img-ph-text');
|
||
const pdfContainer = document.getElementById('pdf-container');
|
||
const pdfCanvas = document.getElementById('pdf-canvas');
|
||
const overlay = document.getElementById('overlay');
|
||
|
||
const textPlaceholder = document.getElementById('text-placeholder');
|
||
const txtSpinner = document.getElementById('txt-spinner');
|
||
const txtPhText = document.getElementById('txt-ph-text');
|
||
const textOutput = document.getElementById('text-output');
|
||
|
||
const btnPrev = document.getElementById('btn-prev');
|
||
const btnNext = document.getElementById('btn-next');
|
||
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');
|
||
|
||
// 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
|
||
// ─────────────────────────────────────────────────────────────────
|
||
btnLoadCH.addEventListener('click', loadCalgaryHighlanders);
|
||
btnExample.addEventListener('click', loadSampleData);
|
||
|
||
inputPdf.addEventListener('change', async e => {
|
||
const file = e.target.files[0]; if (!file) return;
|
||
setStatus('Loading PDF: ' + file.name + '…'); showImgSpinner(true);
|
||
const buf = await file.arrayBuffer();
|
||
showImgSpinner(false);
|
||
await loadPDFBuffer(buf);
|
||
setStatus('PDF: ' + file.name + ' — ' + state.totalPages + ' pages');
|
||
});
|
||
|
||
inputText.addEventListener('change', e => {
|
||
const file = e.target.files[0]; if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = ev => { loadOCRText(ev.target.result, true); setStatus('Text: ' + file.name); };
|
||
reader.readAsText(file);
|
||
});
|
||
|
||
inputJson.addEventListener('change', e => {
|
||
const file = e.target.files[0]; if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = ev => {
|
||
try {
|
||
loadWordDataJSON(JSON.parse(ev.target.result));
|
||
setStatus('JSON: ' + file.name + ' — ' + state.wordData.length + ' words');
|
||
} catch (err) { alert('JSON parse error: ' + err.message); }
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
|
||
scaleSelect.addEventListener('change', () => {
|
||
state.scale = parseFloat(scaleSelect.value);
|
||
if (state.mode === 'pdf' && state.pdfDoc) renderCurrentPage();
|
||
});
|
||
|
||
btnPrev.addEventListener('click', () => goToPage(state.currentPage - 1));
|
||
btnNext.addEventListener('click', () => goToPage(state.currentPage + 1));
|
||
pageInput.addEventListener('change', () => {
|
||
const n = parseInt(pageInput.value, 10);
|
||
if (!isNaN(n)) goToPage(n);
|
||
});
|
||
|
||
window.addEventListener('resize', () => {
|
||
if (state.mode === 'pdf' && state.pdfDoc && !state.rendering) renderCurrentPage();
|
||
});
|
||
|
||
// ── Auto-load when served from localhost ─────────────────────────
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||
setTimeout(loadCalgaryHighlanders, 200);
|
||
}
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// LOAD: CALGARY HIGHLANDERS (real data via launch_viewer.py)
|
||
// ─────────────────────────────────────────────────────────────────
|
||
async function loadCalgaryHighlanders() {
|
||
if (typeof pdfjsLib === 'undefined') {
|
||
setStatus('PDF.js not loaded — check internet, then refresh.', true);
|
||
return;
|
||
}
|
||
setStatus('Fetching Calgary Highlanders data…');
|
||
showImgSpinner(true); showTxtSpinner(true);
|
||
|
||
const [textRes, pdfRes] = await Promise.allSettled([
|
||
fetch(REAL_TEXT_PATH).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }),
|
||
fetch(REAL_PDF_PATH ).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }),
|
||
]);
|
||
|
||
// OCR text
|
||
showTxtSpinner(false);
|
||
if (textRes.status === 'fulfilled') {
|
||
loadOCRText(textRes.value, true);
|
||
} else {
|
||
showTxtPlaceholder(
|
||
'Could not load OCR text: ' + textRes.reason + '.<br>' +
|
||
'<em>Run launch_viewer.py first, then refresh.</em>'
|
||
);
|
||
setStatus('Could not load OCR text — run launch_viewer.py', true);
|
||
}
|
||
|
||
// PDF
|
||
showImgSpinner(false);
|
||
if (pdfRes.status === 'fulfilled') {
|
||
await loadPDFBuffer(pdfRes.value);
|
||
const textOK = textRes.status === 'fulfilled';
|
||
setStatus(
|
||
'Calgary Highlanders Sep 44 loaded — ' +
|
||
state.totalPages + '-page PDF' +
|
||
(textOK ? ' + OCR text' : '') + '. Use ◀▶ to page through the diary.'
|
||
);
|
||
} else {
|
||
showImgPlaceholder(
|
||
'Could not load PDF: ' + pdfRes.reason + '.<br>' +
|
||
'<em>Run launch_viewer.py first, or use the PDF Override button.</em>'
|
||
);
|
||
if (textRes.status === 'fulfilled') {
|
||
setStatus('OCR text loaded. PDF unavailable — run launch_viewer.py', true);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// LOAD: SAMPLE DATA (works without server)
|
||
// ─────────────────────────────────────────────────────────────────
|
||
function loadSampleData() {
|
||
state.mode = 'image';
|
||
state.wordData = SAMPLE_JSON; // set BEFORE any rendering so buildIdQueue sees the data
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
state.imageNaturalW = img.naturalWidth;
|
||
state.imageNaturalH = img.naturalHeight;
|
||
pdfCanvas.width = img.naturalWidth;
|
||
pdfCanvas.height = img.naturalHeight;
|
||
pdfCanvas.style.width = img.naturalWidth + 'px';
|
||
pdfCanvas.style.height = img.naturalHeight + 'px';
|
||
pdfCanvas.getContext('2d').drawImage(img, 0, 0);
|
||
pdfContainer.style.display = 'inline-block';
|
||
imagePlaceholder.style.display = 'none';
|
||
pageLabel.textContent = '/ 1'; btnPrev.disabled = true; btnNext.disabled = true;
|
||
|
||
// 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 = buildSampleImageDataURL();
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// PDF.js — load & render
|
||
// ─────────────────────────────────────────────────────────────────
|
||
async function loadPDFBuffer(arrayBuffer) {
|
||
try {
|
||
state.pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||
state.totalPages = state.pdfDoc.numPages;
|
||
state.mode = 'pdf';
|
||
state.currentPage = 1;
|
||
state.wordData = []; // clear JSON sidecar — use PDF text layer
|
||
pageLabel.textContent = '/ ' + state.totalPages;
|
||
// 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';
|
||
imagePlaceholder.style.display = 'none';
|
||
await renderCurrentPage();
|
||
} catch (err) {
|
||
showImgPlaceholder('Failed to open PDF: ' + err.message);
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
// Fit to panel width × user scale
|
||
const panelW = document.getElementById('image-panel-body').clientWidth || 700;
|
||
const baseVP = page.getViewport({ scale: 1 });
|
||
const fitScale = panelW / baseVP.width;
|
||
const vp = page.getViewport({ scale: fitScale * state.scale });
|
||
|
||
pdfCanvas.width = vp.width;
|
||
pdfCanvas.height = vp.height;
|
||
pdfCanvas.style.width = vp.width + 'px';
|
||
pdfCanvas.style.height = vp.height + 'px';
|
||
|
||
await page.render({ canvasContext: pdfCanvas.getContext('2d'), viewport: vp }).promise;
|
||
await buildPDFWordOverlay(page, vp);
|
||
|
||
pageInput.value = state.currentPage;
|
||
btnPrev.disabled = state.currentPage <= 1;
|
||
btnNext.disabled = state.currentPage >= state.totalPages;
|
||
|
||
// 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();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build word-box overlay from PDF.js text content
|
||
async function buildPDFWordOverlay(page, vp) {
|
||
overlay.innerHTML = '';
|
||
overlay.style.width = vp.width + 'px';
|
||
overlay.style.height = vp.height + 'px';
|
||
|
||
// 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;
|
||
state.wordData.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)
|
||
);
|
||
});
|
||
}
|
||
|
||
// 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) {
|
||
const div = document.createElement('div');
|
||
div.className = 'word-box';
|
||
div.dataset.wordId = id;
|
||
div.style.left = Math.round(x) + 'px';
|
||
div.style.top = Math.round(y) + 'px';
|
||
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'); });
|
||
div.addEventListener('mouseleave', () => { div.classList.remove('highlight'); });
|
||
return div;
|
||
}
|
||
|
||
let _pendingPage = null; // holds the last page requested while a render was in progress
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// OCR TEXT RENDERING
|
||
// ─────────────────────────────────────────────────────────────────
|
||
function loadOCRText(rawText, isHtmlMarkdown) {
|
||
textPlaceholder.style.display = 'none';
|
||
textOutput.style.display = 'block';
|
||
|
||
if (isHtmlMarkdown) {
|
||
// 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 {
|
||
// 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');
|
||
const out = [];
|
||
let i = 0;
|
||
while (i < lines.length) {
|
||
const raw = lines[i];
|
||
const trimmed = raw.trim();
|
||
|
||
if (/^## /.test(raw)) { out.push('<h2>' + escH(raw.slice(3)) + '</h2>'); i++; continue; }
|
||
if (/^### /.test(raw)) { out.push('<h3>' + escH(raw.slice(4)) + '</h3>'); i++; continue; }
|
||
if (/^---+$/.test(trimmed)) { out.push('<hr>'); i++; continue; }
|
||
|
||
// Inline HTML block — pass through as-is until a non-HTML line or page heading
|
||
if (/^</.test(trimmed)) {
|
||
let block = '';
|
||
while (i < lines.length) {
|
||
const l = lines[i];
|
||
if (/^## /.test(l) || /^---+$/.test(l.trim())) break;
|
||
block += l + '\n';
|
||
i++;
|
||
}
|
||
out.push(block);
|
||
continue;
|
||
}
|
||
|
||
if (trimmed === '') { out.push('<p> </p>'); i++; continue; }
|
||
out.push('<p>' + inlineMd(raw) + '</p>');
|
||
i++;
|
||
}
|
||
return out.join('\n');
|
||
}
|
||
|
||
function inlineMd(s) {
|
||
return escH(s)
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// WORD DATA (JSON SIDECAR)
|
||
// ─────────────────────────────────────────────────────────────────
|
||
function loadWordDataJSON(data) {
|
||
state.wordData = data;
|
||
|
||
// 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;
|
||
overlay.innerHTML = '';
|
||
overlay.style.width = pdfCanvas.width + 'px';
|
||
overlay.style.height = pdfCanvas.height + 'px';
|
||
data.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)
|
||
));
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// HELPERS
|
||
// ─────────────────────────────────────────────────────────────────
|
||
function escH(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
function showImgSpinner(on) {
|
||
imgSpinner.style.display = on ? 'inline-block' : 'none';
|
||
imgPhText.style.display = on ? 'none' : 'block';
|
||
imagePlaceholder.style.display = 'flex';
|
||
if (on) pdfContainer.style.display = 'none';
|
||
}
|
||
function showTxtSpinner(on) {
|
||
txtSpinner.style.display = on ? 'inline-block' : 'none';
|
||
txtPhText.style.display = on ? 'none' : 'block';
|
||
textPlaceholder.style.display = 'flex';
|
||
if (on) textOutput.style.display = 'none';
|
||
}
|
||
function showImgPlaceholder(html) {
|
||
imgPhText.innerHTML = html;
|
||
imagePlaceholder.style.display = 'flex';
|
||
pdfContainer.style.display = 'none';
|
||
}
|
||
function showTxtPlaceholder(html) {
|
||
txtPhText.innerHTML = html;
|
||
textPlaceholder.style.display = 'flex';
|
||
textOutput.style.display = 'none';
|
||
}
|
||
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>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|