early version of OCR viewer now integrated
This commit is contained in:
@@ -12,6 +12,7 @@ import socket
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
revfrom urllib.parse import urlparse, parse_qs
|
||||
|
||||
PORT = 5044
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -32,7 +33,17 @@ class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
super().end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
"""Handle POST /save-flags and POST /save-corrections."""
|
||||
"""Handle POST /save-flags, /save-pass2-flags, /save-corrections, /clear-all-data.
|
||||
All endpoints accept an optional ?diary=ID query param to use per-diary files.
|
||||
"""
|
||||
parsed = urlparse(self.path)
|
||||
qs = parse_qs(parsed.query)
|
||||
diary = qs.get('diary', ['unknown'])[0]
|
||||
# Sanitize: only allow alphanumeric, hyphens, underscores
|
||||
diary = ''.join(c for c in diary if c.isalnum() or c in '-_')
|
||||
if not diary:
|
||||
diary = 'unknown'
|
||||
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
@@ -41,8 +52,8 @@ class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
self.send_response(400); self.end_headers()
|
||||
return
|
||||
|
||||
if self.path == '/save-flags':
|
||||
dest = os.path.join(ROOT, 'flags.json')
|
||||
if parsed.path == '/save-flags':
|
||||
dest = os.path.join(ROOT, 'flags-' + diary + '.json')
|
||||
with open(dest, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
self.send_response(200)
|
||||
@@ -50,9 +61,8 @@ class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
|
||||
elif self.path == '/save-corrections':
|
||||
dest = os.path.join(ROOT, 'corrections.json')
|
||||
# Load existing, update single page entry, write back
|
||||
elif parsed.path == '/save-corrections':
|
||||
dest = os.path.join(ROOT, 'corrections-' + diary + '.json')
|
||||
existing = {}
|
||||
if os.path.exists(dest):
|
||||
try:
|
||||
@@ -60,10 +70,10 @@ class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
existing = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
page = str(data.get('page', ''))
|
||||
text = data.get('text', '')
|
||||
if text.strip():
|
||||
existing[page] = text
|
||||
page = str(data.get('page', ''))
|
||||
correction = data.get('correction')
|
||||
if correction:
|
||||
existing[page] = correction
|
||||
else:
|
||||
existing.pop(page, None)
|
||||
with open(dest, 'w', encoding='utf-8') as f:
|
||||
@@ -73,6 +83,32 @@ class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
|
||||
elif parsed.path == '/save-pass2-flags':
|
||||
dest = os.path.join(ROOT, 'pass2_flags-' + diary + '.json')
|
||||
try:
|
||||
with open(dest, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception:
|
||||
self.send_response(500); self.end_headers()
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
|
||||
elif parsed.path == '/clear-all-data':
|
||||
for prefix in ['flags-', 'corrections-', 'pass2_flags-']:
|
||||
fpath = os.path.join(ROOT, prefix + diary + '.json')
|
||||
if os.path.exists(fpath):
|
||||
try:
|
||||
os.remove(fpath)
|
||||
except Exception:
|
||||
pass
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
|
||||
else:
|
||||
self.send_response(404); self.end_headers()
|
||||
|
||||
@@ -88,7 +124,7 @@ def start_server():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
url = 'http://localhost:{}/p44-ocr-viewer.html'.format(PORT)
|
||||
url = 'http://localhost:{}/p44-landing.html'.format(PORT)
|
||||
|
||||
if port_in_use(PORT):
|
||||
print('\n P44 OCR Viewer (server already running)')
|
||||
|
||||
337
p44-landing.html
Normal file
337
p44-landing.html
Normal file
@@ -0,0 +1,337 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>P44 OCR — War Diary Transcription</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #111120;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
color: #ddd4bb;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#topbar {
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #2e2e4e;
|
||||
padding: 10px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#topbar h1 {
|
||||
font-size: 13px;
|
||||
letter-spacing: .12em;
|
||||
color: #c8a84b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#role-badge {
|
||||
font-size: 9px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #8a6f2e;
|
||||
border-radius: 3px;
|
||||
color: #8a6f2e;
|
||||
letter-spacing: .06em;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#content {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: #8a6f2e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
#cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #16213e;
|
||||
border: 0.5px solid #2e2e4e;
|
||||
border-radius: 6px;
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
|
||||
.card:hover { border-color: #c8a84b; }
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 56px;
|
||||
height: 72px;
|
||||
background: #0f2a0f;
|
||||
border: 0.5px solid #2e4e2e;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.thumb-lines { width: 40px; }
|
||||
|
||||
.thumb-line {
|
||||
height: 2px;
|
||||
background: #2e4e2e;
|
||||
border-radius: 1px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.thumb-line.dim { background: #1e3e1e; }
|
||||
|
||||
.card-meta { flex: 1; min-width: 0; }
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
color: #e8d9b0;
|
||||
letter-spacing: .03em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 10px;
|
||||
color: #8a6f2e;
|
||||
letter-spacing: .05em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.tag {
|
||||
font-size: 9px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: .05em;
|
||||
background: #0f1a3e;
|
||||
color: #6a8fd8;
|
||||
border: 0.5px solid #2e3e6e;
|
||||
}
|
||||
|
||||
.card-progress {
|
||||
margin-top: 14px;
|
||||
border-top: 0.5px solid #2e2e4e;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.prog-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.prog-row:last-child { margin-bottom: 0; }
|
||||
|
||||
.prog-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
width: 52px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prog-label.p1 { color: #c8a84b; }
|
||||
.prog-label.p2 { color: #6a8fd8; }
|
||||
|
||||
.prog-track {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: #0f1a0f;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prog-fill { height: 100%; border-radius: 3px; transition: width .3s ease; }
|
||||
.prog-fill.p1 { background: #c8a84b; }
|
||||
.prog-fill.p2 { background: #378ADD; }
|
||||
|
||||
.prog-pct {
|
||||
font-size: 10px;
|
||||
width: 32px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prog-pct.p1 { color: #c8a84b; }
|
||||
.prog-pct.p2 { color: #6a8fd8; }
|
||||
|
||||
.card-action { margin-top: 12px; }
|
||||
|
||||
.btn-open, .btn-view {
|
||||
padding: 4px 14px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-family: 'Georgia', serif;
|
||||
letter-spacing: .05em;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-open {
|
||||
border: 1px solid #c8a84b;
|
||||
color: #c8a84b;
|
||||
}
|
||||
|
||||
.btn-open:hover { background: rgba(200,168,75,.08); }
|
||||
|
||||
.btn-view {
|
||||
border: 1px solid #5f5e5a;
|
||||
color: #888780;
|
||||
}
|
||||
|
||||
.btn-view:hover { background: rgba(136,135,128,.08); }
|
||||
|
||||
#footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 10px;
|
||||
color: #5f5e5a;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="topbar">
|
||||
<h1>⚜ P44 War Diary Transcription</h1>
|
||||
<span id="role-badge">Pass 1 — Volunteer</span>
|
||||
</div>
|
||||
|
||||
<div id="content">
|
||||
<div class="section-label">War diaries available for transcription</div>
|
||||
<div id="cards"></div>
|
||||
</div>
|
||||
|
||||
<div id="footer">Project '44 — Canadian Research and Mapping Association</div>
|
||||
|
||||
<script>
|
||||
const DIARIES = [
|
||||
{
|
||||
id: 'calgary-highlanders',
|
||||
title: 'Calgary Highlanders',
|
||||
subtitle: 'War Diary — September 1944',
|
||||
tags: ['Infantry', 'Sep 1944', '343 pages'],
|
||||
totalPages: 343,
|
||||
viewerUrl: 'p44-ocr-viewer.html',
|
||||
flagsKey: 'p44-flags-calgary-highlanders',
|
||||
pass2FlagsKey: 'p44-pass2-flags-calgary-highlanders',
|
||||
},
|
||||
{
|
||||
id: 'black-watch',
|
||||
title: 'Black Watch (RHR) of Canada',
|
||||
subtitle: 'War Diary — September 1944',
|
||||
tags: ['Infantry', 'Sep 1944', '211 pages'],
|
||||
totalPages: 211,
|
||||
viewerUrl: 'p44-ocr-viewer.html',
|
||||
flagsKey: 'p44-flags-black-watch',
|
||||
pass2FlagsKey: 'p44-pass2-flags-black-watch',
|
||||
},
|
||||
{
|
||||
id: '5cib',
|
||||
title: '5th Canadian Infantry Brigade',
|
||||
subtitle: 'War Diary — September 1944',
|
||||
tags: ['Brigade HQ', 'Sep 1944', '178 pages'],
|
||||
totalPages: 178,
|
||||
viewerUrl: 'p44-ocr-viewer.html',
|
||||
flagsKey: 'p44-flags-5cib',
|
||||
pass2FlagsKey: 'p44-pass2-flags-5cib',
|
||||
},
|
||||
];
|
||||
|
||||
function getCurrentRole() {
|
||||
return localStorage.getItem('p44-role') || 'pass1';
|
||||
}
|
||||
|
||||
function getProgress(diary) {
|
||||
let pass1Done = 0, pass2Done = 0;
|
||||
try {
|
||||
const flags = JSON.parse(localStorage.getItem(diary.flagsKey) || '{}');
|
||||
pass1Done = Object.values(flags).filter(f => f === 'done').length;
|
||||
} catch (_) {}
|
||||
try {
|
||||
const pass2Flags = JSON.parse(localStorage.getItem(diary.pass2FlagsKey) || '{}');
|
||||
pass2Done = Object.values(pass2Flags).filter(f => f === 'done').length;
|
||||
} catch (_) {}
|
||||
return {
|
||||
pass1Pct: Math.round((pass1Done / diary.totalPages) * 100),
|
||||
pass2Pct: Math.round((pass2Done / diary.totalPages) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
function openDiary(diaryId) {
|
||||
const diary = DIARIES.find(d => d.id === diaryId);
|
||||
if (diary) window.location.href = diary.viewerUrl + '?diary=' + diaryId;
|
||||
}
|
||||
|
||||
function renderCard(diary) {
|
||||
const role = getCurrentRole();
|
||||
const prog = getProgress(diary);
|
||||
const isViewOnly = role === 'pass1' && prog.pass1Pct === 100;
|
||||
const tagHtml = diary.tags.map(t => '<span class="tag">' + t + '</span>').join('');
|
||||
const thumbLines = Array(10).fill(0).map((_, i) =>
|
||||
'<div class="thumb-line' + (i % 2 ? ' dim' : '') + '"></div>'
|
||||
).join('');
|
||||
const actionBtn = isViewOnly
|
||||
? '<button class="btn-view" onclick="openDiary(\'' + diary.id + '\')">View only</button>'
|
||||
: '<button class="btn-open" onclick="openDiary(\'' + diary.id + '\')">Open diary</button>';
|
||||
|
||||
return '<div class="card">' +
|
||||
'<div class="card-top">' +
|
||||
'<div class="thumb" aria-hidden="true"><div class="thumb-lines">' + thumbLines + '</div></div>' +
|
||||
'<div class="card-meta">' +
|
||||
'<div class="card-title">' + diary.title + '</div>' +
|
||||
'<div class="card-sub">' + diary.subtitle + '</div>' +
|
||||
'<div class="card-tags">' + tagHtml + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-progress">' +
|
||||
'<div class="prog-row">' +
|
||||
'<span class="prog-label p1">Pass 1</span>' +
|
||||
'<div class="prog-track"><div class="prog-fill p1" style="width:' + prog.pass1Pct + '%"></div></div>' +
|
||||
'<span class="prog-pct p1">' + prog.pass1Pct + '%</span>' +
|
||||
'</div>' +
|
||||
'<div class="prog-row">' +
|
||||
'<span class="prog-label p2">Pass 2</span>' +
|
||||
'<div class="prog-track"><div class="prog-fill p2" style="width:' + prog.pass2Pct + '%"></div></div>' +
|
||||
'<span class="prog-pct p2">' + prog.pass2Pct + '%</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-action">' + actionBtn + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function init() {
|
||||
const role = getCurrentRole();
|
||||
document.getElementById('role-badge').textContent =
|
||||
role === 'pass2' ? 'Pass 2 — Senior Editor' : 'Pass 1 — Volunteer';
|
||||
document.getElementById('cards').innerHTML = DIARIES.map(renderCard).join('');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
513
p44-ocr-viewer.css
Normal file
513
p44-ocr-viewer.css
Normal file
@@ -0,0 +1,513 @@
|
||||
/* ── 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) auto 1fr var(--footer-h);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Override dropdown ────────────────────────────────────────── */
|
||||
#override-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#override-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 500;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--gold-dim);
|
||||
border-radius: 4px;
|
||||
min-width: 160px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
#override-menu.open { display: block; }
|
||||
|
||||
.override-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.03em;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.override-item:hover { background: var(--surface2); color: var(--gold); }
|
||||
.override-item input[type="file"] { display: none; }
|
||||
|
||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||
#toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border-bottom: 2px solid var(--gold-dim);
|
||||
overflow: visible; /* must be visible so the Override dropdown can escape the toolbar */
|
||||
flex-wrap: nowrap;
|
||||
position: relative;
|
||||
z-index: 100; /* sit above pass2-banner and panel headers */
|
||||
}
|
||||
|
||||
#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); }
|
||||
a.tb-btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||
|
||||
#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 ────────────────────────── */
|
||||
#image-panel-body {
|
||||
position: relative;
|
||||
scrollbar-gutter: stable; /* reserve scrollbar space always — prevents layout shift on zoom */
|
||||
}
|
||||
|
||||
#pdf-container {
|
||||
position: relative;
|
||||
display: block; /* block instead of inline-block — fills full panel width cleanly */
|
||||
width: 100%;
|
||||
min-height: 100%; /* always occupies the full panel height so nothing else renders below */
|
||||
}
|
||||
|
||||
#pdf-canvas {
|
||||
display: block;
|
||||
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); } }
|
||||
|
||||
/* ── 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); }
|
||||
|
||||
/* ── Flag buttons ─────────────────────────────────────────────── */
|
||||
.flag-btn { font-size: 9px; padding: 2px 7px; }
|
||||
.flag-btn.active { border-color: var(--gold); color: var(--gold); }
|
||||
/* ── 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; }
|
||||
|
||||
|
||||
/* ── Zoom indicator in left panel header ─────────────────────────── */
|
||||
#zoom-label {
|
||||
font-size: 9px; color: var(--gold-dim); letter-spacing: 0.06em;
|
||||
white-space: nowrap; min-width: 34px; text-align: right;
|
||||
}
|
||||
#btn-zoom-reset {
|
||||
padding: 1px 6px; font-size: 9px; font-family: inherit;
|
||||
background: transparent; border: 1px solid var(--gold-dim);
|
||||
border-radius: 2px; color: var(--text-dim); cursor: pointer;
|
||||
letter-spacing: 0.04em; white-space: nowrap;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
#btn-zoom-reset:hover { border-color: var(--gold); color: var(--gold); }
|
||||
|
||||
/* ── Role switcher ────────────────────────────────────────────────── */
|
||||
#role-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;
|
||||
}
|
||||
|
||||
/* ── Pass 2 review banner ─────────────────────────────────────────── */
|
||||
#pass2-banner {
|
||||
display: none;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
background: #2e2010;
|
||||
border-bottom: 1px solid #c08020;
|
||||
font-size: 11px;
|
||||
color: #e8b848;
|
||||
letter-spacing: 0.04em;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Pass 2 revert bar ────────────────────────────────────────── */
|
||||
#pass2-revert-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#pass2-revert-bar .rv-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
flex: 1;
|
||||
}
|
||||
#pass2-revert-bar .rv-btn {
|
||||
font-size: 10px;
|
||||
font-family: 'Georgia', serif;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
#pass2-revert-bar .rv-btn:hover { border-color: var(--gold-dim); color: var(--gold); }
|
||||
#pass2-revert-bar .rv-btn.danger { border-color: var(--red, #d05050); color: var(--red, #d05050); }
|
||||
#pass2-revert-bar .rv-btn.danger:hover { border-color: var(--red, #d05050); color: var(--text); }
|
||||
|
||||
/* ── Pass 2 change summary panel ─────────────────────────────── */
|
||||
#pass2-change-summary {
|
||||
display: none;
|
||||
padding: 10px 20px;
|
||||
background: var(--surface2);
|
||||
border-bottom: 1px solid var(--gold-dim);
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
#pass2-change-summary .cs-title {
|
||||
font-size: 10px;
|
||||
color: var(--gold-dim);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
#pass2-change-summary .cs-list {
|
||||
color: var(--text-dim);
|
||||
line-height: 1.8;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 8px;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
#pass2-change-summary .cs-changed { color: #e8a838; }
|
||||
#pass2-change-summary .cs-added { color: #4caf70; }
|
||||
#pass2-change-summary .cs-deleted { color: #d05050; }
|
||||
#pass2-change-summary .cs-actions { display: flex; gap: 8px; }
|
||||
.cs-revert-btn {
|
||||
font-size: 10px;
|
||||
font-family: Georgia, serif;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.cs-revert-btn:hover { border-color: var(--gold-dim); color: var(--gold); }
|
||||
.cs-revert-btn.danger { border-color: #d05050; color: #d05050; }
|
||||
.cs-revert-btn.danger:hover { border-color: #e07070; color: #e07070; }
|
||||
|
||||
/* ── Plain-text edit surface ──────────────────────────────────── */
|
||||
#text-panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#editor-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#line-numbers {
|
||||
width: 48px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--gold-dim);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.75;
|
||||
padding: 0 8px 0 12px;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
white-space: pre;
|
||||
box-sizing: border-box;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
#text-editor {
|
||||
display: block; /* always block — visibility via #editor-wrap */
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.75;
|
||||
padding: 0 16px 0 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Done-page read-only banner (Pass 1) ─────────────────────── */
|
||||
#done-banner {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 16px;
|
||||
background: #0f2a0f;
|
||||
border-bottom: 1px solid #2e6e2e;
|
||||
font-size: 10px;
|
||||
color: #5cb85c;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Problem flag indicator on page nav input ─────────────────── */
|
||||
#page-input.problem { border-color: #c08020; }
|
||||
|
||||
/* ── OCR overlay panel (replaces raster in left panel) ───────── */
|
||||
#ocr-overlay-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
padding: 20px 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: var(--text);
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
z-index: 10;
|
||||
}
|
||||
1382
p44-ocr-viewer.html
1382
p44-ocr-viewer.html
File diff suppressed because it is too large
Load Diff
1261
p44-ocr-viewer.js
Normal file
1261
p44-ocr-viewer.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
||||
Here's your step-by-step guide. Give Copilot one phase at a time — do not paste them all at once.
|
||||
PHASE 1 — CLEANUP: Remove all cross-highlighting code
|
||||
|
||||
Paste this first:
|
||||
|
||||
I need to clean up p44-ocr-viewer.html by removing all cross-panel word highlighting code that was never fully working. Do not change any CSS, layout, toolbar, PDF rendering, or OCR text rendering. Only remove the highlighting infrastructure.
|
||||
|
||||
Remove the following:
|
||||
|
||||
addHoverSpansToTextNodes() — the entire function
|
||||
highlightBoxesByText() — the entire function
|
||||
clearHighlightedBoxes() — the entire function
|
||||
highlightSpansByText() — the entire function
|
||||
clearHighlightedSpans() — the entire function
|
||||
renderPlainTextWithLinks() — the entire function
|
||||
buildIdQueue() — the entire function
|
||||
dequeueId() — the entire function
|
||||
The call to addHoverSpansToTextNodes(textOutput) inside showOCRPage()
|
||||
The highlightSpansByText(title) fallback call inside makeWordBox()
|
||||
The clearHighlightedSpans() call inside makeWordBox()
|
||||
state.wordSequence if it was added
|
||||
state.useJsonOverlay if it was added
|
||||
All console.log statements added for debugging (the olmOCR word spans logs, PDF.js getTextContent logs)
|
||||
|
||||
Also remove the .word-span CSS rules from the stylesheet — the #text-output .word-span block.
|
||||
|
||||
After removal, verify:
|
||||
|
||||
showOCRPage() still renders text correctly
|
||||
makeWordBox() still builds overlay boxes (they just won't trigger any cross-panel effects)
|
||||
The #word-tip footer element can stay but its content should never be set
|
||||
Sample data / JSON sidecar path must still work
|
||||
|
||||
PHASE 2 — Synchronized scrolling
|
||||
|
||||
After Phase 1 is working, paste this:
|
||||
|
||||
Add synchronized scrolling to p44-ocr-viewer.html. When the user scrolls either panel, the other panel scrolls to the same proportional vertical position.
|
||||
|
||||
Implementation:
|
||||
|
||||
Get references to both scroll containers: document.getElementById('image-panel-body') and document.getElementById('text-panel-body')
|
||||
Add a scroll event listener to each. When one fires, calculate the scroll percentage: scrollTop / (scrollHeight - clientHeight). Apply that same percentage to the other panel's scrollTop.
|
||||
Use a isSyncing boolean flag to prevent the two listeners from triggering each other in a loop. Set it true before programmatically scrolling, then false in a requestAnimationFrame callback after.
|
||||
Add a toggle button to the toolbar labelled Sync Scroll with an active/inactive visual state (use the existing .tb-btn class). When inactive, the listeners are disconnected. Default state is active.
|
||||
|
||||
Constraints:
|
||||
|
||||
Do not change any existing scroll behaviour on page navigation
|
||||
Do not change any CSS other than adding an .active state for the sync button that gives it border-color: var(--gold); color: var(--gold);
|
||||
|
||||
PHASE 3 — Click-to-zoom on the left panel
|
||||
|
||||
After Phase 2 is working, paste this:
|
||||
|
||||
Add click-to-zoom to the left panel (the PDF scan) in p44-ocr-viewer.html.
|
||||
|
||||
Implementation:
|
||||
|
||||
When the user clicks anywhere on #pdf-canvas, calculate the click position as a percentage of the canvas width and height.
|
||||
Create a zoom overlay div with id zoom-lens that appears in the top-right corner of the left panel (position absolute, 280px × 200px, dark border, z-index 10). It contains a second canvas #zoom-canvas.
|
||||
On click, draw a 3× magnified crop of the clicked region onto #zoom-canvas. The crop region is centered on the click point and covers 1/3 of the canvas width and height.
|
||||
Clicking the same spot again, or clicking #zoom-lens itself, dismisses the zoom overlay.
|
||||
Add a small ✕ close button in the top-right corner of #zoom-lens.
|
||||
#zoom-lens styling: background: var(--surface), border: 1px solid var(--gold), border-radius: 4px. Use existing CSS variables throughout.
|
||||
|
||||
Constraints:
|
||||
|
||||
Do not affect PDF rendering or page navigation
|
||||
The zoom lens must not appear until after the first click
|
||||
|
||||
PHASE 4 — Flag/annotation system
|
||||
|
||||
After Phase 3 is working, paste this:
|
||||
|
||||
Add a per-page flag/annotation system to p44-ocr-viewer.html. Reviewers can mark pages and the results persist to disk via the existing Python server.
|
||||
|
||||
Implementation:
|
||||
|
||||
Add three buttons to the right panel header (inside .panel-header, after the existing ocr-sync-label): ✓ Verified, ⚑ Needs Review, ✗ Error Found. Use the existing .tb-btn class. Add a fourth button Clear to remove a flag.
|
||||
Store flags in a state.flags object keyed by page number: { 1: 'verified', 8: 'error', 12: 'review' }.
|
||||
On page change, update the visual state of the three buttons to reflect the current page's flag (highlight the active one with border-color: var(--gold); color: var(--gold)).
|
||||
Save flags by POSTing JSON to /save-flags on the local server. The payload is the entire state.flags object. If the POST fails silently (server doesn't support it yet), store in localStorage as a fallback with key p44-flags.
|
||||
On startup, fetch /flags.json from the server. If it returns 200, load into state.flags. If it 404s, check localStorage fallback. If neither exists, start with empty flags.
|
||||
Add a flags-summary display to the footer showing counts: e.g. ✓ 12 ⚑ 4 ✗ 2.
|
||||
In launch_viewer.py, add a /save-flags POST handler that writes the received JSON to ./flags.json in the project root. Also serve ./flags.json as a static file (it already will be via SimpleHTTPRequestHandler).
|
||||
|
||||
Constraints:
|
||||
|
||||
Do not change PDF rendering, OCR rendering, or scroll sync
|
||||
Flags must survive a browser refresh
|
||||
|
||||
PHASE 5 — Inline text editing
|
||||
|
||||
After Phase 4 is working, paste this:
|
||||
|
||||
Make the OCR text panel editable in p44-ocr-viewer.html so reviewers can correct OCR errors inline.
|
||||
|
||||
Implementation:
|
||||
|
||||
Add a toggle button to the right panel header labelled Edit using the existing .tb-btn class. When active, it shows Editing with gold styling.
|
||||
When edit mode is active, set #text-output to contenteditable="true" and add a subtle inset border: outline: 1px solid var(--gold-dim) on the element.
|
||||
When edit mode is toggled off, read the current innerHTML of #text-output, strip all HTML tags to get plain text, and save it as the corrected text for this page in state.corrections[pageNum].
|
||||
Save corrections by POSTing to /save-corrections with payload { page: N, text: "..." }. If POST fails, store in localStorage under key p44-corrections.
|
||||
On startup, fetch /corrections.json. If found, load into state.corrections. On page render, if state.corrections[pageNum] exists, display the corrected text instead of the raw olmOCR text.
|
||||
In launch_viewer.py, add a /save-corrections POST handler that maintains a corrections.json file in the project root — a dict keyed by page number.
|
||||
Add a visual indicator in the right panel header when the current page has a saved correction: a small gold dot or [edited] label next to the page number.
|
||||
|
||||
Constraints:
|
||||
|
||||
Switching pages while in edit mode should auto-save before navigating
|
||||
Do not affect flags, scroll sync, zoom, or PDF rendering
|
||||
|
||||
PHASE 6 — Keyboard navigation
|
||||
|
||||
After Phase 5 is working, paste this:
|
||||
|
||||
Add keyboard navigation to p44-ocr-viewer.html.
|
||||
|
||||
Implementation:
|
||||
|
||||
Add a keydown event listener on document.
|
||||
Map the following keys — but only when focus is NOT inside #text-output (to avoid interfering with editing) and NOT inside any input element:
|
||||
ArrowRight or l → next page (goToPage(state.currentPage + 1))
|
||||
ArrowLeft or j → previous page (goToPage(state.currentPage - 1))
|
||||
v → mark current page Verified
|
||||
r → mark current page Needs Review
|
||||
e → mark current page Error Found
|
||||
Escape → close zoom lens if open; exit edit mode if active
|
||||
Add a small keyboard shortcut hint to the footer, right-aligned, in var(--text-dim) colour: ← → navigate v/r/e flag esc close
|
||||
|
||||
Constraints:
|
||||
|
||||
No key should fire while the user is typing in the edit panel or the page number input
|
||||
Do not change any existing event listeners
|
||||
485
prompts/ocr-vierwer-redesign6.md
Normal file
485
prompts/ocr-vierwer-redesign6.md
Normal file
@@ -0,0 +1,485 @@
|
||||
P44 OCR Viewer — War diary landing page (p44-landing.html)
|
||||
Create a new standalone file p44-landing.html in the same directory as p44-ocr-viewer.html. Served by launch_viewer.py on the same localhost server. No frameworks, vanilla JS only.
|
||||
|
||||
PART 1 — Data config
|
||||
At the top of the script, define the three war diaries as a static array. Progress is calculated from localStorage keys written by the viewer:
|
||||
javascriptconst DIARIES = [
|
||||
{
|
||||
id: 'calgary-highlanders',
|
||||
title: 'Calgary Highlanders',
|
||||
subtitle: 'War Diary — September 1944',
|
||||
tags: ['Infantry', 'Sep 1944', '343 pages'],
|
||||
totalPages: 343,
|
||||
viewerUrl: 'p44-ocr-viewer.html',
|
||||
flagsKey: 'p44-flags',
|
||||
pass2FlagsKey: 'p44-pass2-flags',
|
||||
},
|
||||
{
|
||||
id: 'black-watch',
|
||||
title: 'Black Watch (RHR) of Canada',
|
||||
subtitle: 'War Diary — September 1944',
|
||||
tags: ['Infantry', 'Sep 1944', '211 pages'],
|
||||
totalPages: 211,
|
||||
viewerUrl: 'p44-ocr-viewer.html',
|
||||
flagsKey: 'p44-flags',
|
||||
pass2FlagsKey: 'p44-pass2-flags',
|
||||
},
|
||||
{
|
||||
id: '5cib',
|
||||
title: '5th Canadian Infantry Brigade',
|
||||
subtitle: 'War Diary — September 1944',
|
||||
tags: ['Brigade HQ', 'Sep 1944', '178 pages'],
|
||||
totalPages: 178,
|
||||
viewerUrl: 'p44-ocr-viewer.html',
|
||||
flagsKey: 'p44-flags',
|
||||
pass2FlagsKey: 'p44-pass2-flags',
|
||||
},
|
||||
];
|
||||
Note: All three diaries currently share the same localStorage keys since the viewer only supports one diary at a time. This is a known limitation — flagsKey and pass2FlagsKey are included in the config so they're easy to make per-diary later when the server handles multiple diaries.
|
||||
|
||||
PART 2 — Progress calculation
|
||||
javascriptfunction getProgress(diary) {
|
||||
let pass1Done = 0, pass2Done = 0;
|
||||
|
||||
try {
|
||||
const flags = JSON.parse(localStorage.getItem(diary.flagsKey) || '{}');
|
||||
pass1Done = Object.values(flags).filter(f => f === 'done').length;
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const pass2Flags = JSON.parse(localStorage.getItem(diary.pass2FlagsKey) || '{}');
|
||||
pass2Done = Object.values(pass2Flags).filter(f => f === 'done').length;
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
pass1Pct: Math.round((pass1Done / diary.totalPages) * 100),
|
||||
pass2Pct: Math.round((pass2Done / diary.totalPages) * 100),
|
||||
pass1Done,
|
||||
pass2Done,
|
||||
};
|
||||
}
|
||||
|
||||
PART 3 — Role detection
|
||||
javascriptfunction getCurrentRole() {
|
||||
return localStorage.getItem('p44-role') || 'pass1';
|
||||
}
|
||||
|
||||
PART 4 — Card renderer
|
||||
javascriptfunction renderCard(diary) {
|
||||
const role = getCurrentRole();
|
||||
const prog = getProgress(diary);
|
||||
const pass1Complete = prog.pass1Pct === 100;
|
||||
const isViewOnly = role === 'pass1' && pass1Complete;
|
||||
|
||||
const tagHtml = diary.tags.map(t =>
|
||||
'<span class="tag">' + t + '</span>'
|
||||
).join('');
|
||||
|
||||
const actionBtn = isViewOnly
|
||||
? '<button class="btn-view" onclick="openDiary(\'' + diary.id + '\')">View only</button>'
|
||||
: '<button class="btn-open" onclick="openDiary(\'' + diary.id + '\')">Open diary</button>';
|
||||
|
||||
return `
|
||||
<div class="card" id="card-${diary.id}">
|
||||
<div class="card-top">
|
||||
<div class="thumb" aria-hidden="true">
|
||||
<div class="thumb-lines">
|
||||
${Array(10).fill(0).map((_, i) =>
|
||||
'<div class="thumb-line' + (i % 2 ? ' dim' : '') + '"></div>'
|
||||
).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="card-title">${diary.title}</div>
|
||||
<div class="card-sub">${diary.subtitle}</div>
|
||||
<div class="card-tags">${tagHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-progress">
|
||||
<div class="prog-row">
|
||||
<span class="prog-label p1">Pass 1</span>
|
||||
<div class="prog-track">
|
||||
<div class="prog-fill p1" style="width:${prog.pass1Pct}%"></div>
|
||||
</div>
|
||||
<span class="prog-pct p1">${prog.pass1Pct}%</span>
|
||||
</div>
|
||||
<div class="prog-row">
|
||||
<span class="prog-label p2">Pass 2</span>
|
||||
<div class="prog-track">
|
||||
<div class="prog-fill p2" style="width:${prog.pass2Pct}%"></div>
|
||||
</div>
|
||||
<span class="prog-pct p2">${prog.pass2Pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-action">${actionBtn}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
PART 5 — Open diary
|
||||
javascriptfunction openDiary(diaryId) {
|
||||
const diary = DIARIES.find(d => d.id === diaryId);
|
||||
if (!diary) return;
|
||||
window.location.href = diary.viewerUrl;
|
||||
}
|
||||
|
||||
PART 6 — Init
|
||||
javascriptfunction init() {
|
||||
const role = getCurrentRole();
|
||||
document.getElementById('role-badge').textContent =
|
||||
role === 'pass2' ? 'Pass 2 — Senior Editor' : 'Pass 1 — Volunteer';
|
||||
|
||||
const container = document.getElementById('cards');
|
||||
container.innerHTML = DIARIES.map(renderCard).join('');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
PART 7 — Full HTML file
|
||||
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 — War Diary Transcription</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #111120;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
color: #ddd4bb;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#topbar {
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #2e2e4e;
|
||||
padding: 10px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#topbar h1 {
|
||||
font-size: 13px;
|
||||
letter-spacing: .12em;
|
||||
color: #c8a84b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#role-badge {
|
||||
font-size: 9px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #8a6f2e;
|
||||
border-radius: 3px;
|
||||
color: #8a6f2e;
|
||||
letter-spacing: .06em;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#content {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: #8a6f2e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
#cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #16213e;
|
||||
border: 0.5px solid #2e2e4e;
|
||||
border-radius: 6px;
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
|
||||
.card:hover { border-color: #c8a84b; }
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 56px;
|
||||
height: 72px;
|
||||
background: #0f2a0f;
|
||||
border: 0.5px solid #2e4e2e;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.thumb-lines { width: 40px; }
|
||||
|
||||
.thumb-line {
|
||||
height: 2px;
|
||||
background: #2e4e2e;
|
||||
border-radius: 1px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.thumb-line.dim { background: #1e3e1e; }
|
||||
|
||||
.card-meta { flex: 1; min-width: 0; }
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
color: #e8d9b0;
|
||||
letter-spacing: .03em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 10px;
|
||||
color: #8a6f2e;
|
||||
letter-spacing: .05em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.tag {
|
||||
font-size: 9px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: .05em;
|
||||
background: #0f1a3e;
|
||||
color: #6a8fd8;
|
||||
border: 0.5px solid #2e3e6e;
|
||||
}
|
||||
|
||||
.card-progress {
|
||||
margin-top: 14px;
|
||||
border-top: 0.5px solid #2e2e4e;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.prog-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.prog-row:last-child { margin-bottom: 0; }
|
||||
|
||||
.prog-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
width: 52px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prog-label.p1 { color: #c8a84b; }
|
||||
.prog-label.p2 { color: #6a8fd8; }
|
||||
|
||||
.prog-track {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: #0f1a0f;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prog-fill { height: 100%; border-radius: 3px; transition: width .3s ease; }
|
||||
.prog-fill.p1 { background: #c8a84b; }
|
||||
.prog-fill.p2 { background: #378ADD; }
|
||||
|
||||
.prog-pct {
|
||||
font-size: 10px;
|
||||
width: 32px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prog-pct.p1 { color: #c8a84b; }
|
||||
.prog-pct.p2 { color: #6a8fd8; }
|
||||
|
||||
.card-action { margin-top: 12px; }
|
||||
|
||||
.btn-open, .btn-view {
|
||||
padding: 4px 14px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-family: 'Georgia', serif;
|
||||
letter-spacing: .05em;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-open {
|
||||
border: 1px solid #c8a84b;
|
||||
color: #c8a84b;
|
||||
}
|
||||
|
||||
.btn-open:hover { background: rgba(200,168,75,.08); }
|
||||
|
||||
.btn-view {
|
||||
border: 1px solid #5f5e5a;
|
||||
color: #888780;
|
||||
}
|
||||
|
||||
.btn-view:hover { background: rgba(136,135,128,.08); }
|
||||
|
||||
#footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 10px;
|
||||
color: #5f5e5a;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="topbar">
|
||||
<h1>⚜ P44 War Diary Transcription</h1>
|
||||
<span id="role-badge">Pass 1 — Volunteer</span>
|
||||
</div>
|
||||
|
||||
<div id="content">
|
||||
<div class="section-label">War diaries available for transcription</div>
|
||||
<div id="cards"></div>
|
||||
</div>
|
||||
|
||||
<div id="footer">Project '44 — Canadian Research and Mapping Association</div>
|
||||
|
||||
<script>
|
||||
const DIARIES = [
|
||||
{
|
||||
id: 'calgary-highlanders',
|
||||
title: 'Calgary Highlanders',
|
||||
subtitle: 'War Diary — September 1944',
|
||||
tags: ['Infantry', 'Sep 1944', '343 pages'],
|
||||
totalPages: 343,
|
||||
viewerUrl: 'p44-ocr-viewer.html',
|
||||
flagsKey: 'p44-flags',
|
||||
pass2FlagsKey: 'p44-pass2-flags',
|
||||
},
|
||||
{
|
||||
id: 'black-watch',
|
||||
title: 'Black Watch (RHR) of Canada',
|
||||
subtitle: 'War Diary — September 1944',
|
||||
tags: ['Infantry', 'Sep 1944', '211 pages'],
|
||||
totalPages: 211,
|
||||
viewerUrl: 'p44-ocr-viewer.html',
|
||||
flagsKey: 'p44-flags',
|
||||
pass2FlagsKey: 'p44-pass2-flags',
|
||||
},
|
||||
{
|
||||
id: '5cib',
|
||||
title: '5th Canadian Infantry Brigade',
|
||||
subtitle: 'War Diary — September 1944',
|
||||
tags: ['Brigade HQ', 'Sep 1944', '178 pages'],
|
||||
totalPages: 178,
|
||||
viewerUrl: 'p44-ocr-viewer.html',
|
||||
flagsKey: 'p44-flags',
|
||||
pass2FlagsKey: 'p44-pass2-flags',
|
||||
},
|
||||
];
|
||||
|
||||
function getCurrentRole() {
|
||||
return localStorage.getItem('p44-role') || 'pass1';
|
||||
}
|
||||
|
||||
function getProgress(diary) {
|
||||
let pass1Done = 0, pass2Done = 0;
|
||||
try {
|
||||
const flags = JSON.parse(localStorage.getItem(diary.flagsKey) || '{}');
|
||||
pass1Done = Object.values(flags).filter(f => f === 'done').length;
|
||||
} catch (_) {}
|
||||
try {
|
||||
const pass2Flags = JSON.parse(localStorage.getItem(diary.pass2FlagsKey) || '{}');
|
||||
pass2Done = Object.values(pass2Flags).filter(f => f === 'done').length;
|
||||
} catch (_) {}
|
||||
return {
|
||||
pass1Pct: Math.round((pass1Done / diary.totalPages) * 100),
|
||||
pass2Pct: Math.round((pass2Done / diary.totalPages) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
function openDiary(diaryId) {
|
||||
const diary = DIARIES.find(d => d.id === diaryId);
|
||||
if (diary) window.location.href = diary.viewerUrl;
|
||||
}
|
||||
|
||||
function renderCard(diary) {
|
||||
const role = getCurrentRole();
|
||||
const prog = getProgress(diary);
|
||||
const isViewOnly = role === 'pass1' && prog.pass1Pct === 100;
|
||||
const tagHtml = diary.tags.map(t => '<span class="tag">' + t + '</span>').join('');
|
||||
const thumbLines = Array(10).fill(0).map((_, i) =>
|
||||
'<div class="thumb-line' + (i % 2 ? ' dim' : '') + '"></div>'
|
||||
).join('');
|
||||
const actionBtn = isViewOnly
|
||||
? '<button class="btn-view" onclick="openDiary(\'' + diary.id + '\')">View only</button>'
|
||||
: '<button class="btn-open" onclick="openDiary(\'' + diary.id + '\')">Open diary</button>';
|
||||
|
||||
return '<div class="card">' +
|
||||
'<div class="card-top">' +
|
||||
'<div class="thumb" aria-hidden="true"><div class="thumb-lines">' + thumbLines + '</div></div>' +
|
||||
'<div class="card-meta">' +
|
||||
'<div class="card-title">' + diary.title + '</div>' +
|
||||
'<div class="card-sub">' + diary.subtitle + '</div>' +
|
||||
'<div class="card-tags">' + tagHtml + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-progress">' +
|
||||
'<div class="prog-row">' +
|
||||
'<span class="prog-label p1">Pass 1</span>' +
|
||||
'<div class="prog-track"><div class="prog-fill p1" style="width:' + prog.pass1Pct + '%"></div></div>' +
|
||||
'<span class="prog-pct p1">' + prog.pass1Pct + '%</span>' +
|
||||
'</div>' +
|
||||
'<div class="prog-row">' +
|
||||
'<span class="prog-label p2">Pass 2</span>' +
|
||||
'<div class="prog-track"><div class="prog-fill p2" style="width:' + prog.pass2Pct + '%"></div></div>' +
|
||||
'<span class="prog-pct p2">' + prog.pass2Pct + '%</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-action">' + actionBtn + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function init() {
|
||||
const role = getCurrentRole();
|
||||
document.getElementById('role-badge').textContent =
|
||||
role === 'pass2' ? 'Pass 2 — Senior Editor' : 'Pass 1 — Volunteer';
|
||||
document.getElementById('cards').innerHTML = DIARIES.map(renderCard).join('');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
VERIFICATION CHECKLIST:
|
||||
|
||||
Page loads at http://localhost:XXXX/p44-landing.html
|
||||
Three diary cards visible with correct titles and tags
|
||||
Role badge in top right reflects p44-role from localStorage
|
||||
Pass 1 progress bar reads from p44-flags — counts pages with flag done
|
||||
Pass 2 progress bar reads from p44-pass2-flags — counts pages with flag done
|
||||
Diary with Pass 1 at 100% shows "View only" button for Pass 1 role
|
||||
All other diaries show "Open diary" button
|
||||
Clicking "Open diary" or "View only" navigates to p44-ocr-viewer.html
|
||||
Pass 2 role always sees "Open diary" on all cards regardless of progress
|
||||
Footer shows CRMA credit
|
||||
441
prompts/ocr-viewer-audit.md
Normal file
441
prompts/ocr-viewer-audit.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# P44 OCR Viewer — Full Rebuild Audit
|
||||
*Combined: Copilot codebase audit + Nathan/Claude session history audit*
|
||||
*Date: May 2026 — Pre-rebuild reference document*
|
||||
|
||||
---
|
||||
|
||||
## Purpose of this document
|
||||
|
||||
This document captures everything known about the current `p44-ocr-viewer` codebase — what works, what is broken, what is dead, and critically **why** things broke — so the rebuild starts from a complete picture rather than repeating the same mistakes.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Copilot Codebase Audit
|
||||
|
||||
### 1. Feature Inventory
|
||||
|
||||
**PDF Rendering and Page Navigation**
|
||||
Functions: `loadPDFBuffer()`, `renderCurrentPage()`, `buildPDFWordOverlay()`, `makeWordBox()`, `goToPage()`, `_pendingPage` queue.
|
||||
Status: Working. DPR scaling, viewport fitting, zoom multiplier, and pending-page queue are all correct. `goToPage()` guards out-of-range values, debounces re-renders, and saves edits before navigating.
|
||||
|
||||
---
|
||||
|
||||
**Ctrl+Scroll Zoom on the Left Panel**
|
||||
Functions: `wheel` event on `imagePanelBody`, `updateZoomLabel()`, `btnZoomReset` click handler.
|
||||
Status: **Broken.** The handler has no `e.ctrlKey` check. It calls `e.preventDefault()` unconditionally on every wheel event when a PDF is loaded. In PDF mode, the user cannot scroll the left panel with the mouse wheel at all — every wheel movement zooms instead of scrolls. Sync-scroll from the left panel via wheel is also broken in PDF mode as a result.
|
||||
|
||||
---
|
||||
|
||||
**Synchronized Scrolling**
|
||||
Functions: `onImgScroll()`, `onTxtScroll()`, `_syncScrolling` / `_syncActive` flags, `btnSyncScroll` click handler.
|
||||
Status: Partially working. The proportional scroll logic and rAF anti-loop are correct. Toggle button works. However because the left-panel wheel handler kills normal scroll-via-wheel in PDF mode, users can only trigger left→right sync via the scrollbar. Right→left sync works normally.
|
||||
|
||||
---
|
||||
|
||||
**OCR Text Loading and Per-Page Rendering**
|
||||
Functions: `loadOCRText()`, `parseOCRByPage()`, `showOCRPage()`, `escH()`.
|
||||
Status: Working. `parseOCRByPage()` splits on `## Page N` headings into `state.ocrPages`. `showOCRPage()` reads a single page, checks for corrections, renders as `<pre>`. Startup race (OCR arrives before PDF) is handled correctly.
|
||||
|
||||
---
|
||||
|
||||
**Confidence Scoring Display (Per-Page)**
|
||||
Functions: `buildConfidenceIndicatorHTML()`, `updateNavConfidenceColour()`, `confTier()`, `confTierLabel()`, `loadConfidenceOnStartup()`.
|
||||
Status: Working. Loads from `REAL_CONFIDENCE_PATH` on startup. Per-page indicator bar with score, tier colour, uncertain word pills, and notes is injected above OCR text. Nav input/buttons change border colour to match tier.
|
||||
|
||||
---
|
||||
|
||||
**Confidence Summary Panel**
|
||||
Functions: `buildConfidenceSummaryHTML()`, `btn-conf-summary` click handler, `goToPage(0)` special case.
|
||||
Status: Working. Shows High/Med/Low counts, average score, distribution bar, and 10 lowest-scoring pages as clickable pills.
|
||||
|
||||
---
|
||||
|
||||
**Flag System (Pass 1 Flags)**
|
||||
Functions: `setFlag()`, `updateFlagButtons()`, `updateFlagsSummary()`, `saveFlags()`, `loadFlagsOnStartup()`, `clearPass1Queue()`.
|
||||
Status: Working. Pass 1 flags stored in `state.flags`. POSTed to `/save-flags` with localStorage fallback. Loaded from `/flags.json` on startup with localStorage fallback.
|
||||
Note: Pass 2 flags stored only in `pass2Flags` module variable and saved only to `localStorage('p44_pass2_flags')` — never to the server. Asymmetry with Pass 1.
|
||||
|
||||
---
|
||||
|
||||
**Inline Text Editing — Pass 1**
|
||||
Functions: `enterEditMode()`, `exitEditMode()`, `btnEdit` click handler, `updateCorrectionIndicator()`, `postCorrection()`, `loadCorrectionsOnStartup()`.
|
||||
Status: Working. `enterEditMode()` snapshots raw OCR into `corrections[pageNum].original` on first open. `exitEditMode()` saves to `corrections[pageNum].pass1`. Uses `<textarea id="text-editor">` — `#text-output` is hidden during editing. Navigating away auto-saves.
|
||||
|
||||
---
|
||||
|
||||
**Inline Text Editing — Pass 2**
|
||||
Functions: Same as Pass 1 with `pass2` key, `showPass2RevertBar()`.
|
||||
Status: Working with a minor flow issue. `showOCRPage()` unconditionally calls `enterEditMode()` at the end. Pages with no OCR content return early before reaching that call — those pages arrive with edit mode OFF in Pass 2 role, inconsistently.
|
||||
|
||||
---
|
||||
|
||||
**Role Switcher (Pass 1 / Pass 2)**
|
||||
Functions: `applyRole()`, `roleSelect` change handler, `currentRole` module variable.
|
||||
Status: Working. Saves role to `localStorage('p44_dev_role')` and restores on startup. Changes flag button labels, shows/hides Pass 2 banner, updates nav button behaviour.
|
||||
|
||||
---
|
||||
|
||||
**Pass 2 Banner and Queue**
|
||||
Functions: `updatePass2Banner()`, `updatePass2NavButtons()`, `getVerifiedPages()`.
|
||||
Status: Working. Banner shows count of Pass 1-verified pages ready for review. Nav buttons in Pass 2 mode skip to next/previous verified page.
|
||||
|
||||
---
|
||||
|
||||
**Pass 2 Change Summary Panel**
|
||||
Status: **Not implemented.** No change-summary panel exists in HTML or JS. No function builds a diff or change log. Only `#pass2-revert-bar` exists with two action buttons — no summary of what was changed.
|
||||
|
||||
---
|
||||
|
||||
**Revert to Pass 1 / Revert to Original**
|
||||
Functions: `showPass2RevertBar()`. Two buttons injected dynamically into `#pass2-revert-bar`.
|
||||
Status: Working in Pass 2 mode when page has a `pass1` correction. "Revert to Pass 1" sets `textEditor.value` to `corr.pass1`. "Revert to Original OCR" requires typing `REVERT`, then clears `state.corrections[pageNum]`. Neither revert auto-saves — user must navigate away or toggle Edit to persist.
|
||||
|
||||
---
|
||||
|
||||
**Correction Storage Model**
|
||||
Current structure:
|
||||
```javascript
|
||||
state.corrections = {
|
||||
[pageNum]: {
|
||||
original: string, // raw OCR snapshot, taken once on first edit-open
|
||||
pass1: string, // Pass 1 editor's text (absent if unchanged from original)
|
||||
pass2: string, // Pass 2 editor's text (absent if unchanged from pass1/original)
|
||||
}
|
||||
}
|
||||
```
|
||||
Status: Consistent across all functions. `showOCRPage()` reads `pass2 || pass1 || original || rawContent` (Pass 2) or `pass1 || original || rawContent` (Pass 1). `exitEditMode()` prunes entry entirely when neither `pass1` nor `pass2` is present. `postCorrection()` sends `{ page: N, correction: corrObj }`.
|
||||
Migration: `loadCorrectionsOnStartup()` migrates old flat-string corrections to `{ pass1: oldString }`. Migration runs on startup only and does not write migrated data back to the server.
|
||||
|
||||
---
|
||||
|
||||
**Keyboard Navigation**
|
||||
Function: `document.addEventListener('keydown', ...)`.
|
||||
Status: **Broken on Escape.** `ArrowRight/l`, `ArrowLeft/j`, `v/r/e` work. Escape handler references `zoomLens` — a variable never declared anywhere. Throws `ReferenceError: zoomLens is not defined` every time Escape is pressed. Click-to-zoom (Phase 3) was never implemented so the element was never created.
|
||||
|
||||
---
|
||||
|
||||
**Clear All Data Button**
|
||||
Function: `clearAllLocalData()`.
|
||||
Status: Working for local state. Wipes all five localStorage keys, resets state, resets role to Pass 1. Calls `fetch('/clear-all-data', { method: 'POST' })` but this endpoint does not exist in `launch_viewer.py`. Failure is silently swallowed. Server-side `flags.json` and `corrections.json` are NOT cleared.
|
||||
|
||||
---
|
||||
|
||||
**Sample Data / Load Sample Button**
|
||||
Functions: `loadSampleData()`, `buildSampleImageDataURL()`, `SAMPLE_TEXT`, `SAMPLE_JSON`.
|
||||
Status: Working. Status bar message still references "bidirectional highlighting" — a removed feature.
|
||||
|
||||
---
|
||||
|
||||
**Calgary Highlanders Auto-Load**
|
||||
Function: `loadCalgaryHighlanders()`, `DOMContentLoaded` handler.
|
||||
Status: Working when served via `launch_viewer.py`.
|
||||
|
||||
---
|
||||
|
||||
### 2. Dead Code
|
||||
|
||||
| Item | Location | Reason |
|
||||
|------|----------|--------|
|
||||
| `state.scale` (= 1.0) | State object | No scale dropdown exists in HTML. Never written after init, never read in any calculation. Vestigial. |
|
||||
| `state.ocrRawFull` | `loadOCRText()` | Set for plain-text/sample mode but never read again anywhere. |
|
||||
| `const base = ...` in `enterEditMode()` | Line ~1108 | Declared but never referenced. Orphan from a refactor. |
|
||||
| `#scale-select` CSS block | CSS | No element with `id="scale-select"` exists in HTML. |
|
||||
| `#text-output[contenteditable="true"]` CSS rule | CSS | Editor uses `<textarea id="text-editor">` not contenteditable on `#text-output`. Never fires. |
|
||||
| `zoomLens` reference | JS line ~1200 | Used in Escape handler but never declared. Causes runtime ReferenceError. |
|
||||
| Stale status message | `loadSampleData()` | References removed bidirectional highlighting feature. |
|
||||
| `p44_diff_legend_dismissed` in `clearAllLocalData()` | JS | Key is never written anywhere in the codebase. Only cleared. |
|
||||
|
||||
---
|
||||
|
||||
### 3. State Audit
|
||||
|
||||
| Property | Type | Status |
|
||||
|----------|------|--------|
|
||||
| `mode` | `'pdf' \| 'image' \| 'none'` | Active |
|
||||
| `pdfDoc` | PDF.js document or null | Active |
|
||||
| `currentPage` | number | Active |
|
||||
| `totalPages` | number | Active |
|
||||
| `scale` | number (1.0, never changes) | **Dead** — initialized but never written or read in any live code path |
|
||||
| `zoomScale` | number | Active |
|
||||
| `wordData` | array | Active |
|
||||
| `imageNaturalW` | number | Active (image mode only) |
|
||||
| `imageNaturalH` | number | Active (image mode only) |
|
||||
| `rendering` | boolean | Active |
|
||||
| `ocrPages` | `{ [pageNum]: string }` | Active |
|
||||
| `ocrRawFull` | string | **Dead** — written in plain-text path, never read again |
|
||||
| `lastRenderTime` | number | Active |
|
||||
| `flags` | object | Active |
|
||||
| `corrections` | object | Active |
|
||||
| `editMode` | boolean | Active |
|
||||
| `confidenceData` | object | Active |
|
||||
|
||||
---
|
||||
|
||||
### 4. localStorage Audit
|
||||
|
||||
| Key | Written by | Read by | Notes |
|
||||
|-----|-----------|---------|-------|
|
||||
| `p44-flags` | `saveFlags()` | `loadFlagsOnStartup()` | Fallback when server unavailable |
|
||||
| `p44-corrections` | `postCorrection()` fallback | `loadCorrectionsOnStartup()` | Also written during startup migration |
|
||||
| `p44_pass2_flags` | `savePass2Flags()` | `loadPass2FlagsOnStartup()` | **Only storage for Pass 2 flags — no server endpoint** |
|
||||
| `p44_dev_role` | `applyRole()` | Startup IIFE | Persists role across refreshes |
|
||||
| `p44_diff_legend_dismissed` | **Never written** | **Never read** | Only appears in `clearAllLocalData()` as a `removeItem()` call. Feature was removed without cleanup. |
|
||||
|
||||
---
|
||||
|
||||
### 5. Known Broken or Incomplete
|
||||
|
||||
1. **`zoomLens` ReferenceError (hard crash on Escape).** `zoomLens` is never declared. Every Escape keypress throws `ReferenceError`.
|
||||
|
||||
2. **Wheel zoom intercepts all scroll, not Ctrl+scroll.** No `e.ctrlKey` check. Left panel cannot be scrolled with mouse wheel in PDF mode.
|
||||
|
||||
3. **`updateFlagsSummary()` silently ignores Pass 2 flags.** Footer counts only `state.flags` (Pass 1). Pass 2 flags never included in summary even when in Pass 2 mode.
|
||||
|
||||
4. **`savePass2Flags()` never saves to server.** Pass 2 flags are lost if browser storage is cleared or a different machine is used.
|
||||
|
||||
5. **`/clear-all-data` POST endpoint does not exist in `launch_viewer.py`.** Server files not cleared when user clicks "Clear all data".
|
||||
|
||||
6. **`state.ocrRawFull` is populated but never used.** Latent issue if plain-text path is extended.
|
||||
|
||||
7. **`p44_diff_legend_dismissed` cleared but never written.** Dead localStorage reference.
|
||||
|
||||
8. **`showPass2RevertBar()` adds fresh event listeners on each call.** Low risk but possible accumulation in edge cases.
|
||||
|
||||
9. **Corrections migration does not update server file.** `corrections.json` on disk stays in old format after migration. Re-runs from stale file on every server restart.
|
||||
|
||||
10. **Stale status message in `loadSampleData()`.** References removed bidirectional highlighting feature.
|
||||
|
||||
---
|
||||
|
||||
### 6. Data Model Audit — `state.corrections`
|
||||
|
||||
```javascript
|
||||
state.corrections = {
|
||||
1: {
|
||||
original: "raw OCR text at moment editor first opened",
|
||||
pass1: "Pass 1 volunteer's saved text", // absent if unchanged from original
|
||||
pass2: "Pass 2 editor's saved text", // absent if unchanged from pass1/original
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Consistent across all read/write sites. `original` is preserved in memory but pruned from persisted object if both `pass1` and `pass2` are absent. So `original` is a transient in-session anchor, not guaranteed to be in stored JSON.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Nathan/Claude Session History Audit
|
||||
|
||||
*What actually happened during development — the decisions made, why things broke, and what to do differently in the rebuild.*
|
||||
|
||||
---
|
||||
|
||||
### The fundamental architectural mistake
|
||||
|
||||
**`textOutput` was used as display container, edit surface, and diff viewer simultaneously.**
|
||||
|
||||
These three roles have incompatible requirements:
|
||||
- Display container needs rendered HTML (`innerHTML`)
|
||||
- Edit surface needs plain text capture (`innerText`)
|
||||
- Diff viewer needs highlight spans (`innerHTML` with CSS classes)
|
||||
|
||||
`innerHTML` → `innerText` on a div containing tables, headings, and diff spans is unpredictable. Tables become tab-separated text. Headings lose formatting. Diff spans get their text content extracted without markup. This single decision caused the majority of bugs encountered:
|
||||
- HTML tags appearing as literal text in corrections
|
||||
- Table structure being lost when pages were edited
|
||||
- Pass 1 edits bleeding onto Pass 2 pages
|
||||
- False-positive diffs from whitespace mismatches
|
||||
- Confidence indicator text being captured into corrections
|
||||
|
||||
The fix — a separate `<textarea id="text-editor">` that is shown/hidden while `#text-output` is hidden/shown — was the correct architectural decision but took too long to reach because symptoms were being patched instead of root causes diagnosed.
|
||||
|
||||
**In the rebuild: display and editing must be separate elements from day one.**
|
||||
|
||||
---
|
||||
|
||||
### The markdown/HTML rendering dead end
|
||||
|
||||
olmOCR was asked to output "clean markdown" but war diary pages are tabular. The model correctly used HTML tables because markdown tables cannot handle multi-line cells with `<br>` tags. This produced inconsistent output — some pages as prose, some as HTML tables, some mixed.
|
||||
|
||||
A hand-rolled `convertMarkdown()` parser was built to handle this. It required repeated patching:
|
||||
- First version missed indented HTML blocks (`<tr>` lines starting with spaces)
|
||||
- Second version added depth tracking for HTML blocks
|
||||
- Third version was still producing inconsistent output
|
||||
|
||||
Six separate debugging sessions were spent on markdown rendering issues that had nothing to do with the actual feature being worked on at the time.
|
||||
|
||||
**In the rebuild: OCR prompt outputs plain text only. No markdown parser needed. `<pre>` display of plain text. Zero rendering complexity.**
|
||||
|
||||
---
|
||||
|
||||
### The diff system — built and abandoned three times
|
||||
|
||||
**Attempt 1:** Text matching — highlighted all instances of the same word. "The" highlighted every "The" on the page. Fundamental design flaw — no unique link between words.
|
||||
|
||||
**Attempt 2:** `contentEditable` + `innerText` diff — caused cross-page bleed (page N edits appearing on page N+1) and false positives from HTML whitespace.
|
||||
|
||||
**Attempt 3:** `stripHTML()` on both sides — `stripHTML(markdown)` and `stripHTML(innerText)` produce different whitespace patterns for the same content even when text is identical. False positives on every page.
|
||||
|
||||
All three attempts failed for the same root reason: the input to the diff was contaminated HTML, not clean text. With plain text OCR this is now permanently solved. A simple word-array diff will work cleanly.
|
||||
|
||||
**In the rebuild: diff runs on plain text strings only. No stripHTML needed. Build it once.**
|
||||
|
||||
---
|
||||
|
||||
### Features added before foundations were stable
|
||||
|
||||
The build sequence was:
|
||||
1. PDF rendering ✓
|
||||
2. OCR loading ✓
|
||||
3. Sync scroll ✓ (partially broken by zoom later)
|
||||
4. Zoom — broke scroll
|
||||
5. Flags ✓
|
||||
6. Edit mode — introduced `contentEditable` architectural flaw
|
||||
7. Confidence display ✓
|
||||
8. Pass 2 mode + diff — built on broken edit foundation
|
||||
9. Role switcher ✓
|
||||
10. Change summary — never actually implemented despite multiple prompts
|
||||
11. Revert system — partially implemented
|
||||
|
||||
Each feature after #6 was built on top of a broken edit/save loop. Every new feature added complexity that made the underlying bugs harder to isolate.
|
||||
|
||||
**In the rebuild: get edit/save 100% correct before building Pass 2 mode. Get Pass 2 mode working before building change summary. No feature ships until the feature before it is solid.**
|
||||
|
||||
---
|
||||
|
||||
### Copilot context loss in large files
|
||||
|
||||
When everything lived in one 2,176-line HTML file, Copilot would:
|
||||
- Apply changes in the wrong function (similar names, wrong location)
|
||||
- Miss functions entirely (outside its context window)
|
||||
- Partially implement features (add HTML but forget JS, or vice versa)
|
||||
- Introduce new bugs while fixing old ones (touching code it wasn't asked to touch)
|
||||
|
||||
Splitting into HTML/CSS/JS reduced this significantly. The remaining issues were smaller in scope and easier to diagnose.
|
||||
|
||||
**In the rebuild: three files from day one. Each prompt specifies exactly which file and which function. No prompt touches more than one file at a time unless unavoidable.**
|
||||
|
||||
---
|
||||
|
||||
### The OCR pipeline decision history
|
||||
|
||||
Original prompt: "Output clean markdown." → Inconsistent markdown+HTML output → hand-rolled parser → cascading failures.
|
||||
|
||||
Revised prompt: "Plain text only, no tables, transcribe war diary entries as prose." → Model made editorial decisions about what was "diary content" → omitted text that humans should see.
|
||||
|
||||
Final prompt: "Transcribe ALL text visible on the page. Do not decide what is important." → Correct.
|
||||
|
||||
**In the rebuild: use the final prompt. Never ask the model to interpret or select content.**
|
||||
|
||||
---
|
||||
|
||||
### What was never built but was promised
|
||||
|
||||
These features were designed in full detail during the session but never actually implemented:
|
||||
|
||||
1. **Pass 2 change summary panel** — `buildChangeSummary()` function spec written, HTML/CSS specified, Copilot prompt written. Never confirmed working in a screenshot.
|
||||
|
||||
2. **`/clear-all-data` server endpoint** — specified for `launch_viewer.py`. Copilot prompt written. Endpoint confirmed missing by audit.
|
||||
|
||||
3. **Pass 2 flags saved to server** — asymmetry identified but never fixed.
|
||||
|
||||
4. **`/clear-all-data` in `launch_viewer.py`** — POST handler specified but never confirmed added.
|
||||
|
||||
5. **Scroll-to-region approximate highlighting** — discussed as a replacement for word-level cross-highlighting. Never implemented. Deprioritized in favour of other features.
|
||||
|
||||
---
|
||||
|
||||
### Persistent user frustrations (verbatim from session)
|
||||
|
||||
- *"stuff keeps breaking"* — after every Copilot fix session
|
||||
- *"are we just applying bandaids?"* — after the fourth markdown patch
|
||||
- *"it broke the output"* — diff showing tags as literal text
|
||||
- *"now its marking stuff that wasnt changed as changed"* — false-positive diff on every word
|
||||
- *"still getting the same old errors"* — after file split, still hitting edit/save bugs
|
||||
- *"I am still getting the page numbers bleeding into the output"* — `## Page N` appearing in editor
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Rebuild Specification
|
||||
|
||||
### What to keep from the old codebase (working, clean, reuse as-is)
|
||||
|
||||
- `loadPDFBuffer()` and `renderCurrentPage()` — solid, no changes needed
|
||||
- `buildPDFWordOverlay()` and `makeWordBox()` — solid
|
||||
- `parseOCRByPage()` — clean, keep exactly as-is
|
||||
- `loadConfidenceOnStartup()` and all confidence display functions — working
|
||||
- `saveFlags()` / `loadFlagsOnStartup()` / `setFlag()` / `updateFlagButtons()` — working
|
||||
- `applyRole()` / `getVerifiedPages()` / `updatePass2Banner()` — working
|
||||
- `goToPage()` — working, keep the edit-save guard
|
||||
- Sync scroll logic — working (fix wheel zoom first)
|
||||
- CSS variables and visual design — keep the aesthetic
|
||||
|
||||
---
|
||||
|
||||
### What to rebuild from scratch
|
||||
|
||||
**Edit system — new architecture:**
|
||||
```
|
||||
#text-output — display only, never editable, always innerHTML
|
||||
#text-editor — <textarea>, plain text only, shown only in edit mode
|
||||
pre-populated from corrections.pass1 or corrections.original
|
||||
hidden when not editing
|
||||
```
|
||||
|
||||
No `contentEditable` anywhere. Ever.
|
||||
|
||||
**Correction data model — keep the object structure, rebuild the plumbing:**
|
||||
```javascript
|
||||
corrections[pageNum] = {
|
||||
original: string, // snapshotted once on first edit-open
|
||||
pass1: string, // saved by Pass 1 on exit
|
||||
pass2: string, // saved by Pass 2 on exit
|
||||
}
|
||||
```
|
||||
|
||||
**Change summary — build it once, correctly:**
|
||||
- Plain text word-array diff: `original.split(/\s+/)` vs `pass1.split(/\s+/)`
|
||||
- Output a simple list: changed / added / deleted words
|
||||
- Read-only panel above textarea in Pass 2 mode
|
||||
- No inline highlighting — just a clean change log
|
||||
|
||||
**`launch_viewer.py` — add missing endpoints:**
|
||||
- `/save-corrections` — write `corrections.json`
|
||||
- `/save-flags` — write `flags.json` (already exists, verify)
|
||||
- `/save-pass2-flags` — write `pass2_flags.json` (new)
|
||||
- `/clear-all-data` — delete all three JSON files
|
||||
|
||||
---
|
||||
|
||||
### Build order for the rebuild
|
||||
|
||||
1. HTML/CSS shell — layout only, no JS
|
||||
2. PDF loading and page navigation
|
||||
3. OCR loading and plain text display (`<pre>`)
|
||||
4. Confidence display
|
||||
5. Flags (Pass 1)
|
||||
6. **Edit mode — Pass 1 only, textarea architecture. Do not proceed until 100% correct.**
|
||||
7. Corrections storage and server persistence
|
||||
8. Role switcher and Pass 2 mode
|
||||
9. Pass 2 change summary panel
|
||||
10. Revert system
|
||||
11. Sync scroll (fix wheel zoom at same time)
|
||||
12. Keyboard navigation (fix `zoomLens` reference)
|
||||
13. Clear all data (with working server endpoint)
|
||||
|
||||
**Each step must be confirmed working before the next step starts.**
|
||||
|
||||
---
|
||||
|
||||
### localStorage keys for the rebuild
|
||||
|
||||
| Key | Purpose | Server backup |
|
||||
|-----|---------|--------------|
|
||||
| `p44-flags` | Pass 1 flags | `flags.json` |
|
||||
| `p44-pass2-flags` | Pass 2 flags | `pass2_flags.json` |
|
||||
| `p44-corrections` | All corrections (object model) | `corrections.json` |
|
||||
| `p44-role` | Current role | None needed |
|
||||
|
||||
Remove: `p44_diff_legend_dismissed` (feature gone), `p44_dev_role` (rename to `p44-role` for consistency).
|
||||
|
||||
---
|
||||
|
||||
*End of audit. This document is the complete reference for the rebuild.*
|
||||
262
prompts/ocr-viewer-audit4.md
Normal file
262
prompts/ocr-viewer-audit4.md
Normal file
@@ -0,0 +1,262 @@
|
||||
P44 OCR Viewer — Remove diff system, add Pass 2 split panel view
|
||||
Scope: p44-ocr-viewer.js, p44-ocr-viewer.css, p44-ocr-viewer.html. Complete removal of all diff logic, replace with a simple toggleable split panel for Pass 2.
|
||||
|
||||
PART 1 — Remove diff entirely
|
||||
HTML — remove these elements:
|
||||
|
||||
<button id="btn-diff" ...>⇄ Diff</button>
|
||||
<div id="diff-toolbar" ...></div>
|
||||
<div id="diff-reference" ...></div>
|
||||
<div id="editor-bottom"> wrapper — move its children (#line-numbers and #text-editor) back up directly inside #editor-wrap
|
||||
|
||||
CSS — remove all rules for:
|
||||
|
||||
#diff-toolbar
|
||||
#diff-toolbar .diff-legend
|
||||
#diff-toolbar .diff-count
|
||||
.diff-swatch
|
||||
.diff-swatch-del
|
||||
.diff-swatch-ins
|
||||
.diff-del
|
||||
.diff-ins
|
||||
#diff-reference
|
||||
#diff-reference-label
|
||||
#editor-bottom
|
||||
|
||||
JS — remove these functions entirely:
|
||||
|
||||
buildInlineDiff()
|
||||
renderInlineDiff()
|
||||
showDiffToolbar()
|
||||
applyDiffToDisplay()
|
||||
applyDiffToReference()
|
||||
toggleDiff()
|
||||
|
||||
JS — remove these DOM refs:
|
||||
|
||||
const btnDiff
|
||||
const diffToolbar
|
||||
const diffReference
|
||||
const editorBottom
|
||||
|
||||
JS — remove state.diffMode from the state object.
|
||||
JS — in showOCRPage() remove the entire block added for diff auto-enable and toolbar display — the block starting with const corrForPage = state.corrections && state.corrections[pageNum] down to the closing } of the Pass 2 auto-diff block. Restore the original Pass 2 block at the bottom of showOCRPage():
|
||||
javascript if (currentRole === 'pass2') {
|
||||
if (!state.editMode) enterEditMode();
|
||||
updatePass2NavButtons();
|
||||
}
|
||||
JS — in enterEditMode() remove the line applyDiffToReference(pageNum);
|
||||
JS — in exitEditMode() remove:
|
||||
|
||||
diffReference.style.display = 'none';
|
||||
if (state.diffMode) applyDiffToDisplay(pageNum);
|
||||
|
||||
JS — in goToPage() remove the four lines:
|
||||
javascript state.diffMode = false;
|
||||
btnDiff.classList.remove('active');
|
||||
diffToolbar.style.display = 'none';
|
||||
diffReference.style.display = 'none';
|
||||
|
||||
PART 2 — HTML (p44-ocr-viewer.html)
|
||||
In the right panel header, add a toggle button immediately before #btn-edit:
|
||||
html<button id="btn-split" class="tb-btn" title="Toggle split view — shows original OCR above for reference (Pass 2 only)" style="display:none;">⊟ Split</button>
|
||||
Inside #text-panel-body, immediately before #text-output, add the original OCR reference pane:
|
||||
html<div id="split-pane" style="display:none;">
|
||||
<div id="split-pane-header">ORIGINAL OCR</div>
|
||||
<div id="split-ocr-wrap">
|
||||
<div id="split-line-numbers"></div>
|
||||
<div id="split-ocr-output"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="split-divider" style="display:none;"></div>
|
||||
|
||||
PART 3 — CSS (p44-ocr-viewer.css)
|
||||
Add at the end of the file:
|
||||
css/* ── Split panel layout ───────────────────────────────────────── */
|
||||
#text-panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#split-pane {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#split-pane-header {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold-dim);
|
||||
padding: 5px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
#split-ocr-wrap {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
#split-line-numbers {
|
||||
width: 48px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px 0 12px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
line-height: 1.75;
|
||||
color: var(--gold-dim);
|
||||
font-family: 'Courier New', monospace;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border);
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
#split-ocr-output {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
#split-divider {
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
cursor: row-resize;
|
||||
border-top: 1px solid var(--gold-dim);
|
||||
border-bottom: 1px solid var(--gold-dim);
|
||||
}
|
||||
|
||||
/* When split is active, text-output and editor-wrap each take half */
|
||||
#text-panel-body.split-active #text-output,
|
||||
#text-panel-body.split-active #editor-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#text-panel-body.split-active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
PART 4 — JS (p44-ocr-viewer.js)
|
||||
4a — DOM refs
|
||||
Add:
|
||||
javascriptconst btnSplit = document.getElementById('btn-split');
|
||||
const splitPane = document.getElementById('split-pane');
|
||||
const splitDivider = document.getElementById('split-divider');
|
||||
const splitOcrOutput = document.getElementById('split-ocr-output');
|
||||
const splitLineNums = document.getElementById('split-line-numbers');
|
||||
4b — State flag
|
||||
Add to the state object:
|
||||
javascriptsplitMode: false,
|
||||
4c — Line number generator for split pane
|
||||
Add this new function above showOCRPage():
|
||||
javascriptfunction buildSplitLineNumbers(text) {
|
||||
const lines = (text || '').split('\n');
|
||||
return lines.map((_, i) => i + 1).join('\n');
|
||||
}
|
||||
4d — showSplitPane(pageNum)
|
||||
Add this new function:
|
||||
javascriptfunction showSplitPane(pageNum) {
|
||||
const corr = state.corrections && state.corrections[pageNum];
|
||||
const originalText = (corr && corr.original) || state.ocrPages[pageNum] || '';
|
||||
|
||||
splitOcrOutput.textContent = originalText;
|
||||
splitLineNums.textContent = buildSplitLineNumbers(originalText);
|
||||
|
||||
splitPane.style.display = 'flex';
|
||||
splitDivider.style.display = 'block';
|
||||
textPanelBody.classList.add('split-active');
|
||||
}
|
||||
4e — hideSplitPane()
|
||||
Add this new function:
|
||||
javascriptfunction hideSplitPane() {
|
||||
splitPane.style.display = 'none';
|
||||
splitDivider.style.display = 'none';
|
||||
textPanelBody.classList.remove('split-active');
|
||||
}
|
||||
4f — toggleSplit()
|
||||
Add this new function:
|
||||
javascriptfunction toggleSplit() {
|
||||
state.splitMode = !state.splitMode;
|
||||
btnSplit.classList.toggle('active', state.splitMode);
|
||||
|
||||
if (state.splitMode) {
|
||||
showSplitPane(state.currentPage);
|
||||
} else {
|
||||
hideSplitPane();
|
||||
}
|
||||
}
|
||||
Add the event listener immediately after:
|
||||
javascriptbtnSplit.addEventListener('click', toggleSplit);
|
||||
4g — applyRole()
|
||||
In applyRole(), in the pass2 branch add:
|
||||
javascript btnSplit.style.display = 'inline-block';
|
||||
In the pass1 branch add:
|
||||
javascript btnSplit.style.display = 'none';
|
||||
state.splitMode = false;
|
||||
hideSplitPane();
|
||||
4h — goToPage()
|
||||
At the start of goToPage(), after if (state.editMode) exitEditMode();, add:
|
||||
javascript if (state.splitMode) showSplitPane(n);
|
||||
This refreshes the split pane content for the new page without toggling it off.
|
||||
4i — showOCRPage()
|
||||
At the end of showOCRPage(), after updatePass2NavButtons(), add:
|
||||
javascript if (state.splitMode) showSplitPane(pageNum);
|
||||
|
||||
VERIFICATION CHECKLIST:
|
||||
|
||||
Pass 1 role: Split button not visible
|
||||
Pass 2 role: Split button visible in panel header
|
||||
Clicking Split in Pass 2: right panel splits into original OCR on top and editor on bottom, both with line numbers, both scroll independently
|
||||
Clicking Split again: collapses back to single panel, editor fills full height
|
||||
Navigating to a new page while split is on: both panes update to the new page's content, split stays on
|
||||
Switching from Pass 2 to Pass 1 while split is on: split closes, button hides
|
||||
Original OCR pane shows raw unedited text — not the Pass 1 corrected version
|
||||
Line numbers visible in both panes and match the text lines
|
||||
You said: Review this code.p44-ocr-viewer.jsjsp44-ocr-viewer.csscssp44-ocr-viewer.html140 lineshtmlReview this code. I have asked copilot numerous times to fix a bug but it cant figure it outThe text is not aligning properly or showing the wrong line breaks. this page is unedited, so the OCR and the editable text should look and behave the same exact way:2:24 p.m.Claude responded: Let me read the current files first.Let me read the current files first.Viewed 3 filesViewed 3 filesFound it. The bug is on line 602:
|
||||
javascriptif (currentRole === 'pass2') {
|
||||
if (!state.editMode) enterEditMode();
|
||||
Pass 2 auto-enters edit mode on every showOCRPage() call. That means the split pane's top half is showing the original OCR in a read-only div, but the bottom half is the textarea — and the textarea uses a completely different rendering path (wrap measurement, line numbers calculated differently) than the read-only display div. They look different because they literally ARE different elements with different sizing, padding, and line-break behaviour.
|
||||
The line number mismatch and text wrap differences you're seeing are all caused by this — the two panes are never showing text the same way.
|
||||
Here's the Copilot fix:
|
||||
|
||||
P44 OCR Viewer — Fix split pane text rendering mismatch
|
||||
Scope: p44-ocr-viewer.js only. Two targeted changes.
|
||||
Fix 1 — Remove Pass 2 auto-edit on showOCRPage()
|
||||
Find in showOCRPage() at the bottom:
|
||||
javascript if (currentRole === 'pass2') {
|
||||
if (!state.editMode) enterEditMode();
|
||||
updatePass2NavButtons();
|
||||
if (state.splitMode) showSplitPane(pageNum);
|
||||
}
|
||||
Replace with:
|
||||
javascript if (currentRole === 'pass2') {
|
||||
updatePass2NavButtons();
|
||||
if (state.splitMode) showSplitPane(pageNum);
|
||||
}
|
||||
Fix 2 — Make split pane top use identical rendering to #text-output
|
||||
Find in showSplitPane():
|
||||
javascript splitOcrOutput.textContent = originalText;
|
||||
updateSplitLineNumbers(originalText);
|
||||
Replace with:
|
||||
javascript splitOcrOutput.innerHTML =
|
||||
'<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;' +
|
||||
'line-height:1.75;color:var(--text);padding:16px 20px;">' +
|
||||
escH(originalText) + '</pre>';
|
||||
updateSplitLineNumbers(originalText);
|
||||
721
prompts/ocr-viewer-prompt.md
Normal file
721
prompts/ocr-viewer-prompt.md
Normal file
@@ -0,0 +1,721 @@
|
||||
# P44 OCR Viewer — Rebuild Prompts
|
||||
*Reference: ocr-viewer-audit.md — read this before starting any step.*
|
||||
*Each step must be confirmed working before the next step starts.*
|
||||
*Three files: p44-ocr-viewer.html / p44-ocr-viewer.css / p44-ocr-viewer.js*
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
Read `ocr-viewer-audit.md` in full. It contains the complete audit of the previous codebase, the root causes of every bug we hit, and the rebuild specification. Every architectural decision in these prompts comes from that document.
|
||||
|
||||
Key rules that must never be violated in this rebuild:
|
||||
- `#text-output` is display only — never set `contentEditable` on it
|
||||
- `#text-editor` is the only edit surface — always a `<textarea>`, always plain text
|
||||
- No markdown parser — OCR output is plain text, display with `<pre>`
|
||||
- No confidence scoring — removed by design
|
||||
- Each step confirmed working before the next begins
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — HTML/CSS shell
|
||||
|
||||
**No JavaScript. Layout and visual design only.**
|
||||
|
||||
Create three empty files: `p44-ocr-viewer.html`, `p44-ocr-viewer.css`, `p44-ocr-viewer.js`
|
||||
|
||||
Build the complete HTML structure and CSS. Nothing should be functional.
|
||||
|
||||
**HTML structure required:**
|
||||
|
||||
```
|
||||
#app (grid: toolbar / pass2-banner / panels / footer)
|
||||
|
||||
#toolbar
|
||||
h1 "⚜ P44 OCR VIEWER"
|
||||
#btn-load-ch button "▶ Calgary Highlanders — Sep 44"
|
||||
PDF override: label.file-btn > input#input-pdf[type=file accept=.pdf]
|
||||
OCR override: label.file-btn > input#input-text[type=file accept=.txt,.md]
|
||||
JSON override: label.file-btn > input#input-json[type=file accept=.json]
|
||||
#btn-example "Load sample"
|
||||
#btn-sync-scroll "Sync Scroll" (class active by default)
|
||||
role switcher: label "ROLE" + select#role-select
|
||||
option value="pass1" "Pass 1 — Volunteer"
|
||||
option value="pass2" "Pass 2 — Senior Editor"
|
||||
#btn-clear-all "⚠ Clear all data" (red-tinted border and text)
|
||||
|
||||
#pass2-banner (display:none by default)
|
||||
span#pass2-banner-text
|
||||
button#btn-pass2-reset "↺ Reset Pass 2"
|
||||
|
||||
#panels (two-column flex)
|
||||
|
||||
Left panel div.panel
|
||||
div.panel-header
|
||||
span "Source Document — Page Scan"
|
||||
span#zoom-label "100%"
|
||||
button#btn-zoom-reset "1:1"
|
||||
div.page-nav
|
||||
button#btn-prev "◀" disabled
|
||||
input#page-input type=number value=1 min=1
|
||||
button#btn-next "▶" disabled
|
||||
span#page-label "/ —"
|
||||
div.panel-body#image-panel-body
|
||||
div#image-placeholder
|
||||
div#img-spinner style=display:none
|
||||
div#img-ph-text "War diary scan loads automatically."
|
||||
div#pdf-container style=display:none
|
||||
canvas#pdf-canvas
|
||||
div#overlay
|
||||
|
||||
Right panel div.panel
|
||||
div.panel-header
|
||||
span "OCR Text Output"
|
||||
span#ocr-sync-label
|
||||
div#correction-dot style=display:none title="This page has been edited"
|
||||
button#btn-flag-verified class="tb-btn flag-btn" "✓ Verified"
|
||||
button#btn-flag-review class="tb-btn flag-btn" "⚑ Review"
|
||||
button#btn-flag-error class="tb-btn flag-btn" "✗ Error"
|
||||
button#btn-flag-clear class="tb-btn flag-btn" "Clear"
|
||||
button#btn-edit class="tb-btn" "Edit"
|
||||
div.panel-body#text-panel-body
|
||||
div#text-placeholder
|
||||
div#txt-spinner style=display:none
|
||||
div#txt-ph-text "OCR transcript loads automatically."
|
||||
div#text-output style=display:none
|
||||
textarea#text-editor style=display:none
|
||||
|
||||
#footer
|
||||
span#status-msg "Ready."
|
||||
span#flags-summary
|
||||
span#kbd-hint "← → navigate v/r/e flag esc close"
|
||||
```
|
||||
|
||||
**CSS variables — use exactly these:**
|
||||
```css
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--surface: #16213e;
|
||||
--surface2: #0f3460;
|
||||
--gold: #c8a84b;
|
||||
--gold-dim: #8a6f2e;
|
||||
--text: #ddd4bb;
|
||||
--text-dim: #7a7260;
|
||||
--text-muted:#4a4538;
|
||||
--border: #2e2e4e;
|
||||
--green: #4caf70;
|
||||
--green-bg: #1a3326;
|
||||
--amber: #e8a838;
|
||||
--amber-bg: #2e2010;
|
||||
--red: #d05050;
|
||||
--red-bg: #2e1a1a;
|
||||
--toolbar-h: 52px;
|
||||
--footer-h: 34px;
|
||||
}
|
||||
```
|
||||
|
||||
**CSS layout rules:**
|
||||
- `#app`: CSS grid `grid-template-rows: var(--toolbar-h) auto 1fr var(--footer-h)`, `height: 100vh`, `overflow: hidden`
|
||||
- `#panels`: flex row, each `.panel` takes `width: 50%`, `display: flex`, `flex-direction: column`, `overflow: hidden`
|
||||
- `.panel-body`: `flex: 1`, `overflow-y: auto`, `overflow-x: hidden`
|
||||
- `#pass2-banner`: `display: none`, slim flex row, `background: #1a1200`, `border-bottom: 1px solid var(--gold-dim)`, `padding: 6px 14px`, `font-size: 11px`, `color: var(--amber)`
|
||||
- `#text-editor`: `display: none`, `width: 100%`, `height: 100%`, `resize: none`, `font-family: Georgia serif`, `font-size: 12.5px`, `line-height: 1.75`, `background: var(--surface2)`, `color: var(--text)`, `border: none`, `border-top: 1px solid var(--gold-dim)`, `padding: 16px 20px`, `box-sizing: border-box`, `outline: none`
|
||||
- `#text-editor:focus`: `border-top-color: var(--gold)`
|
||||
- `.tb-btn`: `padding: 3px 9px`, `background: transparent`, `border: 1px solid var(--gold-dim)`, `border-radius: 3px`, `color: var(--text-dim)`, `font-family: Georgia serif`, `font-size: 10px`, `cursor: pointer`, `letter-spacing: 0.04em`
|
||||
- `.tb-btn:hover`: `border-color: var(--gold)`, `color: var(--gold)`
|
||||
- `.tb-btn.active`: `border-color: var(--gold)`, `color: var(--gold)`
|
||||
- `.file-btn`: same as `.tb-btn` but as a `<label>` with hidden `<input type="file">` inside
|
||||
- `#btn-clear-all`: `border-color: #d05050`, `color: #d05050`
|
||||
- `#btn-clear-all:hover`: `border-color: #e07070`, `color: #e07070`
|
||||
- `.flag-btn.active`: `border-color: var(--gold)`, `color: var(--gold)`
|
||||
- `#correction-dot`: `width: 8px`, `height: 8px`, `border-radius: 50%`, `background: var(--gold)`, `display: none`, `flex-shrink: 0`
|
||||
- `.panel-header`: `display: flex`, `align-items: center`, `gap: 6px`, `padding: 0 10px`, `height: 38px`, `background: var(--surface)`, `border-bottom: 1px solid var(--border)`, `flex-shrink: 0`, `font-size: 9px`, `letter-spacing: 0.1em`, `color: var(--text-dim)`, `text-transform: uppercase`
|
||||
- `#toolbar`: `display: flex`, `align-items: center`, `gap: 8px`, `padding: 0 12px`, `background: var(--surface)`, `border-bottom: 2px solid var(--gold-dim)`
|
||||
- `#toolbar h1`: `font-size: 13px`, `font-weight: normal`, `letter-spacing: 0.1em`, `color: var(--gold)`, `white-space: nowrap`
|
||||
- `.divider`: `width: 1px`, `height: 26px`, `background: var(--border)`, `flex-shrink: 0`
|
||||
- `#footer`: `display: flex`, `align-items: center`, `justify-content: space-between`, `padding: 0 14px`, `background: var(--surface)`, `border-top: 1px solid var(--border)`, `font-size: 10px`, `color: var(--text-dim)`
|
||||
- `#pdf-container`: `position: relative`, `display: inline-block`
|
||||
- `#overlay`: `position: absolute`, `top: 0`, `left: 0`, `pointer-events: none`
|
||||
- `.word-box`: `position: absolute`, `cursor: crosshair`, `border: 1px solid transparent`, `border-radius: 2px`
|
||||
- `.word-box:hover, .word-box.highlight`: `background: rgba(200,168,75,0.18)`, `border-color: var(--gold-dim)`
|
||||
- `.panel-placeholder`: `display: flex`, `flex-direction: column`, `align-items: center`, `justify-content: center`, `height: 100%`, `color: var(--text-dim)`, `font-size: 12px`, `text-align: center`, `gap: 10px`
|
||||
- `.spinner`: `width: 24px`, `height: 24px`, `border: 2px solid var(--border)`, `border-top-color: var(--gold)`, `border-radius: 50%`, `animation: spin 0.8s linear infinite`
|
||||
- `@keyframes spin`: `to { transform: rotate(360deg) }`
|
||||
- `#page-input`: `width: 48px`, `text-align: center`, `background: var(--surface2)`, `border: 1px solid var(--border)`, `color: var(--text)`, `font-family: Georgia serif`, `font-size: 12px`, `padding: 2px 4px`, `border-radius: 3px`
|
||||
- `select#role-select`: same styling as `.tb-btn` but as a select element, `background: var(--surface2)`
|
||||
- `#ocr-sync-label`: `font-size: 9px`, `color: var(--gold-dim)`, `letter-spacing: 0.06em`
|
||||
|
||||
**Do not write any JavaScript yet.**
|
||||
|
||||
Confirm when complete by listing every `id` and every CSS class defined.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — PDF loading and page navigation
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
|
||||
|
||||
Add the following in this order:
|
||||
|
||||
1. PDF.js worker setup:
|
||||
```javascript
|
||||
if (typeof pdfjsLib !== 'undefined') {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
}
|
||||
```
|
||||
|
||||
2. Data paths:
|
||||
```javascript
|
||||
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';
|
||||
```
|
||||
|
||||
3. State object — only these properties to start:
|
||||
```javascript
|
||||
const state = {
|
||||
mode: 'none', // 'pdf' | 'image' | 'none'
|
||||
pdfDoc: null,
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
zoomScale: 1.0, // Ctrl+scroll zoom multiplier (0.5–4.0)
|
||||
wordData: [],
|
||||
rendering: false,
|
||||
lastRenderTime: 0,
|
||||
ocrPages: {}, // { [pageNum]: string } — populated in Step 3
|
||||
flags: {}, // populated in Step 5
|
||||
corrections: {}, // populated in Step 6
|
||||
editMode: false, // populated in Step 6
|
||||
};
|
||||
```
|
||||
|
||||
4. All DOM refs (getElementById for every id in the HTML)
|
||||
|
||||
5. `escH(s)` utility function
|
||||
|
||||
6. `setStatus(msg, warn)` utility function
|
||||
|
||||
7. `showImgSpinner(on)`, `showTxtSpinner(on)`, `showImgPlaceholder(html)`, `showTxtPlaceholder(html)` utility functions
|
||||
|
||||
8. `loadPDFBuffer(arrayBuffer)` — loads PDF into PDF.js, sets `state.pdfDoc`, `state.totalPages`, `state.mode = 'pdf'`, calls `renderCurrentPage()`
|
||||
|
||||
9. `renderCurrentPage()` — fits page to panel width, applies `state.zoomScale`, uses DPR for HiDPI, renders to `#pdf-canvas`, calls `buildPDFWordOverlay()`. Wrap `showOCRPage()` call in try/catch (showOCRPage is added in Step 3). Include `_pendingPage` queue and debounce guard.
|
||||
|
||||
10. `buildPDFWordOverlay(page, vp, dpr)` — builds word boxes from `state.wordData` if loaded, otherwise leaves overlay blank
|
||||
|
||||
11. `makeWordBox(id, x, y, w, h, title)` — creates positioned div with hover highlight
|
||||
|
||||
12. `goToPage(n)` — validates range, guards against re-render of same page, calls `renderCurrentPage()`. No edit mode save yet (added in Step 6).
|
||||
|
||||
13. `_pendingPage = null` module variable
|
||||
|
||||
14. `updateZoomLabel()` — updates `#zoom-label` text
|
||||
|
||||
15. Event listeners:
|
||||
- `#btn-prev` click → `goToPage(state.currentPage - 1)`
|
||||
- `#btn-next` click → `goToPage(state.currentPage + 1)`
|
||||
- `#page-input` change → `goToPage(parseInt(value))`
|
||||
- `#btn-zoom-reset` click → reset `state.zoomScale` to 1.0, re-render
|
||||
- `window resize` → re-render if PDF loaded and not rendering
|
||||
- `#input-pdf` change → read file, call `loadPDFBuffer()`
|
||||
- `#input-json` change → read file, parse JSON, set `state.wordData`, re-render
|
||||
|
||||
16. Ctrl+scroll zoom on `#image-panel-body`:
|
||||
- **MUST check `e.ctrlKey` — only zoom when Ctrl is held**
|
||||
- Only call `e.preventDefault()` when `e.ctrlKey` is true
|
||||
- Without Ctrl: let the event pass through normally for scrolling
|
||||
- With Ctrl: adjust `state.zoomScale` by ±0.1, clamp to 0.5–4.0, re-render
|
||||
- After re-render, scroll to keep cursor anchor point in view
|
||||
|
||||
17. `DOMContentLoaded` auto-load:
|
||||
```javascript
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
setTimeout(loadCalgaryHighlanders, 200);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
18. `loadCalgaryHighlanders()` — fetches PDF only for now (OCR text fetch added in Step 3). Uses `Promise.allSettled`.
|
||||
|
||||
**Test: PDF loads, pages navigate with buttons and keyboard input, zoom works with Ctrl+scroll, normal scroll works without Ctrl.**
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — OCR text loading and plain text display
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
|
||||
|
||||
1. `parseOCRByPage(rawText)`:
|
||||
- Splits on `## Page N` headings
|
||||
- Stores content between headings in `state.ocrPages[N]`
|
||||
- **Does NOT include the `## Page N` heading line in the content**
|
||||
- `buf = []` not `buf = [line]`
|
||||
|
||||
2. `loadOCRText(rawText)`:
|
||||
- Calls `parseOCRByPage(rawText)`
|
||||
- Does NOT call `showOCRPage()` — only calls it if `state.pdfDoc` is already loaded
|
||||
- This prevents double-render on startup
|
||||
|
||||
3. `showOCRPage(pageNum)`:
|
||||
- Clears `textOutput.innerHTML = ''`
|
||||
- Sets `textOutput.dataset.page = pageNum`
|
||||
- Hides placeholder, shows `textOutput`
|
||||
- If no OCR pages loaded: shows "OCR text not loaded yet" message
|
||||
- If no content for this page: shows "No OCR text for page N" message
|
||||
- Gets display text: `corrections[pageNum]?.pass1 || state.ocrPages[pageNum]` (corrections object added in Step 6, guard with optional chaining for now)
|
||||
- Renders as: `<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;line-height:1.75;padding:16px 20px;">{escH(displayText)}</pre>`
|
||||
- Scrolls text panel to top
|
||||
- Updates `#ocr-sync-label` text
|
||||
|
||||
4. Update `loadCalgaryHighlanders()` to also fetch `REAL_TEXT_PATH` and call `loadOCRText()`
|
||||
|
||||
5. Add `#input-text` change event listener → `loadOCRText()`
|
||||
|
||||
6. Add `showOCRPage(state.currentPage)` call inside `renderCurrentPage()` after PDF page renders
|
||||
|
||||
**Test: OCR text loads alongside PDF, correct page shown for each page number, no `## Page N` heading visible in text panel, plain text renders correctly.**
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Synchronized scrolling
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
|
||||
|
||||
1. Module variables:
|
||||
```javascript
|
||||
let _syncScrolling = false;
|
||||
let _syncActive = true;
|
||||
```
|
||||
|
||||
2. `onImgScroll()` — when left panel scrolls, scroll right panel to same proportional position. Use `requestAnimationFrame` to reset `_syncScrolling` flag.
|
||||
|
||||
3. `onTxtScroll()` — when right panel scrolls, scroll left panel to same proportional position.
|
||||
|
||||
4. Attach scroll listeners to `#image-panel-body` and `#text-panel-body`
|
||||
|
||||
5. `#btn-sync-scroll` click → toggle `_syncActive`, toggle `.active` class on button
|
||||
|
||||
**Test: Scrolling either panel scrolls the other proportionally. Toggle button disables sync. Normal mouse wheel scroll works on left panel (no zoom without Ctrl).**
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Flag system
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
|
||||
|
||||
1. `updateFlagButtons(pageNum)` — toggles `.active` class on flag buttons based on `state.flags[pageNum]`
|
||||
|
||||
2. `updateFlagsSummary()` — counts verified/review/error flags, updates `#flags-summary` text
|
||||
|
||||
3. `setFlag(type)` — sets or clears `state.flags[state.currentPage]`, calls `updateFlagButtons()`, `updateFlagsSummary()`, `saveFlags()`
|
||||
|
||||
4. `saveFlags()`:
|
||||
- POST to `/save-flags` with `state.flags` as JSON body
|
||||
- On failure: fallback to `localStorage.setItem('p44-flags', ...)`
|
||||
|
||||
5. `loadFlagsOnStartup()`:
|
||||
- Fetch `/flags.json`
|
||||
- On failure: fallback to `localStorage.getItem('p44-flags')`
|
||||
- Call `updateFlagsSummary()` after loading
|
||||
|
||||
6. `clearPass1Queue()` — removes all `verified` flags, keeps review/error, confirm dialog required
|
||||
|
||||
7. Event listeners:
|
||||
- `#btn-flag-verified` → `setFlag('verified')`
|
||||
- `#btn-flag-review` → `setFlag('review')`
|
||||
- `#btn-flag-error` → `setFlag('error')`
|
||||
- `#btn-flag-clear` → `setFlag(null)`
|
||||
- `#btn-clear-p1-queue` → `clearPass1Queue()`
|
||||
|
||||
8. Call `loadFlagsOnStartup()` on `DOMContentLoaded`
|
||||
|
||||
9. Call `updateFlagButtons(pageNum)` at the end of `showOCRPage()`
|
||||
|
||||
**Test: Flags save and persist across refresh. Footer summary updates. Clear queue removes only verified flags.**
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Edit mode (Pass 1 only)
|
||||
|
||||
**This is the most critical step. Read the architecture rules before starting.**
|
||||
|
||||
**Architecture rules — must not be violated:**
|
||||
- `#text-output` is NEVER set to `contentEditable`. It is display only.
|
||||
- `#text-editor` (`<textarea>`) is the ONLY edit surface.
|
||||
- When edit mode is active: hide `#text-output`, show `#text-editor`
|
||||
- When edit mode is inactive: hide `#text-editor`, show `#text-output`
|
||||
- `#text-editor` always contains plain text — never HTML
|
||||
|
||||
**Correction data model:**
|
||||
```javascript
|
||||
state.corrections[pageNum] = {
|
||||
original: string, // raw OCR snapshot, taken ONCE on first edit-open, never overwritten
|
||||
pass1: string, // Pass 1 saved text (absent if unchanged from original)
|
||||
pass2: string, // Pass 2 saved text (added in Step 8, absent for now)
|
||||
}
|
||||
```
|
||||
|
||||
**Add to `p44-ocr-viewer.js`:**
|
||||
|
||||
1. `updateCorrectionIndicator(pageNum)` — shows/hides `#correction-dot` based on whether `corrections[pageNum]?.pass1` exists
|
||||
|
||||
2. `postCorrection(pageNum, corrObj)`:
|
||||
- POST to `/save-corrections` with `{ page: pageNum, correction: corrObj }`
|
||||
- On failure: fallback to localStorage `p44-corrections`
|
||||
|
||||
3. `loadCorrectionsOnStartup()`:
|
||||
- Fetch `/corrections.json`
|
||||
- On failure: fallback to localStorage
|
||||
- Remove any entries where both `pass1` and `pass2` are absent
|
||||
- Set `state.corrections`
|
||||
|
||||
4. `enterEditMode()`:
|
||||
```
|
||||
- Get pageNum = state.currentPage
|
||||
- If corrections[pageNum] does not exist: create { original: state.ocrPages[pageNum].trim() }
|
||||
- If corrections[pageNum].original does not exist: set it to state.ocrPages[pageNum].trim()
|
||||
- Set textEditor.value = corrections[pageNum].pass1 || corrections[pageNum].original
|
||||
- Set textEditor.dataset.page = pageNum
|
||||
- Hide textOutput (display:none)
|
||||
- Show textEditor (display:block)
|
||||
- Focus textEditor
|
||||
- Set state.editMode = true
|
||||
- Update btnEdit text to "Editing", add .active class
|
||||
```
|
||||
|
||||
5. `exitEditMode()`:
|
||||
```
|
||||
- If !state.editMode: return
|
||||
- Get pageNum = parseInt(textEditor.dataset.page)
|
||||
- Get editedText = textEditor.value.trim()
|
||||
- Get originalText = corrections[pageNum]?.original || state.ocrPages[pageNum]?.trim() || ''
|
||||
- If editedText !== originalText AND editedText is not empty:
|
||||
corrections[pageNum].pass1 = editedText
|
||||
Else:
|
||||
delete corrections[pageNum].pass1
|
||||
- If neither pass1 nor pass2 exist: delete corrections[pageNum] entirely
|
||||
- Call postCorrection(pageNum, corrections[pageNum] || null)
|
||||
- Call updateCorrectionIndicator(pageNum)
|
||||
- Hide textEditor (display:none), clear textEditor.value
|
||||
- Show textOutput (display:block)
|
||||
- Set state.editMode = false
|
||||
- Update btnEdit text to "Edit", remove .active class
|
||||
- Call showOCRPage(pageNum) to re-render display with saved correction
|
||||
```
|
||||
|
||||
6. Update `goToPage()` — call `exitEditMode()` at the very top before any other logic
|
||||
|
||||
7. `#btn-edit` click → toggle edit mode
|
||||
|
||||
8. Update `showOCRPage()` display text logic:
|
||||
```javascript
|
||||
const corr = state.corrections[pageNum];
|
||||
const displayText = (corr?.pass1) || state.ocrPages[pageNum] || '';
|
||||
```
|
||||
|
||||
9. Call `loadCorrectionsOnStartup()` on `DOMContentLoaded`
|
||||
|
||||
10. Call `updateCorrectionIndicator(pageNum)` at end of `showOCRPage()`
|
||||
|
||||
**Test thoroughly before proceeding:**
|
||||
- Enter edit mode: textarea appears, display hidden
|
||||
- Make a change and exit: display re-renders with correction, gold dot appears
|
||||
- Navigate away and back: correction persists, dot still shows
|
||||
- Enter and exit without changing: no correction saved, no dot
|
||||
- Refresh page: correction loads from localStorage/server, dot shows correctly
|
||||
- Confirm `## Page N` never appears in the textarea
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Corrections persistence in launch_viewer.py
|
||||
|
||||
**Add to `launch_viewer.py` only. Do not change any viewer files.**
|
||||
|
||||
Add a `do_POST` method to `QuietHandler` that handles these endpoints:
|
||||
|
||||
1. `POST /save-flags` — read JSON body, write to `flags.json` in ROOT
|
||||
|
||||
2. `POST /save-corrections` — read JSON body (`{ page, correction }`), load existing `corrections.json`, update the entry for `page`, write back
|
||||
|
||||
3. `POST /save-pass2-flags` — read JSON body, write to `pass2_flags.json` in ROOT
|
||||
|
||||
4. `POST /clear-all-data` — delete `flags.json`, `corrections.json`, `pass2_flags.json` if they exist, respond 200
|
||||
|
||||
All endpoints:
|
||||
- Read body with `self.rfile.read(int(self.headers['Content-Length']))`
|
||||
- Parse with `json.loads()`
|
||||
- Respond `200 OK` on success
|
||||
- Respond `400` on parse error
|
||||
- Never crash the server on bad input — wrap in try/except
|
||||
|
||||
**Test: Save a flag, restart the server, reload viewer — flag persists from file not just localStorage.**
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Role switcher and Pass 2 mode
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
|
||||
|
||||
1. Module variables:
|
||||
```javascript
|
||||
let currentRole = 'pass1';
|
||||
let pass2Flags = {};
|
||||
const PASS1_LABELS = { verified: '✓ Verified', review: '⚑ Review', error: '✗ Error' };
|
||||
const PASS2_LABELS = { verified: '✓ Pass 2 approved', review: '⚑ Needs work', error: '✕ Rejected' };
|
||||
```
|
||||
|
||||
2. `getVerifiedPages()` — returns sorted array of page numbers where `state.flags[n] === 'verified'`
|
||||
|
||||
3. `updatePass2NavButtons()` — in Pass 2 mode, disables prev/next if no verified pages before/after current
|
||||
|
||||
4. `updatePass2Banner()` — updates banner text with count of verified pages ready for review
|
||||
|
||||
5. `applyRole(role)`:
|
||||
- Sets `currentRole`
|
||||
- Saves to `localStorage('p44-role')`
|
||||
- Shows/hides `#pass2-banner`
|
||||
- Updates flag button labels to PASS1 or PASS2 labels
|
||||
- Calls `updatePass2Banner()` if pass2
|
||||
- Re-renders current page via `showOCRPage(state.currentPage)`
|
||||
|
||||
6. Update `#btn-prev` / `#btn-next` click handlers — in Pass 2 mode, jump between verified pages only
|
||||
|
||||
7. Update `showOCRPage()` — at the end, if `currentRole === 'pass2'`, call `enterEditMode()` if not already in edit mode. Also show pass2-specific display text: `corr?.pass2 || corr?.pass1 || state.ocrPages[pageNum]`
|
||||
|
||||
8. `savePass2Flags()` — POST to `/save-pass2-flags`, fallback to localStorage `p44-pass2-flags`
|
||||
|
||||
9. `loadPass2FlagsOnStartup()` — fetch `/pass2_flags.json`, fallback to localStorage
|
||||
|
||||
10. Update `setFlag()` — in Pass 2 mode, write to `pass2Flags` and call `savePass2Flags()` instead of `state.flags`
|
||||
|
||||
11. Update `updateFlagButtons()` — read from `pass2Flags` in Pass 2 mode
|
||||
|
||||
12. `resetPass2()` — clears `pass2Flags` and all corrections, confirm dialog, cannot be undone
|
||||
|
||||
13. `#btn-pass2-reset` click → `resetPass2()`
|
||||
|
||||
14. `#role-select` change → `applyRole(value)`
|
||||
|
||||
15. On `DOMContentLoaded`, restore role: `applyRole(localStorage.getItem('p44-role') || 'pass1')`
|
||||
|
||||
16. Call `loadPass2FlagsOnStartup()` on `DOMContentLoaded`
|
||||
|
||||
**Test: Switch roles, Pass 2 nav jumps between verified pages, Pass 2 flags stored separately, role persists on refresh.**
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Pass 2 change summary panel
|
||||
|
||||
**Add to HTML, CSS, and JS.**
|
||||
|
||||
**HTML — add inside `#text-panel-body` after `#text-placeholder`, before `#text-output`:**
|
||||
```html
|
||||
<div id="pass2-change-summary" style="display:none;"></div>
|
||||
```
|
||||
|
||||
**CSS — add:**
|
||||
```css
|
||||
#pass2-change-summary {
|
||||
display: none;
|
||||
padding: 10px 20px;
|
||||
background: var(--surface2);
|
||||
border-bottom: 1px solid var(--gold-dim);
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
#pass2-change-summary .cs-title {
|
||||
font-size: 10px;
|
||||
color: var(--gold-dim);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
#pass2-change-summary .cs-list {
|
||||
color: var(--text-dim);
|
||||
line-height: 1.8;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 8px;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
#pass2-change-summary .cs-changed { color: var(--amber); }
|
||||
#pass2-change-summary .cs-added { color: var(--green); }
|
||||
#pass2-change-summary .cs-deleted { color: var(--red); }
|
||||
#pass2-change-summary .cs-actions { display: flex; gap: 8px; }
|
||||
.cs-revert-btn {
|
||||
font-size: 10px;
|
||||
font-family: Georgia, serif;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.cs-revert-btn:hover { border-color: var(--gold-dim); color: var(--gold); }
|
||||
.cs-revert-btn.danger { border-color: var(--red); color: var(--red); }
|
||||
```
|
||||
|
||||
**JS — add:**
|
||||
|
||||
1. `buildChangeSummary(originalText, pass1Text)`:
|
||||
- Split both strings on whitespace: `str.trim().split(/\s+/)`
|
||||
- Walk both arrays simultaneously
|
||||
- Produce array of `{ type: 'changed'|'added'|'deleted', from?, to?, word? }`
|
||||
- Cap at 50 changes, add `{ type: 'truncated' }` if over
|
||||
- Return empty array if strings are identical
|
||||
|
||||
2. `showPass2ChangeSummary(pageNum)`:
|
||||
- Get `corr = state.corrections[pageNum]`
|
||||
- If no `corr?.pass1` or `currentRole !== 'pass2'`: hide panel, return
|
||||
- Build changes from `corr.original` vs `corr.pass1`
|
||||
- If no changes: hide panel, return
|
||||
- Render `cs-title` with change count
|
||||
- Render `cs-list` with each change coloured by type
|
||||
- Render two action buttons: "↺ Revert to Pass 1" and "⚠ Revert to original OCR"
|
||||
- Show panel
|
||||
- Wire revert buttons:
|
||||
- Revert to Pass 1: `confirm()` dialog, set `textEditor.value = corr.pass1`, status message
|
||||
- Revert to original: `prompt('Type REVERT to confirm')`, if confirmed delete `corrections[pageNum]`, call `postCorrection(pageNum, null)`, update indicator, hide summary, status message
|
||||
|
||||
3. Call `showPass2ChangeSummary(state.currentPage)` at end of `enterEditMode()`
|
||||
|
||||
4. Hide `#pass2-change-summary` at start of `exitEditMode()`
|
||||
|
||||
**Test: Switch to Pass 2, navigate to a page with Pass 1 corrections — change summary appears above textarea showing exactly what was changed. Revert to Pass 1 restores Pass 1 text. Revert to original requires typing REVERT and clears all corrections.**
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Keyboard navigation
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only.**
|
||||
|
||||
1. `document.addEventListener('keydown', e => {...})`
|
||||
|
||||
2. Guard: if `state.editMode` is true, return immediately (let all keys go to textarea)
|
||||
|
||||
3. Guard: if focus is on any `INPUT`, `TEXTAREA`, or `SELECT`, return
|
||||
|
||||
4. Key mappings:
|
||||
- `ArrowRight` or `l` → `goToPage(state.currentPage + 1)`
|
||||
- `ArrowLeft` or `j` → `goToPage(state.currentPage - 1)`
|
||||
- `v` → `setFlag('verified')`
|
||||
- `r` → `setFlag('review')`
|
||||
- `e` → `setFlag('error')`
|
||||
- `Escape` → if edit mode active: `exitEditMode()`. Otherwise: nothing. **Do NOT reference `zoomLens` — that element does not exist.**
|
||||
|
||||
**Test: Arrow keys navigate pages, v/r/e set flags, Escape exits edit mode, no errors thrown.**
|
||||
|
||||
---
|
||||
|
||||
## Step 11 — Clear all data
|
||||
|
||||
**Add to `p44-ocr-viewer.js` and `launch_viewer.py`.**
|
||||
|
||||
**JS — add `clearAllLocalData()`:**
|
||||
```javascript
|
||||
function clearAllLocalData() {
|
||||
if (!confirm(
|
||||
'Clear ALL saved data?\n\n' +
|
||||
'This will wipe:\n' +
|
||||
' • All Pass 1 and Pass 2 flags\n' +
|
||||
' • All text corrections\n' +
|
||||
' • Role setting\n\n' +
|
||||
'This cannot be undone.'
|
||||
)) return;
|
||||
|
||||
localStorage.removeItem('p44-corrections');
|
||||
localStorage.removeItem('p44-flags');
|
||||
localStorage.removeItem('p44-pass2-flags');
|
||||
localStorage.removeItem('p44-role');
|
||||
|
||||
state.corrections = {};
|
||||
state.flags = {};
|
||||
pass2Flags = {};
|
||||
|
||||
applyRole('pass1');
|
||||
updateFlagButtons(state.currentPage);
|
||||
updateFlagsSummary();
|
||||
updateCorrectionIndicator(state.currentPage);
|
||||
if (state.pdfDoc) showOCRPage(state.currentPage);
|
||||
|
||||
fetch('/clear-all-data', { method: 'POST' }).catch(() => {});
|
||||
setStatus('All saved data cleared — fresh start.');
|
||||
}
|
||||
```
|
||||
|
||||
Wire `#btn-clear-all` click → `clearAllLocalData()`
|
||||
|
||||
**`launch_viewer.py` — confirm `/clear-all-data` endpoint exists from Step 7. If not added yet, add it now.**
|
||||
|
||||
**Test: Click clear, confirm, all flags gone, all corrections gone, role reset, page re-renders as raw OCR, server files deleted.**
|
||||
|
||||
---
|
||||
|
||||
## Step 12 — Sample data
|
||||
|
||||
**Add to `p44-ocr-viewer.js` only.**
|
||||
|
||||
1. Add `SAMPLE_TEXT` constant (plain prose — no markdown)
|
||||
|
||||
2. Add `SAMPLE_JSON` constant (word bbox data)
|
||||
|
||||
3. `buildSampleImageDataURL()` — canvas-drawn sample page image
|
||||
|
||||
4. `loadSampleData()`:
|
||||
- Sets `state.mode = 'image'`
|
||||
- Draws sample image to canvas
|
||||
- Loads sample JSON word data
|
||||
- Calls `loadOCRText(SAMPLE_TEXT)`
|
||||
- Status: `'Sample data loaded.'` — **do not reference any removed features**
|
||||
|
||||
5. `#btn-example` click → `loadSampleData()`
|
||||
|
||||
**Test: Sample button loads image and OCR text, page displays correctly.**
|
||||
|
||||
---
|
||||
|
||||
## Step 13 — Final cleanup and launch_viewer.py review
|
||||
|
||||
**Review all three files against this checklist:**
|
||||
|
||||
**JS cleanup:**
|
||||
- [ ] No reference to `zoomLens` anywhere
|
||||
- [ ] No `contentEditable` set on `#text-output` anywhere
|
||||
- [ ] No `convertMarkdown()` or `inlineMd()` function
|
||||
- [ ] No `sanitizeCorrection()` function
|
||||
- [ ] No `stripHTML()` function
|
||||
- [ ] No word-level diff functions (`wordDiff`, `lcsWords`, `tokenizeWords`, `renderDiff`)
|
||||
- [ ] No diff legend DOM refs or event listeners
|
||||
- [ ] `state.scale` removed (it was dead — there is no scale dropdown)
|
||||
- [ ] `state.ocrRawFull` removed (dead — never read)
|
||||
- [ ] `p44_diff_legend_dismissed` NOT in `clearAllLocalData()` (key was never written)
|
||||
- [ ] localStorage keys are consistent: `p44-flags`, `p44-pass2-flags`, `p44-corrections`, `p44-role`
|
||||
|
||||
**launch_viewer.py review:**
|
||||
- [ ] `/save-flags` POST handler exists
|
||||
- [ ] `/save-corrections` POST handler exists
|
||||
- [ ] `/save-pass2-flags` POST handler exists
|
||||
- [ ] `/clear-all-data` POST handler exists
|
||||
- [ ] All handlers wrapped in try/except
|
||||
- [ ] `Cache-Control: no-store` headers present on all responses
|
||||
|
||||
**Final test — full workflow:**
|
||||
1. Load Calgary Highlanders
|
||||
2. Navigate to page 10
|
||||
3. Flag as Verified (Pass 1)
|
||||
4. Enter edit mode, make a correction, exit
|
||||
5. Refresh — correction and flag persist
|
||||
6. Switch to Pass 2
|
||||
7. Navigate to page 10 (only verified page)
|
||||
8. Change summary shows Pass 1 edits
|
||||
9. Make a Pass 2 correction
|
||||
10. Revert to original (type REVERT)
|
||||
11. Clear all data
|
||||
12. Refresh — everything is gone
|
||||
|
||||
---
|
||||
|
||||
*End of rebuild prompts.*
|
||||
162
prompts/ocr-viewer-redeisgn4.md
Normal file
162
prompts/ocr-viewer-redeisgn4.md
Normal file
@@ -0,0 +1,162 @@
|
||||
P44 OCR Viewer — Move original OCR toggle to left panel, replace raster with plain text
|
||||
Scope: p44-ocr-viewer.js, p44-ocr-viewer.css, p44-ocr-viewer.html. Remove horizontal split pane entirely, replace with left panel toggle.
|
||||
|
||||
PART 1 — HTML (p44-ocr-viewer.html)
|
||||
Remove the split button from the right panel header:
|
||||
html<button id="btn-split" class="tb-btn" title="Toggle split view — shows original OCR above for reference (Pass 2 only)" style="display:none;">⊟ Split</button>
|
||||
Add a toggle button to the left panel header, immediately after the zoom controls and before the page nav:
|
||||
html<button id="btn-ocr-toggle" class="tb-btn" title="Toggle between page scan and original OCR text" style="display:none;">📄 OCR</button>
|
||||
Remove these elements from #text-panel-body entirely:
|
||||
html<div id="split-pane" style="display:none;">
|
||||
<div id="split-pane-header">ORIGINAL OCR</div>
|
||||
<div id="split-ocr-wrap">
|
||||
<div id="split-line-numbers"></div>
|
||||
<div id="split-ocr-output"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="split-divider" style="display:none;"></div>
|
||||
Add a new OCR text overlay div inside #image-panel-body, immediately after the pdf-container div:
|
||||
html<div id="ocr-overlay-panel" style="display:none;"></div>
|
||||
|
||||
PART 2 — CSS (p44-ocr-viewer.css)
|
||||
Remove all rules for:
|
||||
|
||||
#split-pane
|
||||
#split-pane-header
|
||||
#split-ocr-wrap
|
||||
#split-line-numbers
|
||||
#split-ocr-output
|
||||
#split-divider
|
||||
#text-panel-body.split-active
|
||||
.split-active
|
||||
|
||||
Add at the end of the file:
|
||||
css/* ── OCR overlay panel (replaces raster in left panel) ───────── */
|
||||
#ocr-overlay-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
padding: 20px 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: var(--text);
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
z-index: 10;
|
||||
}
|
||||
Also ensure #image-panel-body has position: relative — add it to the existing rule if not already present:
|
||||
css#image-panel-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
PART 3 — JS (p44-ocr-viewer.js)
|
||||
3a — Remove all split pane code
|
||||
Remove these DOM refs:
|
||||
javascriptconst btnSplit = document.getElementById('btn-split');
|
||||
const splitPane = document.getElementById('split-pane');
|
||||
const splitDivider = document.getElementById('split-divider');
|
||||
const splitOcrOutput = document.getElementById('split-ocr-output');
|
||||
const splitLineNums = document.getElementById('split-line-numbers');
|
||||
Remove state.splitMode and state.splitRatio from the state object.
|
||||
Remove these functions entirely:
|
||||
|
||||
buildSplitLineNumbers()
|
||||
updateSplitLineNumbers()
|
||||
applySplitRatio()
|
||||
resetSplitSizing()
|
||||
startSplitResize()
|
||||
showSplitPane()
|
||||
hideSplitPane()
|
||||
toggleSplit()
|
||||
getWrapMeasureEl()
|
||||
buildWrappedLineNumbers()
|
||||
parseLineHeightPx()
|
||||
|
||||
Remove these event listeners:
|
||||
javascriptbtnSplit.addEventListener('click', toggleSplit);
|
||||
splitDivider.addEventListener('mousedown', startSplitResize);
|
||||
Remove from the resize event listener the split mode block:
|
||||
javascriptif (state.splitMode) {
|
||||
applySplitRatio(state.splitRatio || 0.5);
|
||||
const pageNum = state.currentPage;
|
||||
const corr = state.corrections && state.corrections[pageNum];
|
||||
const originalText = (corr && corr.original) || state.ocrPages[pageNum] || '';
|
||||
updateSplitLineNumbers(originalText);
|
||||
}
|
||||
Remove from goToPage():
|
||||
javascriptif (state.splitMode) showSplitPane(n);
|
||||
Remove from showOCRPage():
|
||||
javascriptif (state.splitMode) showSplitPane(pageNum);
|
||||
Remove from applyRole() all references to btnSplit, state.splitMode, hideSplitPane().
|
||||
3b — New DOM refs
|
||||
Add:
|
||||
javascriptconst btnOcrToggle = document.getElementById('btn-ocr-toggle');
|
||||
const ocrOverlayPanel = document.getElementById('ocr-overlay-panel');
|
||||
Add to state object:
|
||||
javascriptocrOverlay: false,
|
||||
3c — showOCROverlay(pageNum)
|
||||
Add this new function:
|
||||
javascriptfunction showOCROverlay(pageNum) {
|
||||
const corr = state.corrections && state.corrections[pageNum];
|
||||
const originalText = (corr && corr.original) || state.ocrPages[pageNum] || '';
|
||||
|
||||
ocrOverlayPanel.innerHTML =
|
||||
'<pre style="white-space:pre-wrap;font-family:inherit;font-size:13px;' +
|
||||
'line-height:1.8;color:var(--text);margin:0;">' +
|
||||
escH(originalText) + '</pre>';
|
||||
|
||||
ocrOverlayPanel.style.display = 'block';
|
||||
pdfContainer.style.display = 'none';
|
||||
imagePlaceholder.style.display = 'none';
|
||||
}
|
||||
3d — hideOCROverlay()
|
||||
Add this new function:
|
||||
javascriptfunction hideOCROverlay() {
|
||||
ocrOverlayPanel.style.display = 'none';
|
||||
if (state.mode === 'pdf') {
|
||||
pdfContainer.style.display = 'inline-block';
|
||||
} else {
|
||||
imagePlaceholder.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
3e — toggleOCROverlay()
|
||||
Add this new function:
|
||||
javascriptfunction toggleOCROverlay() {
|
||||
state.ocrOverlay = !state.ocrOverlay;
|
||||
btnOcrToggle.classList.toggle('active', state.ocrOverlay);
|
||||
|
||||
if (state.ocrOverlay) {
|
||||
showOCROverlay(state.currentPage);
|
||||
} else {
|
||||
hideOCROverlay();
|
||||
}
|
||||
}
|
||||
Add event listener:
|
||||
javascriptbtnOcrToggle.addEventListener('click', toggleOCROverlay);
|
||||
3f — goToPage()
|
||||
Add after if (state.editMode) exitEditMode();:
|
||||
javascript if (state.ocrOverlay) showOCROverlay(n);
|
||||
3g — showOCRPage()
|
||||
Add after updateCorrectionIndicator(pageNum):
|
||||
javascript if (state.ocrOverlay) showOCROverlay(pageNum);
|
||||
3h — applyRole()
|
||||
In the pass2 branch add:
|
||||
javascript btnOcrToggle.style.display = 'inline-block';
|
||||
In the pass1 branch add:
|
||||
javascript btnOcrToggle.style.display = 'none';
|
||||
state.ocrOverlay = false;
|
||||
hideOCROverlay();
|
||||
|
||||
VERIFICATION CHECKLIST:
|
||||
|
||||
Pass 1 role: OCR toggle button not visible in left panel header
|
||||
Pass 2 role: OCR toggle button visible in left panel header
|
||||
Clicking OCR toggle: raster scan disappears, plain text original OCR fills the left panel, button highlights active
|
||||
Clicking OCR toggle again: plain text disappears, raster scan returns
|
||||
Navigating to a new page while OCR overlay is on: overlay updates to new page's original OCR, stays visible
|
||||
Switching from Pass 2 to Pass 1 while overlay is on: overlay closes, scan returns
|
||||
No horizontal split pane anywhere in the UI
|
||||
Editor (right panel) unchanged — line numbers, textarea, all flags working as before
|
||||
207
prompts/ocr-viewer-redesign.md
Normal file
207
prompts/ocr-viewer-redesign.md
Normal file
@@ -0,0 +1,207 @@
|
||||
P44 OCR Viewer — Pass 1 Flag System Redesign
|
||||
Scope: p44-ocr-viewer.js, p44-ocr-viewer.css, p44-ocr-viewer.html. Make all three changes in sequence. Do not touch PDF rendering, zoom, sync scroll, Pass 2 logic, or the corrections storage model.
|
||||
|
||||
PART 1 — HTML (p44-ocr-viewer.html)
|
||||
In the right panel header, replace the five buttons:
|
||||
html<button id="btn-flag-verified" ...>✓ Verified</button>
|
||||
<button id="btn-flag-review" ...>⚑ Review</button>
|
||||
<button id="btn-flag-error" ...>✗ Error</button>
|
||||
<button id="btn-flag-clear" ...>Clear</button>
|
||||
<button id="btn-edit" ...>Edit</button>
|
||||
With these three buttons:
|
||||
html<button id="btn-flag-done" class="tb-btn flag-btn" title="Mark page as done — sends to Pass 2 queue. Cannot be undone.">✓ Done</button>
|
||||
<button id="btn-flag-problem" class="tb-btn flag-btn" title="Flag page as a problem — OCR too bad to fix. Still goes to Pass 2.">⚠ Problem</button>
|
||||
<button id="btn-edit" class="tb-btn" title="Toggle edit mode">Edit</button>
|
||||
Add a new read-only banner element inside #text-panel-body, immediately before #pass2-revert-bar:
|
||||
html<div id="done-banner" style="display:none;"></div>
|
||||
|
||||
PART 2 — CSS (p44-ocr-viewer.css)
|
||||
Remove the CSS rules for #btn-flag-verified, #btn-flag-review, #btn-flag-error if any exist by those IDs. The .flag-btn and .flag-btn.active rules stay — they apply to the new buttons too.
|
||||
Add these new rules at the end of the file:
|
||||
css/* ── Done-page read-only banner (Pass 1) ─────────────────────── */
|
||||
#done-banner {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 16px;
|
||||
background: #0f2a0f;
|
||||
border-bottom: 1px solid #2e6e2e;
|
||||
font-size: 10px;
|
||||
color: #5cb85c;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Problem flag indicator on page nav input ─────────────────── */
|
||||
#page-input.problem { border-color: #c08020; }
|
||||
|
||||
/* ── Greyed-out done state on the OCR text panel ─────────────── */
|
||||
#text-output.done-readonly {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
PART 3 — JS (p44-ocr-viewer.js)
|
||||
Make the following changes in order. Each change is isolated — do not touch any other function.
|
||||
3a — DOM refs
|
||||
Replace:
|
||||
javascriptconst 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');
|
||||
With:
|
||||
javascriptconst btnFlagDone = document.getElementById('btn-flag-done');
|
||||
const btnFlagProblem = document.getElementById('btn-flag-problem');
|
||||
3b — Flag label constants
|
||||
Replace the PASS2_BTN_LABELS and PASS1_BTN_LABELS constants with:
|
||||
javascriptconst PASS2_BTN_LABELS = { done: '✓ Pass 2 approved', problem: '⚠ Needs work' };
|
||||
const PASS1_BTN_LABELS = { done: '✓ Done', problem: '⚠ Problem' };
|
||||
3c — updateFlagButtons(pageNum)
|
||||
Replace the entire function body with:
|
||||
javascriptfunction updateFlagButtons(pageNum) {
|
||||
const flagSrc = currentRole === 'pass2' ? pass2Flags : state.flags;
|
||||
const flag = flagSrc[pageNum] || null;
|
||||
btnFlagDone.classList.toggle('active', flag === 'done');
|
||||
btnFlagProblem.classList.toggle('active', flag === 'problem');
|
||||
|
||||
// Done pages are read-only in Pass 1 — disable both flag buttons and Edit
|
||||
const isDone = currentRole === 'pass1' && flag === 'done';
|
||||
btnFlagDone.disabled = isDone;
|
||||
btnFlagProblem.disabled = isDone;
|
||||
btnEdit.disabled = isDone;
|
||||
btnEdit.style.opacity = isDone ? '0.35' : '';
|
||||
}
|
||||
3d — setFlag(type)
|
||||
Replace the entire function body with:
|
||||
javascriptfunction setFlag(type) {
|
||||
const p = state.currentPage;
|
||||
|
||||
if (currentRole === 'pass2') {
|
||||
if (type === null) { delete pass2Flags[p]; } else { pass2Flags[p] = type; }
|
||||
updateFlagButtons(p);
|
||||
savePass2Flags();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass 1 — Done is one-way: once set it cannot be cleared by the volunteer
|
||||
if (state.flags[p] === 'done') return;
|
||||
|
||||
// Problem is toggleable — clicking it again removes it
|
||||
if (type === 'problem' && state.flags[p] === 'problem') {
|
||||
delete state.flags[p];
|
||||
} else if (type === null) {
|
||||
delete state.flags[p];
|
||||
} else {
|
||||
state.flags[p] = type;
|
||||
}
|
||||
|
||||
updateFlagButtons(p);
|
||||
updateFlagsSummary();
|
||||
saveFlags();
|
||||
}
|
||||
3e — showOCRPage(pageNum)
|
||||
At the end of showOCRPage(), after the existing block that calls updateFlagButtons() and updateCorrectionIndicator(), add the following. Do not change any existing lines in showOCRPage() — only add this block at the end (before the closing brace), replacing the existing Pass 2 auto-edit-mode call:
|
||||
javascript // Pass 1 done-page read-only state
|
||||
const doneBanner = document.getElementById('done-banner');
|
||||
const isDonePage = currentRole === 'pass1' && state.flags[pageNum] === 'done';
|
||||
|
||||
if (isDonePage) {
|
||||
// Show the final corrected text (same display logic as normal, already rendered above)
|
||||
// Lock the panel visually
|
||||
textOutput.classList.add('done-readonly');
|
||||
doneBanner.innerHTML =
|
||||
'✓ <strong>Done</strong> — submitted to Pass 2 queue. This page is locked.';
|
||||
doneBanner.style.display = 'flex';
|
||||
// Ensure editor is closed and stays closed
|
||||
if (state.editMode) exitEditMode();
|
||||
} else {
|
||||
textOutput.classList.remove('done-readonly');
|
||||
doneBanner.style.display = 'none';
|
||||
}
|
||||
|
||||
// Pass 2 auto-edit mode (unchanged behaviour)
|
||||
if (currentRole === 'pass2') {
|
||||
if (!state.editMode) enterEditMode();
|
||||
updatePass2NavButtons();
|
||||
}
|
||||
Note: this block replaces the existing two lines at the bottom of showOCRPage() that read:
|
||||
javascript if (currentRole === 'pass2') {
|
||||
if (!state.editMode) enterEditMode();
|
||||
updatePass2NavButtons();
|
||||
}
|
||||
3f — applyRole()
|
||||
In applyRole(), replace the two label-setting lines for pass2 branch:
|
||||
javascript btnFlagVerified.textContent = PASS2_BTN_LABELS.verified;
|
||||
btnFlagReview.textContent = PASS2_BTN_LABELS.review;
|
||||
btnFlagError.textContent = PASS2_BTN_LABELS.error;
|
||||
With:
|
||||
javascript btnFlagDone.textContent = PASS2_BTN_LABELS.done;
|
||||
btnFlagProblem.textContent = PASS2_BTN_LABELS.problem;
|
||||
And replace the three label-setting lines for the pass1 branch:
|
||||
javascript btnFlagVerified.textContent = PASS1_BTN_LABELS.verified;
|
||||
btnFlagReview.textContent = PASS1_BTN_LABELS.review;
|
||||
btnFlagError.textContent = PASS1_BTN_LABELS.error;
|
||||
With:
|
||||
javascript btnFlagDone.textContent = PASS1_BTN_LABELS.done;
|
||||
btnFlagProblem.textContent = PASS1_BTN_LABELS.problem;
|
||||
3g — Event listeners
|
||||
Replace:
|
||||
javascriptbtnFlagVerified.addEventListener('click', () => setFlag('verified'));
|
||||
btnFlagReview.addEventListener('click', () => setFlag('review'));
|
||||
btnFlagError.addEventListener('click', () => setFlag('error'));
|
||||
btnFlagClear.addEventListener('click', () => setFlag(null));
|
||||
With:
|
||||
javascriptbtnFlagDone.addEventListener('click', () => setFlag('done'));
|
||||
btnFlagProblem.addEventListener('click', () => setFlag('problem'));
|
||||
3h — updateFlagsSummary()
|
||||
Replace the counting logic inside updateFlagsSummary():
|
||||
javascript 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
|
||||
: '';
|
||||
With:
|
||||
javascript let done = 0, problem = 0;
|
||||
Object.values(state.flags).forEach(f => {
|
||||
if (f === 'done') done++;
|
||||
else if (f === 'problem') problem++;
|
||||
});
|
||||
flagsSummary.textContent = done || problem
|
||||
? '✓ ' + done + (problem ? ' ⚠ ' + problem : '')
|
||||
: '';
|
||||
3i — Keyboard shortcut
|
||||
In the keydown handler, replace:
|
||||
javascript case 'v':
|
||||
setFlag('verified'); break;
|
||||
case 'r':
|
||||
setFlag('review'); break;
|
||||
case 'e':
|
||||
setFlag('error'); break;
|
||||
With:
|
||||
javascript case 'v':
|
||||
setFlag('done'); break;
|
||||
case 'p':
|
||||
setFlag('problem'); break;
|
||||
Update the footer kbd hint in p44-ocr-viewer.html from:
|
||||
html<span id="kbd-hint">← → navigate v/r/e flag esc close</span>
|
||||
To:
|
||||
html<span id="kbd-hint">← → navigate v done p problem esc close</span>
|
||||
3j — getVerifiedPages()
|
||||
Replace:
|
||||
javascript .filter(k => state.flags[k] === 'verified')
|
||||
With:
|
||||
javascript .filter(k => state.flags[k] === 'done')
|
||||
|
||||
VERIFICATION CHECKLIST — confirm each before closing the prompt:
|
||||
|
||||
Clicking ✓ Done on an unreviewed page: button highlights gold, page text goes grey, green Done banner appears, Edit button is disabled, cannot click Done or Problem again
|
||||
Clicking ⚠ Problem on an unreviewed page: button highlights gold, page remains editable
|
||||
Clicking ⚠ Problem again on a problem page: flag clears, button returns to inactive
|
||||
Navigating to a Done page while in Pass 1 role: banner shows, editor locked, text visible as final corrected version
|
||||
Switching to Pass 2 role: Done banner hidden, flag buttons show Pass 2 labels, auto-edit mode activates as before
|
||||
Footer summary shows correct counts using ✓ and ⚠ symbols
|
||||
Keyboard v sets Done, p sets/toggles Problem, no reference to old r or e keys anywhere
|
||||
409
prompts/ocr-viewer-redesign2.md
Normal file
409
prompts/ocr-viewer-redesign2.md
Normal file
@@ -0,0 +1,409 @@
|
||||
P44 OCR Viewer — Pass 2 Diff View + Edit Mode Reference Pane
|
||||
|
||||
Scope: p44-ocr-viewer.js, p44-ocr-viewer.css, p44-ocr-viewer.html. Do not touch PDF rendering, zoom, Pass 1 flag logic, or the corrections storage model.
|
||||
|
||||
BACKGROUND — how corrections are stored
|
||||
|
||||
Each page's corrections live in state.corrections[pageNum] with this shape:
|
||||
javascript
|
||||
|
||||
{
|
||||
original: "raw OCR text as loaded",
|
||||
pass1: "volunteer-corrected text",
|
||||
pass2: "senior editor text"
|
||||
}
|
||||
|
||||
The diff always compares original against pass1. Never use state.currentPage inside diff functions — always pass pageNum explicitly to avoid page-bleed bugs.
|
||||
|
||||
PART 1 — HTML (p44-ocr-viewer.html)
|
||||
|
||||
Inside #text-panel-body, replace:
|
||||
html
|
||||
|
||||
<div id="pass2-revert-bar" style="display:none;"></div>
|
||||
<div id="pass2-change-summary" style="display:none;"></div>
|
||||
<div id="text-output" style="display:none;"></div>
|
||||
<div id="editor-wrap" style="display:none;">
|
||||
<div id="line-numbers"></div>
|
||||
<textarea id="text-editor" aria-label="OCR text editor"></textarea>
|
||||
</div>
|
||||
|
||||
With:
|
||||
html
|
||||
|
||||
<div id="pass2-revert-bar" style="display:none;"></div>
|
||||
<div id="diff-toolbar" style="display:none;"></div>
|
||||
<div id="text-output" style="display:none;"></div>
|
||||
<div id="editor-wrap" style="display:none;">
|
||||
<div id="diff-reference" style="display:none;"></div>
|
||||
<div id="editor-bottom">
|
||||
<div id="line-numbers"></div>
|
||||
<textarea id="text-editor" aria-label="OCR text editor"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
In the right panel header, add a diff toggle button immediately before #btn-edit:
|
||||
html
|
||||
|
||||
<button id="btn-diff" class="tb-btn" title="Toggle diff view — shows Pass 1 changes highlighted">⇄ Diff</button>
|
||||
|
||||
Remove <div id="pass2-change-summary" style="display:none;"></div> entirely — it is replaced by the new diff system.
|
||||
|
||||
PART 2 — CSS (p44-ocr-viewer.css)
|
||||
|
||||
Add at the end of the file:
|
||||
css
|
||||
|
||||
/* ── Diff toolbar ─────────────────────────────────────────────── */
|
||||
#diff-toolbar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#diff-toolbar .diff-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
#diff-toolbar .diff-count {
|
||||
font-size: 10px;
|
||||
color: var(--gold-dim);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.diff-swatch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.diff-swatch-del {
|
||||
width: 24px; height: 10px;
|
||||
background: rgba(208,80,80,0.22);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.diff-swatch-ins {
|
||||
width: 24px; height: 10px;
|
||||
background: rgba(92,184,92,0.18);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Diff inline highlights ───────────────────────────────────── */
|
||||
.diff-del {
|
||||
background: rgba(208,80,80,0.22);
|
||||
color: #e07070;
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
text-decoration: line-through;
|
||||
font-size: 11px;
|
||||
}
|
||||
.diff-ins {
|
||||
background: rgba(92,184,92,0.18);
|
||||
color: #7ed87e;
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* ── Editor wrap — vertical split ─────────────────────────────── */
|
||||
#editor-wrap {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--gold-dim);
|
||||
}
|
||||
|
||||
/* ── Diff reference pane (top half of editor split) ───────────── */
|
||||
#diff-reference {
|
||||
display: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 20px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
white-space: pre-wrap;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
}
|
||||
#diff-reference-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold-dim);
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Editor bottom half ───────────────────────────────────────── */
|
||||
#editor-bottom {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
Also update the existing #editor-wrap rule — remove flex-direction and border-top from it since those are now set above. The existing rule in the file should become:
|
||||
css
|
||||
|
||||
#editor-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
The new rule added above replaces it fully — delete the old one to avoid conflict.
|
||||
|
||||
PART 3 — JS (p44-ocr-viewer.js)
|
||||
|
||||
3a — New state flag
|
||||
|
||||
Inside the state object, add:
|
||||
javascript
|
||||
|
||||
diffMode: false,
|
||||
|
||||
3b — DOM refs
|
||||
|
||||
Add after the existing editor DOM refs:
|
||||
javascript
|
||||
|
||||
const btnDiff = document.getElementById('btn-diff');
|
||||
const diffToolbar = document.getElementById('diff-toolbar');
|
||||
const diffReference = document.getElementById('diff-reference');
|
||||
const editorBottom = document.getElementById('editor-bottom');
|
||||
|
||||
3c — Word-level diff engine
|
||||
|
||||
Add this new function. Place it directly above buildChangeSummary():
|
||||
javascript
|
||||
|
||||
function buildInlineDiff(originalText, pass1Text) {
|
||||
const orig = (originalText || '').split(/(\s+)/);
|
||||
const edit = (pass1Text || '').split(/(\s+)/);
|
||||
const result = [];
|
||||
|
||||
let i = 0, j = 0;
|
||||
while (i < orig.length || j < edit.length) {
|
||||
if (i >= orig.length) {
|
||||
result.push({ type: 'ins', text: edit[j++] });
|
||||
} else if (j >= edit.length) {
|
||||
result.push({ type: 'del', text: orig[i++] });
|
||||
} else if (orig[i] === edit[j]) {
|
||||
result.push({ type: 'same', text: orig[i] });
|
||||
i++; j++;
|
||||
} else {
|
||||
result.push({ type: 'del', text: orig[i++] });
|
||||
result.push({ type: 'ins', text: edit[j++] });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
3d — Diff renderer
|
||||
|
||||
Add this new function directly below buildInlineDiff():
|
||||
javascript
|
||||
|
||||
function renderInlineDiff(pageNum) {
|
||||
const corr = state.corrections && state.corrections[pageNum];
|
||||
const originalText = (corr && corr.original) || state.ocrPages[pageNum] || '';
|
||||
const pass1Text = corr && corr.pass1;
|
||||
|
||||
if (!pass1Text) return null;
|
||||
|
||||
const tokens = buildInlineDiff(originalText, pass1Text);
|
||||
const delCount = tokens.filter(t => t.type === 'del' && t.text.trim()).length;
|
||||
const insCount = tokens.filter(t => t.type === 'ins' && t.text.trim()).length;
|
||||
const total = Math.max(delCount, insCount);
|
||||
|
||||
const html = tokens.map(t => {
|
||||
if (t.type === 'same') return escH(t.text);
|
||||
if (t.type === 'del') return '<span class="diff-del">' + escH(t.text) + '</span>';
|
||||
if (t.type === 'ins') return '<span class="diff-ins">' + escH(t.text) + '</span>';
|
||||
return '';
|
||||
}).join('');
|
||||
|
||||
return { html, total };
|
||||
}
|
||||
|
||||
3e — Diff toolbar renderer
|
||||
|
||||
Add this new function:
|
||||
javascript
|
||||
|
||||
function showDiffToolbar(pageNum) {
|
||||
const result = renderInlineDiff(pageNum);
|
||||
if (!result) {
|
||||
diffToolbar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
diffToolbar.innerHTML =
|
||||
'<div class="diff-legend">' +
|
||||
'<span class="diff-swatch"><span class="diff-swatch-del"></span> removed</span>' +
|
||||
'<span class="diff-swatch"><span class="diff-swatch-ins"></span> added</span>' +
|
||||
'</div>' +
|
||||
'<span class="diff-count">' + result.total + ' change' + (result.total === 1 ? '' : 's') + ' from Pass 1</span>';
|
||||
diffToolbar.style.display = 'flex';
|
||||
}
|
||||
|
||||
3f — Apply diff to display panel
|
||||
|
||||
Add this new function:
|
||||
javascript
|
||||
|
||||
function applyDiffToDisplay(pageNum) {
|
||||
if (!state.diffMode) return;
|
||||
const result = renderInlineDiff(pageNum);
|
||||
if (!result) return;
|
||||
textOutput.innerHTML =
|
||||
'<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;' +
|
||||
'line-height:1.75;color:var(--text);padding:16px 20px;">' +
|
||||
result.html + '</pre>';
|
||||
}
|
||||
|
||||
3g — Apply diff to reference pane (edit mode)
|
||||
|
||||
Add this new function:
|
||||
javascript
|
||||
|
||||
function applyDiffToReference(pageNum) {
|
||||
const result = renderInlineDiff(pageNum);
|
||||
if (!result || !state.diffMode) {
|
||||
diffReference.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
diffReference.innerHTML =
|
||||
'<div id="diff-reference-label">Pass 1 corrections — reference</div>' +
|
||||
'<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;' +
|
||||
'line-height:1.75;color:var(--text);margin:0;">' +
|
||||
result.html + '</pre>';
|
||||
diffReference.style.display = 'block';
|
||||
}
|
||||
|
||||
3h — Toggle diff mode
|
||||
|
||||
Add this new function:
|
||||
javascript
|
||||
|
||||
function toggleDiff(pageNum) {
|
||||
const corr = state.corrections && state.corrections[pageNum];
|
||||
const hasPass1 = corr && corr.pass1;
|
||||
|
||||
if (!hasPass1) {
|
||||
setStatus('No Pass 1 corrections on this page — nothing to diff.');
|
||||
return;
|
||||
}
|
||||
|
||||
state.diffMode = !state.diffMode;
|
||||
btnDiff.classList.toggle('active', state.diffMode);
|
||||
|
||||
if (state.editMode) {
|
||||
applyDiffToReference(pageNum);
|
||||
} else {
|
||||
if (state.diffMode) {
|
||||
applyDiffToDisplay(pageNum);
|
||||
showDiffToolbar(pageNum);
|
||||
} else {
|
||||
showOCRPage(pageNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Add the event listener immediately after the function:
|
||||
javascript
|
||||
|
||||
btnDiff.addEventListener('click', () => toggleDiff(state.currentPage));
|
||||
|
||||
3i — Update showOCRPage()
|
||||
|
||||
At the very end of showOCRPage(), after updateCorrectionIndicator(pageNum) and before the Pass 2 auto-edit block, add:
|
||||
javascript
|
||||
|
||||
// Show diff toolbar if page has Pass 1 corrections
|
||||
const corrForPage = state.corrections && state.corrections[pageNum];
|
||||
const hasPass1 = corrForPage && corrForPage.pass1;
|
||||
if (hasPass1) {
|
||||
showDiffToolbar(pageNum);
|
||||
} else {
|
||||
diffToolbar.style.display = 'none';
|
||||
state.diffMode = false;
|
||||
btnDiff.classList.remove('active');
|
||||
}
|
||||
|
||||
// Auto-enable diff in Pass 2 if Pass 1 corrections exist
|
||||
if (currentRole === 'pass2' && hasPass1 && !state.editMode) {
|
||||
state.diffMode = true;
|
||||
btnDiff.classList.add('active');
|
||||
applyDiffToDisplay(pageNum);
|
||||
}
|
||||
|
||||
Replace the existing Pass 2 auto-edit block at the bottom of showOCRPage():
|
||||
javascript
|
||||
|
||||
if (currentRole === 'pass2') {
|
||||
if (!state.editMode) enterEditMode();
|
||||
updatePass2NavButtons();
|
||||
}
|
||||
|
||||
With:
|
||||
javascript
|
||||
|
||||
if (currentRole === 'pass2') {
|
||||
updatePass2NavButtons();
|
||||
}
|
||||
|
||||
Note: Pass 2 no longer auto-enters edit mode on page load. The editor opens edit mode manually via the Edit button. This is intentional — the senior editor reviews the diff first, then decides to edit.
|
||||
|
||||
3j — Update enterEditMode()
|
||||
|
||||
At the end of enterEditMode(), after textEditor.focus(), add:
|
||||
javascript
|
||||
|
||||
applyDiffToReference(pageNum);
|
||||
|
||||
3k — Update exitEditMode()
|
||||
|
||||
At the start of exitEditMode(), before any existing logic, add:
|
||||
javascript
|
||||
|
||||
diffReference.style.display = 'none';
|
||||
|
||||
After the existing line textOutput.style.display = 'block';, add:
|
||||
javascript
|
||||
|
||||
if (state.diffMode) applyDiffToDisplay(pageNum);
|
||||
|
||||
3l — Update goToPage()
|
||||
|
||||
At the start of goToPage(), before if (state.editMode) exitEditMode();, add:
|
||||
javascript
|
||||
|
||||
state.diffMode = false;
|
||||
btnDiff.classList.remove('active');
|
||||
diffToolbar.style.display = 'none';
|
||||
diffReference.style.display = 'none';
|
||||
|
||||
This ensures diff state is always reset on navigation — prevents any possibility of page-bleed.
|
||||
|
||||
VERIFICATION CHECKLIST:
|
||||
|
||||
Pass 2 lands on a page with Pass 1 corrections: diff toolbar appears automatically, ⇄ Diff button is active, inline highlights visible
|
||||
Pass 2 lands on a page with no Pass 1 corrections: diff toolbar hidden, ⇄ Diff button inactive, clicking it shows status message
|
||||
Clicking ⇄ Diff toggles highlights on/off without changing page or losing position
|
||||
Clicking Edit while diff is on: reference pane appears above textarea showing highlighted diff, editor opens below
|
||||
Clicking Edit while diff is off: reference pane hidden, editor fills full height
|
||||
Toggling diff while in edit mode: reference pane appears/disappears above the open textarea
|
||||
Navigating to any new page: diff mode resets to off, no highlights carry over from previous page
|
||||
Pass 1 role: ⇄ Diff button present but only activates on pages with Pass 1 corrections
|
||||
231
prompts/ocr-viewer-redesign3.md
Normal file
231
prompts/ocr-viewer-redesign3.md
Normal file
@@ -0,0 +1,231 @@
|
||||
P44 OCR Viewer — Remove diff system, add Pass 2 split panel view
|
||||
Scope: p44-ocr-viewer.js, p44-ocr-viewer.css, p44-ocr-viewer.html. Complete removal of all diff logic, replace with a simple toggleable split panel for Pass 2.
|
||||
|
||||
PART 1 — Remove diff entirely
|
||||
HTML — remove these elements:
|
||||
|
||||
<button id="btn-diff" ...>⇄ Diff</button>
|
||||
<div id="diff-toolbar" ...></div>
|
||||
<div id="diff-reference" ...></div>
|
||||
<div id="editor-bottom"> wrapper — move its children (#line-numbers and #text-editor) back up directly inside #editor-wrap
|
||||
|
||||
CSS — remove all rules for:
|
||||
|
||||
#diff-toolbar
|
||||
#diff-toolbar .diff-legend
|
||||
#diff-toolbar .diff-count
|
||||
.diff-swatch
|
||||
.diff-swatch-del
|
||||
.diff-swatch-ins
|
||||
.diff-del
|
||||
.diff-ins
|
||||
#diff-reference
|
||||
#diff-reference-label
|
||||
#editor-bottom
|
||||
|
||||
JS — remove these functions entirely:
|
||||
|
||||
buildInlineDiff()
|
||||
renderInlineDiff()
|
||||
showDiffToolbar()
|
||||
applyDiffToDisplay()
|
||||
applyDiffToReference()
|
||||
toggleDiff()
|
||||
|
||||
JS — remove these DOM refs:
|
||||
|
||||
const btnDiff
|
||||
const diffToolbar
|
||||
const diffReference
|
||||
const editorBottom
|
||||
|
||||
JS — remove state.diffMode from the state object.
|
||||
JS — in showOCRPage() remove the entire block added for diff auto-enable and toolbar display — the block starting with const corrForPage = state.corrections && state.corrections[pageNum] down to the closing } of the Pass 2 auto-diff block. Restore the original Pass 2 block at the bottom of showOCRPage():
|
||||
javascript if (currentRole === 'pass2') {
|
||||
if (!state.editMode) enterEditMode();
|
||||
updatePass2NavButtons();
|
||||
}
|
||||
JS — in enterEditMode() remove the line applyDiffToReference(pageNum);
|
||||
JS — in exitEditMode() remove:
|
||||
|
||||
diffReference.style.display = 'none';
|
||||
if (state.diffMode) applyDiffToDisplay(pageNum);
|
||||
|
||||
JS — in goToPage() remove the four lines:
|
||||
javascript state.diffMode = false;
|
||||
btnDiff.classList.remove('active');
|
||||
diffToolbar.style.display = 'none';
|
||||
diffReference.style.display = 'none';
|
||||
|
||||
PART 2 — HTML (p44-ocr-viewer.html)
|
||||
In the right panel header, add a toggle button immediately before #btn-edit:
|
||||
html<button id="btn-split" class="tb-btn" title="Toggle split view — shows original OCR above for reference (Pass 2 only)" style="display:none;">⊟ Split</button>
|
||||
Inside #text-panel-body, immediately before #text-output, add the original OCR reference pane:
|
||||
html<div id="split-pane" style="display:none;">
|
||||
<div id="split-pane-header">ORIGINAL OCR</div>
|
||||
<div id="split-ocr-wrap">
|
||||
<div id="split-line-numbers"></div>
|
||||
<div id="split-ocr-output"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="split-divider" style="display:none;"></div>
|
||||
|
||||
PART 3 — CSS (p44-ocr-viewer.css)
|
||||
Add at the end of the file:
|
||||
css/* ── Split panel layout ───────────────────────────────────────── */
|
||||
#text-panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#split-pane {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#split-pane-header {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold-dim);
|
||||
padding: 5px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
#split-ocr-wrap {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
#split-line-numbers {
|
||||
width: 48px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px 0 12px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
line-height: 1.75;
|
||||
color: var(--gold-dim);
|
||||
font-family: 'Courier New', monospace;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border);
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
#split-ocr-output {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
#split-divider {
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
cursor: row-resize;
|
||||
border-top: 1px solid var(--gold-dim);
|
||||
border-bottom: 1px solid var(--gold-dim);
|
||||
}
|
||||
|
||||
/* When split is active, text-output and editor-wrap each take half */
|
||||
#text-panel-body.split-active #text-output,
|
||||
#text-panel-body.split-active #editor-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#text-panel-body.split-active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
PART 4 — JS (p44-ocr-viewer.js)
|
||||
4a — DOM refs
|
||||
Add:
|
||||
javascriptconst btnSplit = document.getElementById('btn-split');
|
||||
const splitPane = document.getElementById('split-pane');
|
||||
const splitDivider = document.getElementById('split-divider');
|
||||
const splitOcrOutput = document.getElementById('split-ocr-output');
|
||||
const splitLineNums = document.getElementById('split-line-numbers');
|
||||
4b — State flag
|
||||
Add to the state object:
|
||||
javascriptsplitMode: false,
|
||||
4c — Line number generator for split pane
|
||||
Add this new function above showOCRPage():
|
||||
javascriptfunction buildSplitLineNumbers(text) {
|
||||
const lines = (text || '').split('\n');
|
||||
return lines.map((_, i) => i + 1).join('\n');
|
||||
}
|
||||
4d — showSplitPane(pageNum)
|
||||
Add this new function:
|
||||
javascriptfunction showSplitPane(pageNum) {
|
||||
const corr = state.corrections && state.corrections[pageNum];
|
||||
const originalText = (corr && corr.original) || state.ocrPages[pageNum] || '';
|
||||
|
||||
splitOcrOutput.textContent = originalText;
|
||||
splitLineNums.textContent = buildSplitLineNumbers(originalText);
|
||||
|
||||
splitPane.style.display = 'flex';
|
||||
splitDivider.style.display = 'block';
|
||||
textPanelBody.classList.add('split-active');
|
||||
}
|
||||
4e — hideSplitPane()
|
||||
Add this new function:
|
||||
javascriptfunction hideSplitPane() {
|
||||
splitPane.style.display = 'none';
|
||||
splitDivider.style.display = 'none';
|
||||
textPanelBody.classList.remove('split-active');
|
||||
}
|
||||
4f — toggleSplit()
|
||||
Add this new function:
|
||||
javascriptfunction toggleSplit() {
|
||||
state.splitMode = !state.splitMode;
|
||||
btnSplit.classList.toggle('active', state.splitMode);
|
||||
|
||||
if (state.splitMode) {
|
||||
showSplitPane(state.currentPage);
|
||||
} else {
|
||||
hideSplitPane();
|
||||
}
|
||||
}
|
||||
Add the event listener immediately after:
|
||||
javascriptbtnSplit.addEventListener('click', toggleSplit);
|
||||
4g — applyRole()
|
||||
In applyRole(), in the pass2 branch add:
|
||||
javascript btnSplit.style.display = 'inline-block';
|
||||
In the pass1 branch add:
|
||||
javascript btnSplit.style.display = 'none';
|
||||
state.splitMode = false;
|
||||
hideSplitPane();
|
||||
4h — goToPage()
|
||||
At the start of goToPage(), after if (state.editMode) exitEditMode();, add:
|
||||
javascript if (state.splitMode) showSplitPane(n);
|
||||
This refreshes the split pane content for the new page without toggling it off.
|
||||
4i — showOCRPage()
|
||||
At the end of showOCRPage(), after updatePass2NavButtons(), add:
|
||||
javascript if (state.splitMode) showSplitPane(pageNum);
|
||||
|
||||
VERIFICATION CHECKLIST:
|
||||
|
||||
Pass 1 role: Split button not visible
|
||||
Pass 2 role: Split button visible in panel header
|
||||
Clicking Split in Pass 2: right panel splits into original OCR on top and editor on bottom, both with line numbers, both scroll independently
|
||||
Clicking Split again: collapses back to single panel, editor fills full height
|
||||
Navigating to a new page while split is on: both panes update to the new page's content, split stays on
|
||||
Switching from Pass 2 to Pass 1 while split is on: split closes, button hides
|
||||
Original OCR pane shows raw unedited text — not the Pass 1 corrected version
|
||||
Line numbers visible in both panes and match the text lines
|
||||
187
prompts/ocr-viewer-redesign5.md
Normal file
187
prompts/ocr-viewer-redesign5.md
Normal file
@@ -0,0 +1,187 @@
|
||||
PART 1 — HTML (p44-ocr-viewer.html)
|
||||
Remove the Edit button:
|
||||
html<button id="btn-edit" class="tb-btn" title="Toggle edit mode">Edit</button>
|
||||
Remove the correction dot (no longer needed without edit mode state):
|
||||
html<div id="correction-dot" title="This page has been edited"></div>
|
||||
Remove #text-output div — the textarea replaces it permanently:
|
||||
html<div id="text-output" style="display:none;"></div>
|
||||
The #editor-wrap should now be visible by default — remove style="display:none;" from it:
|
||||
html<div id="editor-wrap">
|
||||
<div id="line-numbers"></div>
|
||||
<textarea id="text-editor" aria-label="OCR text editor" wrap="soft"></textarea>
|
||||
</div>
|
||||
|
||||
PART 2 — CSS (p44-ocr-viewer.css)
|
||||
Remove rules for:
|
||||
|
||||
#correction-dot
|
||||
#text-output
|
||||
#text-output.done-readonly
|
||||
#btn-edit
|
||||
|
||||
Ensure #editor-wrap fills the panel body — update its rule to:
|
||||
css#editor-wrap {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
Ensure #text-panel-body is always a flex column:
|
||||
css#text-panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
PART 3 — JS (p44-ocr-viewer.js)
|
||||
3a — Remove DOM refs
|
||||
Remove:
|
||||
javascriptconst btnEdit = document.getElementById('btn-edit');
|
||||
const correctionDot = document.getElementById('correction-dot');
|
||||
const textOutput = document.getElementById('text-output');
|
||||
Remove from state object:
|
||||
javascripteditMode: false,
|
||||
3b — Remove enterEditMode() and exitEditMode() entirely
|
||||
Delete both functions completely.
|
||||
3c — Remove btnEdit event listener
|
||||
javascriptbtnEdit.addEventListener('click', () => {
|
||||
if (state.editMode) exitEditMode();
|
||||
else enterEditMode();
|
||||
});
|
||||
3d — Replace showOCRPage(pageNum)
|
||||
Replace the entire function body with:
|
||||
javascriptfunction showOCRPage(pageNum) {
|
||||
textEditor.dataset.page = pageNum;
|
||||
|
||||
if (!Object.keys(state.ocrPages).length) {
|
||||
textEditor.value = '';
|
||||
ocrSyncLabel.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const content = state.ocrPages[pageNum];
|
||||
if (!content) {
|
||||
textEditor.value = 'No OCR text for page ' + pageNum + '.';
|
||||
ocrSyncLabel.textContent = 'page ' + pageNum + ' — no data';
|
||||
updateLineNumbers();
|
||||
return;
|
||||
}
|
||||
|
||||
const corr = state.corrections && state.corrections[pageNum];
|
||||
const editText = corr
|
||||
? (currentRole === 'pass2'
|
||||
? (corr.pass2 || corr.pass1 || corr.original || content)
|
||||
: (corr.pass1 || corr.original || content))
|
||||
: content;
|
||||
|
||||
textEditor.value = editText;
|
||||
textEditor.scrollTop = 0;
|
||||
ocrSyncLabel.textContent = 'page ' + pageNum;
|
||||
|
||||
updateLineNumbers();
|
||||
updateFlagButtons(pageNum);
|
||||
updateCorrectionIndicator(pageNum);
|
||||
|
||||
// Snapshot original on first visit
|
||||
if (!state.corrections[pageNum] || !state.corrections[pageNum].original) {
|
||||
if (!state.corrections[pageNum]) state.corrections[pageNum] = {};
|
||||
state.corrections[pageNum].original = content.trim();
|
||||
}
|
||||
|
||||
// Pass 1 done-page lock
|
||||
const doneBanner = document.getElementById('done-banner');
|
||||
const isDonePage = currentRole === 'pass1' && state.flags[pageNum] === 'done';
|
||||
if (isDonePage) {
|
||||
textEditor.readOnly = true;
|
||||
textEditor.style.opacity = '0.55';
|
||||
doneBanner.innerHTML = '✓ <strong>Done</strong> — submitted to Pass 2 queue. This page is locked.';
|
||||
doneBanner.style.display = 'flex';
|
||||
} else {
|
||||
textEditor.readOnly = false;
|
||||
textEditor.style.opacity = '';
|
||||
doneBanner.style.display = 'none';
|
||||
}
|
||||
|
||||
if (currentRole === 'pass2') {
|
||||
updatePass2NavButtons();
|
||||
if (state.ocrOverlay) showOCROverlay(pageNum);
|
||||
showPass2RevertBar(pageNum);
|
||||
}
|
||||
}
|
||||
3e — Replace saveCurrentPage()
|
||||
This function replaces the save logic that was inside exitEditMode(). Add it as a new function:
|
||||
javascriptfunction saveCurrentPage() {
|
||||
const pageNum = parseInt(textEditor.dataset.page, 10);
|
||||
if (!pageNum) return;
|
||||
const editedText = textEditor.value.trim();
|
||||
const passKey = currentRole === 'pass2' ? 'pass2' : 'pass1';
|
||||
const originalText = (state.corrections[pageNum] && state.corrections[pageNum].original)
|
||||
|| (state.ocrPages[pageNum] || '').trim();
|
||||
|
||||
if (!state.corrections[pageNum]) state.corrections[pageNum] = {};
|
||||
state.corrections[pageNum].original = state.corrections[pageNum].original || originalText;
|
||||
|
||||
if (editedText && editedText !== originalText) {
|
||||
state.corrections[pageNum][passKey] = editedText;
|
||||
} else {
|
||||
delete state.corrections[pageNum][passKey];
|
||||
}
|
||||
|
||||
if (!state.corrections[pageNum].pass1 && !state.corrections[pageNum].pass2) {
|
||||
delete state.corrections[pageNum];
|
||||
}
|
||||
|
||||
postCorrection(pageNum, state.corrections[pageNum] || null);
|
||||
updateCorrectionIndicator(pageNum);
|
||||
}
|
||||
3f — Update goToPage()
|
||||
Replace:
|
||||
javascriptfunction goToPage(n) {
|
||||
if (state.editMode) exitEditMode();
|
||||
if (state.ocrOverlay) showOCROverlay(n);
|
||||
...
|
||||
}
|
||||
With:
|
||||
javascriptfunction goToPage(n) {
|
||||
saveCurrentPage();
|
||||
if (state.ocrOverlay) showOCROverlay(n);
|
||||
...
|
||||
}
|
||||
3g — Update updateCorrectionIndicator()
|
||||
This function currently shows/hides correctionDot. Replace its entire body with:
|
||||
javascriptfunction updateCorrectionIndicator(pageNum) {
|
||||
const corr = state.corrections && state.corrections[pageNum];
|
||||
const hasEdit = corr && (corr.pass1 || corr.pass2);
|
||||
ocrSyncLabel.style.color = hasEdit ? 'var(--gold)' : 'var(--gold-dim)';
|
||||
}
|
||||
3h — Update updateFlagButtons(pageNum)
|
||||
Remove all references to btnEdit — specifically remove:
|
||||
javascript btnEdit.disabled = isDone;
|
||||
btnEdit.style.opacity = isDone ? '0.35' : '';
|
||||
3i — Keyboard handler
|
||||
Remove from the keydown handler:
|
||||
javascript if (state.editMode) return;
|
||||
Replace with — skip shortcuts only when typing in the page number input:
|
||||
javascript const tag = document.activeElement && document.activeElement.tagName;
|
||||
if (tag === 'INPUT' && document.activeElement.id === 'page-input') return;
|
||||
if (tag === 'TEXTAREA') return;
|
||||
Remove from the keydown handler:
|
||||
javascript case 'Escape':
|
||||
if (state.editMode) { exitEditMode(); }
|
||||
break;
|
||||
3j — Remove showPass2ChangeSummary()
|
||||
This was called from enterEditMode() which no longer exists. Remove the call if it appears anywhere else. The function itself can stay for now as it does no harm, but it should not be called automatically.
|
||||
|
||||
VERIFICATION CHECKLIST:
|
||||
|
||||
Page loads in Pass 1: textarea immediately visible and editable, no Edit button
|
||||
Page loads in Pass 2: textarea immediately visible and editable, no Edit button
|
||||
Typing in textarea and navigating away: changes saved automatically via saveCurrentPage()
|
||||
Pass 1 Done page: textarea is readOnly, visually dimmed, done banner shows
|
||||
Pass 1 Done page: navigating away and back still shows locked state
|
||||
Line numbers update as user types
|
||||
OCR toggle (Pass 2): left panel shows original OCR, right panel textarea unaffected
|
||||
No references to state.editMode, enterEditMode, exitEditMode, btnEdit anywhere in the file
|
||||
Revert bar still works in Pass 2 — reverts load text into textarea directly
|
||||
298
scripts/ocr_confidence.py
Normal file
298
scripts/ocr_confidence.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
ocr_confidence.py
|
||||
-----------------
|
||||
Reads an existing olmOCR .md output file and scores each page for transcription
|
||||
confidence using the DeepInfra API. Does NOT re-run OCR.
|
||||
|
||||
Requirements:
|
||||
pip install requests
|
||||
|
||||
Usage:
|
||||
python ocr_confidence.py --api_key "YOUR_KEY"
|
||||
python ocr_confidence.py --api_key "YOUR_KEY" --input "path/to/file.md" --output "path/to/out.json"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load .env from project root (if present) — no external dependencies needed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
import os
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
_load_dotenv()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration — mirrors ocr_wardiaries.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEEPINFRA_API_URL = "https://api.deepinfra.com/v1/openai/chat/completions"
|
||||
# olmOCR is a vision model — use a chat LLM for text-based confidence review
|
||||
MODEL = "google/gemma-3-27b-it"
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 5 # seconds between retries
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_INPUT = _PROJECT_ROOT / "Inputs" / "ocr-output" / "Calgary-Highlanders_War-Diary_Sep44_olmocr.md"
|
||||
DEFAULT_OUTPUT = _PROJECT_ROOT / "Inputs" / "ocr-output" / "Calgary-Highlanders_War-Diary_Sep44_confidence.json"
|
||||
|
||||
CONFIDENCE_PROMPT = (
|
||||
"You are reviewing an OCR transcription of a WWII war diary. Your job is to identify words that may have been misread by the OCR scanner — not to correct the soldier's original spelling or interpret abbreviations. Rate OCR accuracy only. Do not suggest what words "should" be. Respond in JSON only:
|
||||
{"score": 7, "uncertain_words": ["Loon", "Fme"], "notes": "Possible OCR misread in line 3"}
|
||||
)
|
||||
|
||||
ERROR_RESULT = {"score": 0, "uncertain_words": [], "notes": "API error"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse olmOCR markdown into {page_num: text} dict
|
||||
# Same logic as parseOCRByPage() in p44-ocr-viewer.html
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_ocr_by_page(raw_text: str) -> dict[int, str]:
|
||||
"""Split olmOCR markdown on '## Page N' headings into a page-keyed dict."""
|
||||
pages: dict[int, str] = {}
|
||||
lines = raw_text.split("\n")
|
||||
page_num: int | None = None
|
||||
buf: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
m_head = _PAGE_HEADING.match(line)
|
||||
if m_head:
|
||||
if page_num is not None:
|
||||
pages[page_num] = "\n".join(buf)
|
||||
page_num = int(m_head.group(1))
|
||||
buf = [line] # keep the heading at the top
|
||||
elif page_num is not None:
|
||||
buf.append(line)
|
||||
|
||||
if page_num is not None:
|
||||
pages[page_num] = "\n".join(buf)
|
||||
|
||||
return pages
|
||||
|
||||
|
||||
import re as _re
|
||||
_PAGE_HEADING = _re.compile(r"^## Page (\d+)\s*$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def score_page(page_text: str, page_num: int, api_key: str) -> dict:
|
||||
"""
|
||||
Send one page's OCR text to DeepInfra for confidence scoring.
|
||||
Returns a dict with keys: score, uncertain_words, notes.
|
||||
On failure returns ERROR_RESULT.
|
||||
"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
payload = {
|
||||
"model": MODEL,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"{CONFIDENCE_PROMPT}\n\n"
|
||||
f"--- BEGIN OCR TEXT (page {page_num}) ---\n"
|
||||
f"{page_text}\n"
|
||||
f"--- END OCR TEXT ---"
|
||||
),
|
||||
}
|
||||
],
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
response = requests.post(
|
||||
DEEPINFRA_API_URL, headers=headers, json=payload, timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
raw_content = response.json()["choices"][0]["message"]["content"].strip()
|
||||
|
||||
# Strip markdown code fences if the model wrapped the JSON
|
||||
if raw_content.startswith("```"):
|
||||
raw_content = _re.sub(r"^```[a-z]*\n?", "", raw_content)
|
||||
raw_content = _re.sub(r"\n?```$", "", raw_content)
|
||||
|
||||
result = json.loads(raw_content)
|
||||
|
||||
# Validate expected keys; fill missing ones with defaults
|
||||
score = int(result.get("score", 0))
|
||||
uncertain_words = result.get("uncertain_words", [])
|
||||
notes = result.get("notes", "")
|
||||
|
||||
if not isinstance(uncertain_words, list):
|
||||
uncertain_words = []
|
||||
|
||||
return {"score": score, "uncertain_words": uncertain_words, "notes": notes}
|
||||
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
warnings.warn(f"Page {page_num}: HTTP error on attempt {attempt}/{MAX_RETRIES}: {exc}")
|
||||
if attempt < MAX_RETRIES:
|
||||
time.sleep(RETRY_DELAY)
|
||||
|
||||
except json.JSONDecodeError as exc:
|
||||
warnings.warn(
|
||||
f"Page {page_num}: Malformed JSON from API on attempt {attempt}/{MAX_RETRIES}: {exc}"
|
||||
)
|
||||
if attempt < MAX_RETRIES:
|
||||
time.sleep(RETRY_DELAY)
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.warn(f"Page {page_num}: Unexpected error on attempt {attempt}/{MAX_RETRIES}: {exc}")
|
||||
if attempt < MAX_RETRIES:
|
||||
time.sleep(RETRY_DELAY)
|
||||
|
||||
warnings.warn(f"Page {page_num}: All {MAX_RETRIES} attempts failed — storing error result.")
|
||||
return dict(ERROR_RESULT) # return a fresh copy
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_summary(results: dict) -> None:
|
||||
high = sum(1 for v in results.values() if v["score"] >= 8)
|
||||
med = sum(1 for v in results.values() if 5 <= v["score"] <= 7)
|
||||
low = sum(1 for v in results.values() if 1 <= v["score"] <= 4)
|
||||
err = sum(1 for v in results.values() if v["score"] == 0)
|
||||
|
||||
total = len(results)
|
||||
print("\n" + "=" * 50)
|
||||
print(f"CONFIDENCE SCORING COMPLETE — {total} pages scored")
|
||||
print("=" * 50)
|
||||
print(f" High (8-10) : {high:>4} ({high/total*100:.1f}%)" if total else " High (8-10) : 0")
|
||||
print(f" Medium (5-7) : {med:>4} ({med/total*100:.1f}%)" if total else " Medium (5-7) : 0")
|
||||
print(f" Low (1-4) : {low:>4} ({low/total*100:.1f}%)" if total else " Low (1-4) : 0")
|
||||
if err:
|
||||
print(f" API errors : {err:>4}")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Score OCR confidence for each page of an olmOCR .md output file."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api_key", default=None,
|
||||
help="DeepInfra API key (defaults to DEEPINFRA_API_KEY env var)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input", default=str(DEFAULT_INPUT),
|
||||
help=f"Path to olmOCR .md file. Default: {DEFAULT_INPUT}"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", default=str(DEFAULT_OUTPUT),
|
||||
help=f"Path to write confidence JSON. Default: {DEFAULT_OUTPUT}"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay", type=float, default=0.5,
|
||||
help="Seconds to pause between API calls (default: 0.5)."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
import os
|
||||
api_key = args.api_key or os.environ.get("DEEPINFRA_API_KEY", "")
|
||||
if not api_key:
|
||||
print("ERROR: No API key provided. Use --api_key or set DEEPINFRA_API_KEY in .env", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
input_path = Path(args.input)
|
||||
output_path = Path(args.output)
|
||||
|
||||
# ── Read source file ──────────────────────────────────────────────────────
|
||||
if not input_path.exists():
|
||||
print(f"ERROR: Input file not found: {input_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading OCR source: {input_path}")
|
||||
raw_text = input_path.read_text(encoding="utf-8")
|
||||
|
||||
pages = parse_ocr_by_page(raw_text)
|
||||
if not pages:
|
||||
print("ERROR: No '## Page N' headings found in the input file.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
total_pages = len(pages)
|
||||
sorted_pages = sorted(pages.keys())
|
||||
print(f"Found {total_pages} pages (Page {sorted_pages[0]} – {sorted_pages[-1]}).")
|
||||
|
||||
# ── Load existing results (resume support) ────────────────────────────────
|
||||
results: dict[str, dict] = {}
|
||||
if output_path.exists():
|
||||
try:
|
||||
results = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
already_done = len(results)
|
||||
print(f"Resuming — {already_done} page(s) already scored, skipping them.")
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
warnings.warn(f"Could not read existing output ({exc}); starting fresh.")
|
||||
results = {}
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── Score each page ───────────────────────────────────────────────────────
|
||||
scored_this_run = 0
|
||||
for page_num in sorted_pages:
|
||||
page_key = str(page_num)
|
||||
|
||||
if page_key in results:
|
||||
continue # already scored — skip
|
||||
|
||||
page_text = pages[page_num]
|
||||
result = score_page(page_text, page_num, api_key)
|
||||
results[page_key] = result
|
||||
scored_this_run += 1
|
||||
|
||||
# Progress line
|
||||
score_str = str(result["score"]) if result["score"] > 0 else "ERR"
|
||||
print(f" Page {page_num}/{sorted_pages[-1]} — score: {score_str}")
|
||||
|
||||
# Write after every page so a crash loses minimal work
|
||||
output_path.write_text(
|
||||
json.dumps(results, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
if scored_this_run > 0 and page_num != sorted_pages[-1]:
|
||||
time.sleep(args.delay)
|
||||
|
||||
# ── Final save & summary ──────────────────────────────────────────────────
|
||||
output_path.write_text(
|
||||
json.dumps(results, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8"
|
||||
)
|
||||
print(f"\nResults written to: {output_path}")
|
||||
print_summary(results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""
|
||||
ocr_wardiaries.py
|
||||
-----------------
|
||||
Converts war diary PDFs to Markdown using the olmOCR model hosted on DeepInfra.
|
||||
Converts war diary PDFs to plain text using the olmOCR model hosted on DeepInfra.
|
||||
Bypasses the olmOCR pipeline GPU check by calling the API directly.
|
||||
|
||||
Output format: plain text only — no markdown, no HTML, no tables.
|
||||
Each diary entry is transcribed as prose with a blank line between entries.
|
||||
|
||||
Requirements:
|
||||
pip install requests pillow
|
||||
|
||||
@@ -11,11 +14,11 @@ Poppler must be on PATH (pdftoppm command must work).
|
||||
|
||||
Usage:
|
||||
python ocr_wardiaries.py --input_dir "C:/path/to/pdfs" --output_dir "C:/path/to/output" --api_key "YOUR_KEY"
|
||||
python ocr_wardiaries.py --single "C:/path/to/diary.pdf" --output_dir "C:/path/to/output" --api_key "YOUR_KEY"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -31,31 +34,71 @@ from PIL import Image
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEEPINFRA_API_URL = "https://api.deepinfra.com/v1/openai/chat/completions"
|
||||
MODEL = "allenai/olmOCR-2-7B-1025"
|
||||
DPI = 150 # Higher = better quality but slower/more expensive. 150 is good for typewritten text.
|
||||
MAX_RETRIES = 3 # Retries per page on API error
|
||||
RETRY_DELAY = 5 # Seconds between retries
|
||||
MODEL = "allenai/olmOCR-2-7B-1025"
|
||||
DPI = 150 # Higher = better quality but slower/more expensive. 150 is good for typewritten text.
|
||||
MAX_RETRIES = 3 # Retries per page on API error
|
||||
RETRY_DELAY = 5 # Seconds between retries
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OCR prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plain text only — no markdown, no HTML.
|
||||
# War diary pages are tabular (Place / Date / Hour / Summary).
|
||||
# We transcribe each row as prose to avoid HTML table output
|
||||
# which causes downstream parsing problems.
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are transcribing a scanned WWII Canadian Army war diary page.
|
||||
Output plain text only. No markdown. No HTML. No tables. No formatting symbols.
|
||||
|
||||
Rules:
|
||||
- Transcribe ALL text visible on the page. Every word, every field, every stamp, every notation.
|
||||
- Do not decide what is important. Do not skip anything. Transcribe everything.
|
||||
- Do not correct spelling, grammar, or abbreviations. Transcribe exactly as written.
|
||||
- If the page has a table with columns (Place / Date / Hour / Summary), transcribe each row \
|
||||
as continuous prose in this format:
|
||||
[DATE] [PLACE]: [SUMMARY TEXT]
|
||||
Example: 4 Sep 44 France, NEUVILLE Les DIEPPE MR 2468 Sheet: The main topic for the morning...
|
||||
- Separate multiple diary entries with a single blank line.
|
||||
- Keep grid references exactly as written (e.g. MR 2468, GR 442891, MR 0675 Sheet).
|
||||
- Keep military abbreviations exactly as written (e.g. Bn, Bde, HQ, OR, C.O., R de Mais, RHC).
|
||||
- Weather notes go on their own line starting with: Weather:
|
||||
- Transcribe page headers, form titles, stamps, handwritten notes, signatures, \
|
||||
and margin notations — all of it.
|
||||
- If a page is genuinely blank with no text at all, output only: [NO DIARY CONTENT]
|
||||
- Do not add commentary, explanations, confidence notes, or summaries.
|
||||
- Do not output page numbers or headings.
|
||||
- Do not use asterisks, hashes, pipes, underscores, or any other formatting characters.\
|
||||
"""
|
||||
|
||||
USER_PROMPT = (
|
||||
"Transcribe every word on this war diary page exactly as written. "
|
||||
"Plain text only — include all text visible on the page, nothing omitted."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pdf_to_images(pdf_path: Path, output_dir: Path, dpi: int = DPI) -> list[Path]:
|
||||
def pdf_to_images(pdf_path: Path, output_dir: Path, dpi: int = DPI,
|
||||
first_page: int = None, last_page: int = None) -> list[Path]:
|
||||
"""Render each page of a PDF to a PNG image using pdftoppm (poppler)."""
|
||||
prefix = output_dir / pdf_path.stem
|
||||
cmd = [
|
||||
"pdftoppm",
|
||||
"-r", str(dpi),
|
||||
"-png",
|
||||
str(pdf_path),
|
||||
str(prefix),
|
||||
]
|
||||
if first_page is not None:
|
||||
cmd += ["-f", str(first_page)]
|
||||
if last_page is not None:
|
||||
cmd += ["-l", str(last_page)]
|
||||
cmd += [str(pdf_path), str(prefix)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"pdftoppm failed for {pdf_path.name}:\n{result.stderr}")
|
||||
|
||||
# pdftoppm names files like prefix-1.png, prefix-2.png etc (zero-padded)
|
||||
images = sorted(output_dir.glob(f"{pdf_path.stem}-*.png"))
|
||||
return images
|
||||
|
||||
@@ -67,7 +110,7 @@ def image_to_base64(image_path: Path) -> str:
|
||||
|
||||
|
||||
def ocr_page(image_path: Path, api_key: str, page_num: int) -> str:
|
||||
"""Send one page image to DeepInfra olmOCR and return the extracted text."""
|
||||
"""Send one page image to DeepInfra olmOCR and return the extracted plain text."""
|
||||
b64 = image_to_base64(image_path)
|
||||
|
||||
headers = {
|
||||
@@ -78,6 +121,10 @@ def ocr_page(image_path: Path, api_key: str, page_num: int) -> str:
|
||||
payload = {
|
||||
"model": MODEL,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
@@ -89,12 +136,7 @@ def ocr_page(image_path: Path, api_key: str, page_num: int) -> str:
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"Below is a scanned page from a Canadian Army WWII war diary or supplementary document, "
|
||||
"circa 1944-1945. Extract all text exactly as it appears, preserving layout, "
|
||||
"column structure, dates, grid references, unit names, and abbreviations. "
|
||||
"Output clean Markdown. Do not add commentary or summaries."
|
||||
),
|
||||
"text": USER_PROMPT,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -105,7 +147,12 @@ def ocr_page(image_path: Path, api_key: str, page_num: int) -> str:
|
||||
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
response = requests.post(DEEPINFRA_API_URL, headers=headers, json=payload, timeout=120)
|
||||
response = requests.post(
|
||||
DEEPINFRA_API_URL,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
@@ -123,8 +170,8 @@ def ocr_page(image_path: Path, api_key: str, page_num: int) -> str:
|
||||
return f"[OCR FAILED - page {page_num} - Error: {e}]"
|
||||
|
||||
|
||||
def ocr_pdf(pdf_path: Path, output_dir: Path, api_key: str) -> Path:
|
||||
"""OCR a single PDF and write output to a Markdown file."""
|
||||
def ocr_pdf(pdf_path: Path, output_dir: Path, api_key: str, max_pages: int = None) -> Path:
|
||||
"""OCR a single PDF and write output to a plain text file."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Processing: {pdf_path.name}")
|
||||
print(f"{'='*60}")
|
||||
@@ -133,7 +180,8 @@ def ocr_pdf(pdf_path: Path, output_dir: Path, api_key: str) -> Path:
|
||||
|
||||
# Skip if already done
|
||||
if output_md.exists():
|
||||
print(f" Already processed — skipping. Delete {output_md.name} to reprocess.")
|
||||
print(f" Already processed — skipping.")
|
||||
print(f" Delete {output_md.name} to reprocess.")
|
||||
return output_md
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
@@ -142,25 +190,33 @@ def ocr_pdf(pdf_path: Path, output_dir: Path, api_key: str) -> Path:
|
||||
# Render PDF pages to images
|
||||
print(f" Rendering PDF pages to images at {DPI} DPI...")
|
||||
try:
|
||||
images = pdf_to_images(pdf_path, tmp_path)
|
||||
images = pdf_to_images(pdf_path, tmp_path, last_page=max_pages)
|
||||
except RuntimeError as e:
|
||||
print(f" ERROR: {e}")
|
||||
return output_md
|
||||
|
||||
total_pages = len(images)
|
||||
print(f" Found {total_pages} pages.")
|
||||
print(f" Found {total_pages} pages to process.")
|
||||
|
||||
# File header — plain text, no markdown
|
||||
all_text = [
|
||||
f"# {pdf_path.stem}\n",
|
||||
f"*OCR'd by olmOCR ({MODEL}) via DeepInfra*\n",
|
||||
f"*Source: {pdf_path.name} — {total_pages} pages*\n",
|
||||
"---\n",
|
||||
f"# {pdf_path.stem}",
|
||||
f"OCR by olmOCR ({MODEL}) via DeepInfra",
|
||||
f"Source: {pdf_path.name} — {len(images)} page(s) processed",
|
||||
"",
|
||||
]
|
||||
|
||||
for i, image_path in enumerate(images, start=1):
|
||||
print(f" Page {i}/{total_pages}...", end=" ", flush=True)
|
||||
page_text = ocr_page(image_path, api_key, i)
|
||||
all_text.append(f"\n---\n## Page {i}\n\n{page_text}\n")
|
||||
|
||||
# Each page is separated by a ## Page N marker so the viewer
|
||||
# can split pages correctly. The content itself is plain text.
|
||||
all_text.append(f"## Page {i}")
|
||||
all_text.append("")
|
||||
all_text.append(page_text.strip())
|
||||
all_text.append("")
|
||||
|
||||
print("done")
|
||||
|
||||
# Small delay to avoid hammering the API
|
||||
@@ -181,40 +237,68 @@ def ocr_pdf(pdf_path: Path, output_dir: Path, api_key: str) -> Path:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OCR war diary PDFs using olmOCR via DeepInfra.")
|
||||
parser.add_argument("--input_dir", required=True, help="Folder containing PDF files to process.")
|
||||
parser.add_argument("--output_dir", required=True, help="Folder to write Markdown output files.")
|
||||
parser.add_argument("--api_key", required=True, help="Your DeepInfra API key.")
|
||||
parser.add_argument("--single", default=None, help="Process a single PDF file instead of a folder.")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="OCR war diary PDFs to plain text using olmOCR via DeepInfra."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input_dir",
|
||||
default=None,
|
||||
help="Folder containing PDF files to process.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output_dir",
|
||||
required=True,
|
||||
help="Folder to write output files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api_key",
|
||||
required=True,
|
||||
help="Your DeepInfra API key.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--single",
|
||||
default=None,
|
||||
help="Process a single PDF file instead of a whole folder.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max_pages",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Only process the first N pages of each PDF (useful for testing).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
input_dir = Path(args.input_dir)
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.single:
|
||||
pdfs = [Path(args.single)]
|
||||
elif args.input_dir:
|
||||
pdfs = sorted(Path(args.input_dir).glob("*.pdf"))
|
||||
else:
|
||||
pdfs = sorted(input_dir.glob("*.pdf"))
|
||||
print("Error: provide --input_dir or --single")
|
||||
sys.exit(1)
|
||||
|
||||
if not pdfs:
|
||||
print(f"No PDF files found in {input_dir}")
|
||||
print(f"No PDF files found.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found {len(pdfs)} PDF(s) to process.")
|
||||
print(f"Output directory: {output_dir}")
|
||||
print(f"Model: {MODEL}")
|
||||
print(f"DPI: {DPI}")
|
||||
|
||||
results = []
|
||||
for pdf_path in pdfs:
|
||||
output_md = ocr_pdf(pdf_path, output_dir, args.api_key)
|
||||
output_md = ocr_pdf(pdf_path, output_dir, args.api_key, max_pages=args.max_pages)
|
||||
results.append(output_md)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Complete. {len(results)} file(s) processed.")
|
||||
print(f"Output files:")
|
||||
print("Output files:")
|
||||
for r in results:
|
||||
print(f" {r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
Reference in New Issue
Block a user