1055 lines
41 KiB
HTML
1055 lines
41 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;
|
||
}
|
||
|
||
.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> </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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
function escA(s) { return s.replace(/"/g,'"'); }
|
||
function scrollIfNeeded(el, container) {
|
||
const er = el.getBoundingClientRect(), cr = container.getBoundingClientRect();
|
||
if (er.top < cr.top || er.bottom > cr.bottom) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||
}
|
||
function showImgSpinner(on) {
|
||
imgSpinner.style.display = on ? 'inline-block' : 'none';
|
||
imgPhText.style.display = on ? 'none' : 'block';
|
||
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>
|
||
|
||
|