Files
AI-Prototype/p44-ocr-viewer.html
2026-05-09 09:36:30 -04:00

1055 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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;
}
.word-box {
position: absolute;
border: 1px solid transparent;
border-radius: 1px;
cursor: crosshair;
pointer-events: all;
transition: background 0.07s, border-color 0.07s;
}
.word-box:hover,
.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); }
/* 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;
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; }
#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); }
</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">
<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>
<!-- ── 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" />
<button id="btn-next" disabled></button>
<span id="page-label">/ —</span>
</div>
</div>
<div class="panel-body" id="image-panel-body">
<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>
<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="word-tip"></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,
};
// ─────────────────────────────────────────────────────────────────
// 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');
const wordTip = document.getElementById('word-tip');
// ─────────────────────────────────────────────────────────────────
// 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 = [];
const dataURL = buildSampleImageDataURL();
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;
loadWordDataJSON(SAMPLE_JSON); // triggers overlay render
};
img.src = dataURL;
loadOCRText(SAMPLE_TEXT, false);
setStatus('Sample data loaded — hover words to see bidirectional link highlighting.');
}
// ─────────────────────────────────────────────────────────────────
// 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;
pageInput.value = 1;
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;
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;
syncOCRToPage(state.currentPage);
} finally {
state.rendering = false;
}
}
// 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';
// If a JSON sidecar is loaded (image mode), use it instead
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)
);
});
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
});
});
}
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');
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 = '';
});
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();
}
// ─────────────────────────────────────────────────────────────────
// OCR TEXT RENDERING
// ─────────────────────────────────────────────────────────────────
function loadOCRText(rawText, isHtmlMarkdown) {
textPlaceholder.style.display = 'none';
textOutput.style.display = 'block';
if (isHtmlMarkdown) {
textOutput.innerHTML = convertMarkdown(rawText);
addHoverSpansToTextNodes(textOutput);
} else {
renderPlainTextWithLinks(rawText);
}
}
// 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>&nbsp;</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>');
}
// 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
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 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';
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)';
}
</script>
</body>
</html>