diff --git a/launch_viewer.py b/launch_viewer.py index 175831f..cae99a5 100644 --- a/launch_viewer.py +++ b/launch_viewer.py @@ -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)') diff --git a/p44-landing.html b/p44-landing.html new file mode 100644 index 0000000..d98aa0b --- /dev/null +++ b/p44-landing.html @@ -0,0 +1,337 @@ + + + + + +P44 OCR — War Diary Transcription + + + + +
+

⚜ P44 War Diary Transcription

+ Pass 1 — Volunteer +
+ +
+
War diaries available for transcription
+
+
+ + + + + + + + + diff --git a/p44-ocr-viewer.css b/p44-ocr-viewer.css new file mode 100644 index 0000000..302fc4d --- /dev/null +++ b/p44-ocr-viewer.css @@ -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; +} diff --git a/p44-ocr-viewer.html b/p44-ocr-viewer.html index ac7d1e9..2c3ad2e 100644 --- a/p44-ocr-viewer.html +++ b/p44-ocr-viewer.html @@ -3,400 +3,56 @@ -P44 OCR Viewer — Calgary Highlanders Sep 44 +P44 OCR Viewer - +
+ ← Diaries +

⚜ P44 OCR VIEWER

+
- -
- -
- PDF - -
-
- OCR - -
-
- JSON - + +
+ +
+ + + +
- + +
+
- SCALE - + +
- -
- + +
+ + +
+ Pass 2 mode · 0 pages ready for review + +
@@ -406,6 +62,9 @@ html, body {
Source Document — Page Scan + 100% + +
-
- - -
- War diary scan loads automatically.
- Run launch_viewer.py — or click the button above + War diary scan loads automatically.
+
@@ -437,22 +92,23 @@ html, body {
OCR Text Output -
- - - - - + +
- OCR transcript loads automatically.
- Run launch_viewer.py — or click the button above + OCR transcript loads automatically.
- + + + +
+
+ +
@@ -461,954 +117,16 @@ html, body { - + - - - - - - - - - - - - - - - - - diff --git a/p44-ocr-viewer.js b/p44-ocr-viewer.js new file mode 100644 index 0000000..2d2a8b9 --- /dev/null +++ b/p44-ocr-viewer.js @@ -0,0 +1,1261 @@ +'use strict'; + +// ── PDF.js worker ──────────────────────────────────────────────── +if (typeof pdfjsLib !== 'undefined') { + pdfjsLib.GlobalWorkerOptions.workerSrc = + 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; +} + +// ── Diary config ───────────────────────────────────────────────── +const DIARY_CONFIG = { + 'calgary-highlanders': { + pdfPath: './Inputs/Calgary-Highlanders_War-Diary_Sep44.pdf', + textPath: './Inputs/ocr-output/Calgary-Highlanders_War-Diary_Sep44_olmocr.md', + }, + 'black-watch': { + pdfPath: './Inputs/RHC-Blackwatch_War-Diary_Sep44.pdf', + textPath: './Inputs/ocr-output/RHC-Blackwatch_War-Diary_Sep44_olmocr.md', + }, + '5cib': { + pdfPath: './Inputs/5CIB_War-Diary_Sep44.pdf', + textPath: './Inputs/ocr-output/5CIB_War-Diary_Sep44_olmocr.md', + }, +}; + +const diaryId = new URLSearchParams(window.location.search).get('diary') || 'calgary-highlanders'; +const _diary = DIARY_CONFIG[diaryId] || DIARY_CONFIG['calgary-highlanders']; + +// ── Data paths (served by launch_viewer.py) ─────────────────────── +const REAL_PDF_PATH = _diary.pdfPath; +const REAL_TEXT_PATH = _diary.textPath; + +// ── Per-diary storage keys / server endpoints ───────────────────── +const LS_FLAGS_KEY = 'p44-flags-' + diaryId; +const LS_PASS2_FLAGS_KEY = 'p44-pass2-flags-' + diaryId; +const LS_CORRECTIONS_KEY = 'p44-corrections-' + diaryId; + +const SERVER_FLAGS_FILE = 'flags-' + diaryId + '.json'; +const SERVER_PASS2_FLAGS_FILE = 'pass2_flags-' + diaryId + '.json'; +const SERVER_CORRECTIONS_FILE = 'corrections-' + diaryId + '.json'; +const SERVER_FLAGS_ENDPOINT = '/save-flags?diary=' + diaryId; +const SERVER_PASS2_ENDPOINT = '/save-pass2-flags?diary=' + diaryId; +const SERVER_CORRECTIONS_END = '/save-corrections?diary=' + diaryId; +const SERVER_CLEAR_ENDPOINT = '/clear-all-data?diary=' + diaryId; + +// ── Diary display names ─────────────────────────────────────────── +const DIARY_TITLES = { + 'calgary-highlanders': 'Calgary Highlanders — Sep 1944', + 'black-watch': 'Black Watch (RHR) — Sep 1944', + '5cib': '5th Canadian Infantry Brigade — Sep 1944', +}; + +// ───────────────────────────────────────────────────────────────── +// STATE +// ───────────────────────────────────────────────────────────────── +const state = { + mode: 'none', // 'pdf' | 'none' + pdfDoc: null, + currentPage: 1, + totalPages: 0, + zoomScale: 1.0, + wordData: [], + rendering: false, + ocrPages: {}, + lastRenderTime: 0, + flags: {}, + corrections: {}, + ocrOverlay: false, +}; + +// ───────────────────────────────────────────────────────────────── +// DOM REFS +// ───────────────────────────────────────────────────────────────── +const inputPdf = document.getElementById('input-pdf'); +const inputText = document.getElementById('input-text'); +const inputJson = document.getElementById('input-json'); + +const imagePlaceholder = document.getElementById('image-placeholder'); +const imgSpinner = document.getElementById('img-spinner'); +const imgPhText = document.getElementById('img-ph-text'); +const pdfContainer = document.getElementById('pdf-container'); +const pdfCanvas = document.getElementById('pdf-canvas'); +const overlay = document.getElementById('overlay'); + +const textPlaceholder = document.getElementById('text-placeholder'); +const txtSpinner = document.getElementById('txt-spinner'); +const txtPhText = document.getElementById('txt-ph-text'); + +const btnPrev = document.getElementById('btn-prev'); +const btnNext = document.getElementById('btn-next'); +const pageInput = document.getElementById('page-input'); +const pageLabel = document.getElementById('page-label'); +const ocrSyncLabel = document.getElementById('ocr-sync-label'); +const statusMsg = document.getElementById('status-msg'); + +// Phase 2 — panel body refs (used for edit mode layout) +const imagePanelBody = document.getElementById('image-panel-body'); +const textPanelBody = document.getElementById('text-panel-body'); + +// Zoom indicator +const zoomLabel = document.getElementById('zoom-label'); +const btnZoomReset = document.getElementById('btn-zoom-reset'); + +// Phase 4 — flags +const btnFlagDone = document.getElementById('btn-flag-done'); +const btnFlagProblem = document.getElementById('btn-flag-problem'); +const flagsSummary = document.getElementById('flags-summary'); + +// Phase 5 — edit surface +const editorWrap = document.getElementById('editor-wrap'); +const lineNumbers = document.getElementById('line-numbers'); +const textEditor = document.getElementById('text-editor'); + +// Phase 6 — OCR overlay toggle (Pass 2) +const btnOcrToggle = document.getElementById('btn-ocr-toggle'); +const ocrOverlayPanel = document.getElementById('ocr-overlay-panel'); + +// ───────────────────────────────────────────────────────────────── +// OVERRIDE DROPDOWN +// ───────────────────────────────────────────────────────────────── +const btnOverride = document.getElementById('btn-override'); +const overrideMenu = document.getElementById('override-menu'); + +btnOverride.addEventListener('click', e => { + e.stopPropagation(); + overrideMenu.classList.toggle('open'); +}); + +// Close when a file input is activated (user picked a file or cancelled) +document.getElementById('input-pdf') .addEventListener('change', () => overrideMenu.classList.remove('open')); +document.getElementById('input-text').addEventListener('change', () => overrideMenu.classList.remove('open')); +document.getElementById('input-json').addEventListener('change', () => overrideMenu.classList.remove('open')); + +// Close when clicking anywhere outside the dropdown +document.addEventListener('click', () => overrideMenu.classList.remove('open')); + +// ───────────────────────────────────────────────────────────────── +// TOOLBAR BUTTON EVENTS +// ───────────────────────────────────────────────────────────────── +inputPdf.addEventListener('change', async e => { + const file = e.target.files[0]; if (!file) return; + setStatus('Loading PDF: ' + file.name + '…'); showImgSpinner(true); + const buf = await file.arrayBuffer(); + showImgSpinner(false); + await loadPDFBuffer(buf); + setStatus('PDF: ' + file.name + ' — ' + state.totalPages + ' pages'); +}); + +inputText.addEventListener('change', e => { + const file = e.target.files[0]; if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { loadOCRText(ev.target.result, true); setStatus('Text: ' + file.name); }; + reader.readAsText(file); +}); + +inputJson.addEventListener('change', e => { + const file = e.target.files[0]; if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + try { + loadWordDataJSON(JSON.parse(ev.target.result)); + setStatus('JSON: ' + file.name + ' — ' + state.wordData.length + ' words'); + } catch (err) { alert('JSON parse error: ' + err.message); } + }; + reader.readAsText(file); +}); + +btnPrev.addEventListener('click', () => { + if (currentRole === 'pass2') { + const verified = getVerifiedPages(); + if (verified.length) { + const prev = [...verified].reverse().find(p => p < state.currentPage); + if (prev !== undefined) goToPage(prev); + } else { + goToPage(state.currentPage - 1); // no queue yet — sequential fallback + } + } else { + goToPage(state.currentPage - 1); + } +}); +btnNext.addEventListener('click', () => { + if (currentRole === 'pass2') { + const verified = getVerifiedPages(); + if (verified.length) { + const next = verified.find(p => p > state.currentPage); + if (next !== undefined) goToPage(next); + } else { + goToPage(state.currentPage + 1); // no queue yet — sequential fallback + } + } else { + goToPage(state.currentPage + 1); + } +}); +pageInput.addEventListener('change', () => { + const n = parseInt(pageInput.value, 10); + if (!isNaN(n)) goToPage(n); +}); + +window.addEventListener('resize', () => { + if (state.mode === 'pdf' && state.pdfDoc && !state.rendering) renderCurrentPage(); + updateLineNumbers(); +}); + +// ── Auto-load on startup when served from localhost ─────────────── +window.addEventListener('DOMContentLoaded', () => { + // Set diary title in toolbar and browser tab + const diaryTitle = DIARY_TITLES[diaryId] || diaryId; + document.title = 'P44 OCR Viewer — ' + diaryTitle; + const diaryLabel = document.getElementById('diary-label'); + if (diaryLabel) diaryLabel.textContent = diaryTitle; + + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + setTimeout(loadWarDiary, 200); + } +}); + +// ───────────────────────────────────────────────────────────────── +// AUTO-LOAD: fetch PDF + OCR text from the Inputs folder +// ───────────────────────────────────────────────────────────────── +async function loadWarDiary() { + if (typeof pdfjsLib === 'undefined') { + setStatus('PDF.js not loaded — check internet connection, then refresh.', true); + return; + } + setStatus('Loading war diary…'); + showImgSpinner(true); showTxtSpinner(true); + + const [textRes, pdfRes] = await Promise.allSettled([ + fetch(REAL_TEXT_PATH).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }), + fetch(REAL_PDF_PATH ).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }), + ]); + + // OCR text + showTxtSpinner(false); + if (textRes.status === 'fulfilled') { + loadOCRText(textRes.value, true); + } else { + showTxtPlaceholder('Could not load OCR text: ' + textRes.reason + '.
Use the OCR Override picker above.'); + } + + // PDF + showImgSpinner(false); + if (pdfRes.status === 'fulfilled') { + await loadPDFBuffer(pdfRes.value); + setStatus( + state.totalPages + '-page PDF loaded' + + (textRes.status === 'fulfilled' ? ' + OCR text' : '') + + '. Use ◀▶ to navigate.' + ); + } else { + showImgPlaceholder('Could not load PDF: ' + pdfRes.reason + '.
Use the PDF Override picker above.'); + if (textRes.status === 'fulfilled') { + setStatus('OCR text loaded. PDF unavailable — use the PDF Override picker.', true); + } + } +} + +// ───────────────────────────────────────────────────────────────── +// PDF.js — load & render +// ───────────────────────────────────────────────────────────────── +async function loadPDFBuffer(arrayBuffer) { + try { + state.pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; + state.totalPages = state.pdfDoc.numPages; + state.mode = 'pdf'; + state.currentPage = 1; + state.wordData = []; // clear JSON sidecar — use PDF text layer + pageLabel.textContent = '/ ' + state.totalPages; + // Note: pageInput.value is set by renderCurrentPage() below. + // Setting it here would fire a change event → goToPage → duplicate render. + btnPrev.disabled = true; + btnNext.disabled = state.totalPages <= 1; + pdfContainer.style.display = 'inline-block'; + imagePlaceholder.style.display = 'none'; + await renderCurrentPage(); + } catch (err) { + showImgPlaceholder('Failed to open PDF: ' + err.message); + } +} + +async function renderCurrentPage() { + if (!state.pdfDoc || state.rendering) return; + // Debounce: ignore spurious re-render calls (e.g. from resize events) that + // fire within 100 ms of a render that just completed. + if (Date.now() - state.lastRenderTime < 100) return; + state.rendering = true; + try { + const page = await state.pdfDoc.getPage(state.currentPage); + + // Fit page to panel width, then apply zoomScale. Multiply by DPR for sharp HiDPI rendering. + const dpr = window.devicePixelRatio || 1; + const panelW = document.getElementById('image-panel-body').clientWidth || 700; + const baseVP = page.getViewport({ scale: 1 }); + const fitScale = panelW / baseVP.width; + const vp = page.getViewport({ scale: fitScale * state.zoomScale * dpr }); + + // Physical canvas size = DPR × CSS size → crisp on Retina/HiDPI displays + pdfCanvas.width = vp.width; + pdfCanvas.height = vp.height; + pdfCanvas.style.width = (vp.width / dpr) + 'px'; + pdfCanvas.style.height = (vp.height / dpr) + 'px'; + + await page.render({ canvasContext: pdfCanvas.getContext('2d'), viewport: vp }).promise; + await buildPDFWordOverlay(page, vp, dpr); + + pageInput.value = state.currentPage; + btnPrev.disabled = state.currentPage <= 1; + btnNext.disabled = state.currentPage >= state.totalPages; + + // Sync right panel + try { + showOCRPage(state.currentPage); + } catch (e) { + setStatus('OCR panel error on page ' + state.currentPage + ': ' + e.message, true); + console.error(e); + } + + state.lastRenderTime = Date.now(); // debounce: record when this render finished + _lastRenderedZoom = state.zoomScale; // keep transform delta in sync + } finally { + state.rendering = false; + // If the user navigated while we were rendering, honour the queued page + if (_pendingPage !== null) { + const next = _pendingPage; + _pendingPage = null; + if (next !== state.currentPage) { + state.currentPage = next; + renderCurrentPage(); + } + } + } +} + +// Build word-box overlay from PDF.js text content +async function buildPDFWordOverlay(page, vp, dpr) { + dpr = dpr || 1; + overlay.innerHTML = ''; + // Overlay must match CSS canvas size, not physical pixel size + overlay.style.width = (vp.width / dpr) + 'px'; + overlay.style.height = (vp.height / dpr) + 'px'; + + // JSON sidecar in PDF mode — coords are PDF points (bottom-left origin) + if (state.wordData.length) { + state.wordData.forEach(e => { + const [cx, cy] = vp.convertToViewportPoint(e.bbox.x, e.bbox.y + e.bbox.h); + const cw = e.bbox.w * vp.scale; + const ch = e.bbox.h * vp.scale; + overlay.appendChild(makeWordBox(e.id, cx/dpr, cy/dpr, cw/dpr, ch/dpr, e.word)); + }); + } + // No JSON sidecar — overlay intentionally left blank +} + +function makeWordBox(id, x, y, w, h, title) { + const div = document.createElement('div'); + div.className = 'word-box'; + div.dataset.wordId = id; + div.style.left = Math.round(x) + 'px'; + div.style.top = Math.round(y) + 'px'; + div.style.width = Math.max(4, Math.round(w)) + 'px'; + div.style.height = Math.max(4, Math.round(h)) + 'px'; + if (title) div.title = title; + div.addEventListener('mouseenter', () => { div.classList.add('highlight'); }); + div.addEventListener('mouseleave', () => { div.classList.remove('highlight'); }); + return div; +} + +let _pendingPage = null; // holds the last page requested while a render was in progress + +// ───────────────────────────────────────────────────────────────── +// OCR TEXT RENDERING +// ───────────────────────────────────────────────────────────────── +function loadOCRText(rawText, isHtmlMarkdown) { + textPlaceholder.style.display = 'none'; + + if (isHtmlMarkdown) { + // Parse the olmOCR markdown into a per-page dictionary so we can render + // exactly one page at a time — fast, accurate, and avoids the "highlight all" + // problem that comes from searching a giant single-document DOM. + state.ocrPages = parseOCRByPage(rawText); + // Do NOT call showOCRPage() here. If the PDF is already loaded, + // renderCurrentPage() will call showOCRPage() as part of its own flow. + // If the PDF isn't loaded yet (the common startup path where OCR arrives + // first), renderCurrentPage() will call showOCRPage() once it runs. + // Calling it here would build the OCR panel twice on every startup. + if (state.pdfDoc) { + showOCRPage(state.currentPage || 1); + } + // else: leave the panel blank — renderCurrentPage() will populate it. + } else { + // Plain-text / sample mode — load directly into editor + state.ocrPages = {}; + textEditor.value = rawText; + updateLineNumbers(); + } +} + +// ── Parse olmOCR markdown into per-page dict ────────────────────────────── +// Each "## Page N" heading starts a new page entry. Content between two +// headings (tables, prose, weather notes) is stored under its page number. +function parseOCRByPage(rawText) { + const pages = {}; + const lines = rawText.split('\n'); + let pageNum = null; + let buf = []; + + for (const line of lines) { + const m = line.match(/^## Page (\d+)\s*$/); + if (m) { + if (pageNum !== null) pages[pageNum] = buf.join('\n'); + pageNum = parseInt(m[1], 10); + buf = []; // do NOT include the ## Page N heading in the content + } else if (pageNum !== null) { + buf.push(line); + } + } + if (pageNum !== null) pages[pageNum] = buf.join('\n'); + return pages; +} + +function showOCROverlay(pageNum) { + const corr = state.corrections && state.corrections[pageNum]; + const originalText = (corr && corr.original) || state.ocrPages[pageNum] || ''; + + ocrOverlayPanel.innerHTML = + '
' +
+    escH(originalText) + '
'; + + ocrOverlayPanel.style.display = 'block'; + pdfContainer.style.display = 'none'; + imagePlaceholder.style.display = 'none'; +} + +function hideOCROverlay() { + ocrOverlayPanel.style.display = 'none'; + if (state.mode === 'pdf') { + pdfContainer.style.display = 'block'; + } else { + imagePlaceholder.style.display = 'flex'; + } +} + +function toggleOCROverlay() { + state.ocrOverlay = !state.ocrOverlay; + btnOcrToggle.classList.toggle('active', state.ocrOverlay); + + if (state.ocrOverlay) { + showOCROverlay(state.currentPage); + } else { + hideOCROverlay(); + } +} + +btnOcrToggle.addEventListener('click', toggleOCROverlay); + +// ── Render the OCR panel for a single page ──────────────────────────────── +function 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 = '✓ Done — 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); + showPass2ChangeSummary(pageNum); + } +} + +// ───────────────────────────────────────────────────────────────── +// WORD DATA (JSON SIDECAR) +// ───────────────────────────────────────────────────────────────── +function loadWordDataJSON(data) { + state.wordData = data; + + // PDF mode: re-render so buildPDFWordOverlay picks up the new boxes + if (state.mode === 'pdf' && state.pdfDoc) { + renderCurrentPage(); + return; + } + + // Image mode: re-render overlay & re-link text spans (unchanged behaviour) + if (state.mode === 'image' && pdfCanvas.width) { + const sx = pdfCanvas.width / state.imageNaturalW; + const sy = pdfCanvas.height / state.imageNaturalH; + overlay.innerHTML = ''; + overlay.style.width = pdfCanvas.width + 'px'; + overlay.style.height = pdfCanvas.height + 'px'; + data.forEach(e => overlay.appendChild( + makeWordBox(e.id, e.bbox.x*sx, e.bbox.y*sy, e.bbox.w*sx, e.bbox.h*sy, e.word) + )); + } +} + +// ───────────────────────────────────────────────────────────────── +// HELPERS +// ───────────────────────────────────────────────────────────────── +function escH(s) { return s.replace(/&/g,'&').replace(//g,'>'); } +function showImgSpinner(on) { + imgSpinner.style.display = on ? 'inline-block' : 'none'; + imgPhText.style.display = on ? 'none' : 'block'; + imagePlaceholder.style.display = 'flex'; + if (on) pdfContainer.style.display = 'none'; +} +function showTxtSpinner(on) { + txtSpinner.style.display = on ? 'inline-block' : 'none'; + txtPhText.style.display = on ? 'none' : 'block'; + textPlaceholder.style.display = 'flex'; +} +function showImgPlaceholder(html) { + imgPhText.innerHTML = html; + imagePlaceholder.style.display = 'flex'; + pdfContainer.style.display = 'none'; +} +function showTxtPlaceholder(html) { + txtPhText.innerHTML = html; + textPlaceholder.style.display = 'flex'; +} +function setStatus(msg, warn) { + statusMsg.textContent = msg; + statusMsg.style.color = warn ? '#e07070' : 'var(--text-dim)'; +} + +// ───────────────────────────────────────────────────────────────── +// PHASE 3 — SCROLL ZOOM ON LEFT PANEL +// ───────────────────────────────────────────────────────────────── +const ZOOM_SCALE_MIN = 0.5; +const ZOOM_SCALE_MAX = 4.0; +const ZOOM_SCALE_STEP = 0.1; + +// Zoom state — separate "last rendered" zoom from "desired" zoom so we can +// use CSS transform for instant feedback without triggering a PDF re-render +// on every wheel tick. +let _lastRenderedZoom = 1.0; +let _zoomSettleTimer = null; +let _zoomAnchorFracX = 0.5; +let _zoomAnchorFracY = 0.5; +let _zoomAnchorClientX = 0; +let _zoomAnchorClientY = 0; + +function updateZoomLabel() { + const pct = Math.round(state.zoomScale * 100); + zoomLabel.textContent = pct + '%'; + btnZoomReset.style.opacity = state.zoomScale === 1.0 ? '0.4' : '1'; +} + +imagePanelBody.addEventListener('wheel', e => { + if (state.mode === 'none') return; + if (state.ocrOverlay) return; // overlay is showing — let scroll pass through normally + e.preventDefault(); + + const delta = e.deltaY < 0 ? ZOOM_SCALE_STEP : -ZOOM_SCALE_STEP; + const newZoom = Math.round( + Math.max(ZOOM_SCALE_MIN, Math.min(ZOOM_SCALE_MAX, state.zoomScale + delta)) * 10 + ) / 10; + + if (newZoom === state.zoomScale) return; + + // Capture cursor anchor once at the start of each new gesture (when timer isn't running) + if (_zoomSettleTimer === null) { + const rect = imagePanelBody.getBoundingClientRect(); + const cssW = parseFloat(pdfCanvas.style.width) || pdfCanvas.clientWidth || 700; + const cssH = parseFloat(pdfCanvas.style.height) || pdfCanvas.clientHeight || 900; + _zoomAnchorClientX = e.clientX - rect.left; + _zoomAnchorClientY = e.clientY - rect.top; + _zoomAnchorFracX = (_zoomAnchorClientX + imagePanelBody.scrollLeft) / cssW; + _zoomAnchorFracY = (_zoomAnchorClientY + imagePanelBody.scrollTop) / cssH; + } + + state.zoomScale = newZoom; + updateZoomLabel(); + + // Instant visual feedback via CSS transform — GPU-composited, zero layout impact, + // zero flicker. Scale relative to the last full render so the factor is exact. + const sf = state.zoomScale / _lastRenderedZoom; + const ox = _zoomAnchorFracX * 100; + const oy = _zoomAnchorFracY * 100; + pdfCanvas.style.transformOrigin = `${ox}% ${oy}%`; + pdfCanvas.style.transform = `scale(${sf})`; + overlay.style.transformOrigin = `${ox}% ${oy}%`; + overlay.style.transform = `scale(${sf})`; + + // After the scroll gesture settles, do ONE proper HiDPI re-render + clearTimeout(_zoomSettleTimer); + _zoomSettleTimer = setTimeout(async () => { + _zoomSettleTimer = null; + + // Clear the transform before re-render to avoid double-scaling + pdfCanvas.style.transform = ''; + pdfCanvas.style.transformOrigin = ''; + overlay.style.transform = ''; + overlay.style.transformOrigin = ''; + + state.lastRenderTime = 0; + + if (state.pdfDoc) { + await renderCurrentPage(); + } + + _lastRenderedZoom = state.zoomScale; + + // Scroll so the anchor point stays under the cursor + const newCSSW = parseFloat(pdfCanvas.style.width) || pdfCanvas.clientWidth; + const newCSSH = parseFloat(pdfCanvas.style.height) || pdfCanvas.clientHeight; + imagePanelBody.scrollLeft = _zoomAnchorFracX * newCSSW - _zoomAnchorClientX; + imagePanelBody.scrollTop = _zoomAnchorFracY * newCSSH - _zoomAnchorClientY; + }, 150); +}, { passive: false }); + +btnZoomReset.addEventListener('click', async () => { + if (state.zoomScale === 1.0) return; + // Cancel any pending settle timer + clearTimeout(_zoomSettleTimer); + _zoomSettleTimer = null; + // Clear any in-progress CSS transform + pdfCanvas.style.transform = ''; + pdfCanvas.style.transformOrigin = ''; + overlay.style.transform = ''; + overlay.style.transformOrigin = ''; + state.zoomScale = 1.0; + updateZoomLabel(); + state.lastRenderTime = 0; + await renderCurrentPage(); + _lastRenderedZoom = 1.0; + imagePanelBody.scrollLeft = 0; + imagePanelBody.scrollTop = 0; +}); + +// ───────────────────────────────────────────────────────────────── +// ROLE SWITCHER — PASS 1 / PASS 2 +// ───────────────────────────────────────────────────────────────── +const roleSelect = document.getElementById('role-select'); +const pass2Banner = document.getElementById('pass2-banner'); +const pass2BannerText = document.getElementById('pass2-banner-text'); + +let currentRole = 'pass1'; +let pass2Flags = {}; + +const PASS2_BTN_LABELS = { done: '✓ Pass 2 approved', problem: '⚠ Needs work' }; +const PASS1_BTN_LABELS = { done: '✓ Done', problem: '⚠ Problem' }; + +function getVerifiedPages() { + return Object.keys(state.flags) + .filter(k => state.flags[k] === 'done') + .map(Number) + .sort((a, b) => a - b); +} + +function updatePass2NavButtons() { + const verified = getVerifiedPages(); + // If Pass 1 queue has no done pages yet, fall back to sequential navigation + if (!verified.length) { + btnPrev.disabled = !state.pdfDoc || state.currentPage <= 1; + btnNext.disabled = !state.pdfDoc || state.currentPage >= state.totalPages; + return; + } + if (!state.pdfDoc) { btnPrev.disabled = true; btnNext.disabled = true; return; } + btnPrev.disabled = !verified.some(p => p < state.currentPage); + btnNext.disabled = !verified.some(p => p > state.currentPage); +} + +function updatePass2Banner() { + const count = getVerifiedPages().length; + pass2BannerText.textContent = count === 0 + ? 'Pass 2 mode · No pages in queue' + : 'Pass 2 mode · ' + count + ' page' + (count === 1 ? '' : 's') + ' ready for review'; + updatePass2NavButtons(); +} + +function applyRole(role) { + currentRole = role; + localStorage.setItem('p44-role', role); + roleSelect.value = role; + if (role === 'pass2') { + pass2Banner.style.display = 'flex'; + btnOcrToggle.style.display = 'inline-block'; + btnFlagDone.textContent = PASS2_BTN_LABELS.done; + btnFlagProblem.textContent = PASS2_BTN_LABELS.problem; + updatePass2Banner(); + } else { + pass2Banner.style.display = 'none'; + btnOcrToggle.style.display = 'none'; + state.ocrOverlay = false; + btnOcrToggle.classList.remove('active'); + hideOCROverlay(); + btnFlagDone.textContent = PASS1_BTN_LABELS.done; + btnFlagProblem.textContent = PASS1_BTN_LABELS.problem; + if (state.pdfDoc) { + btnPrev.disabled = state.currentPage <= 1; + btnNext.disabled = state.currentPage >= state.totalPages; + } + } + updateFlagButtons(state.currentPage); +} + +function savePass2Flags() { + try { localStorage.setItem(LS_PASS2_FLAGS_KEY, JSON.stringify(pass2Flags)); } catch (_) {} + fetch(SERVER_PASS2_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(pass2Flags), + }).catch(() => {}); +} + +function loadPass2FlagsOnStartup() { + fetch('/' + SERVER_PASS2_FLAGS_FILE) + .then(r => r.ok ? r.json() : Promise.reject()) + .then(data => { pass2Flags = data; }) + .catch(() => { + try { + const stored = localStorage.getItem(LS_PASS2_FLAGS_KEY); + if (stored) pass2Flags = JSON.parse(stored); + } catch (_) {} + }); +} + +function resetPass2() { + if (!confirm( + 'Reset Pass 2?\n\n' + + 'This will clear:\n' + + ' • All Pass 2 approved / needs-work / rejected flags\n' + + ' • All saved text corrections\n\n' + + 'Pass 1 done flags are NOT affected.\n\n' + + 'This cannot be undone.' + )) return; + + // Wipe Pass 2 flags + pass2Flags = {}; + try { localStorage.removeItem(LS_PASS2_FLAGS_KEY); } catch (_) {} + + // Wipe corrections (these are shared with Pass 1 editor but the contamination + // source was Pass 2 auto-edit; clearing gives a clean slate) + state.corrections = {}; + try { localStorage.removeItem(LS_CORRECTIONS_KEY); } catch (_) {} + try { fetch(SERVER_CORRECTIONS_END, { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) }); } catch (_) {} + + // Refresh UI + updateFlagButtons(state.currentPage); + updateCorrectionIndicator(state.currentPage); + updatePass2Banner(); + if (state.pdfDoc) showOCRPage(state.currentPage); + setStatus('Pass 2 queue reset — all Pass 2 flags and corrections cleared.'); +} + +document.getElementById('btn-pass2-reset').addEventListener('click', resetPass2); + +// ───────────────────────────────────────────────────────────────── +// PASS 2 — CHANGE SUMMARY PANEL (Step 9) +// ───────────────────────────────────────────────────────────────── + +// Walk both word arrays simultaneously and produce a list of changes. +// Simple sequential diff — fast, good enough for plain-text corrections. +function buildChangeSummary(originalText, pass1Text) { + const orig = (originalText || '').trim().split(/\s+/).filter(Boolean); + const edit = (pass1Text || '').trim().split(/\s+/).filter(Boolean); + if (orig.join(' ') === edit.join(' ')) return []; + + const changes = []; + let i = 0, j = 0; + while (i < orig.length || j < edit.length) { + if (changes.length >= 50) { changes.push({ type: 'truncated' }); break; } + if (i >= orig.length) { changes.push({ type: 'added', word: edit[j++] }); } + else if (j >= edit.length) { changes.push({ type: 'deleted', word: orig[i++] }); } + else if (orig[i] === edit[j]) { i++; j++; } + else { changes.push({ type: 'changed', from: orig[i++], to: edit[j++] }); } + } + return changes; +} + +function showPass2ChangeSummary(pageNum) { + const panel = document.getElementById('pass2-change-summary'); + if (!panel) return; + + const corr = state.corrections && state.corrections[pageNum]; + if (!corr || !corr.pass1 || currentRole !== 'pass2') { + panel.style.display = 'none'; + return; + } + + const changes = buildChangeSummary(corr.original || state.ocrPages[pageNum] || '', corr.pass1); + if (!changes.length) { panel.style.display = 'none'; return; } + + const truncated = changes.find(c => c.type === 'truncated'); + const realChanges = changes.filter(c => c.type !== 'truncated'); + const count = truncated ? '50+' : realChanges.length; + + const listHtml = realChanges.map(c => { + if (c.type === 'changed') return `~ "${escH(c.from)}" → "${escH(c.to)}"`; + if (c.type === 'added') return `+ "${escH(c.word)}"`; + if (c.type === 'deleted') return `− "${escH(c.word)}"`; + return ''; + }).join('
'); + + panel.innerHTML = + '
' + count + ' change' + (count === 1 ? '' : 's') + ' from Pass 1
' + + '
' + listHtml + (truncated ? '
…and more' : '') + '
' + + '
' + + '' + + '' + + '
'; + panel.style.display = 'block'; + + const pass1Text = corr.pass1; + + document.getElementById('btn-cs-revert-pass1').addEventListener('click', () => { + if (!confirm('Revert to Pass 1 text?\n\nYour Pass 2 changes on this page will be lost.')) return; + textEditor.value = pass1Text; + setStatus('Reverted to Pass 1 text — navigate away or click Edit to save.'); + }); + + document.getElementById('btn-cs-revert-original').addEventListener('click', () => { + const typed = prompt( + 'This will discard ALL corrections on this page — both Pass 1 and Pass 2.\n\nType REVERT to confirm:' + ); + if (typed !== 'REVERT') { setStatus('Revert cancelled.'); return; } + textEditor.value = state.ocrPages[pageNum] || ''; + delete state.corrections[pageNum]; + postCorrection(pageNum, null); + updateCorrectionIndicator(pageNum); + panel.style.display = 'none'; + setStatus('Reverted to original OCR — all corrections on this page cleared.'); + }); +} + +roleSelect.addEventListener('change', () => applyRole(roleSelect.value)); + +// ───────────────────────────────────────────────────────────────── +// PHASE 4 — FLAG / ANNOTATION SYSTEM +// ───────────────────────────────────────────────────────────────── +function 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 flag buttons + const isDone = currentRole === 'pass1' && flag === 'done'; + btnFlagDone.disabled = isDone; + btnFlagProblem.disabled = isDone; +} + +function updateFlagsSummary() { + 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 : '') + : ''; + if (currentRole === 'pass2') updatePass2Banner(); +} + +async function saveFlags() { + try { + const res = await fetch(SERVER_FLAGS_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(state.flags), + }); + if (res.ok) return; + } catch (_) {} + // Server doesn't support it (or offline) — fall back to localStorage + try { localStorage.setItem(LS_FLAGS_KEY, JSON.stringify(state.flags)); } catch (_) {} +} + +function 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(); + // Re-render page immediately so done-lock (readOnly) takes effect in Pass 1 + if (type === 'done' && currentRole === 'pass1') showOCRPage(state.currentPage); +} + +btnFlagDone.addEventListener('click', () => setFlag('done')); +btnFlagProblem.addEventListener('click', () => setFlag('problem')); + +async function loadFlagsOnStartup() { + try { + const res = await fetch('/' + SERVER_FLAGS_FILE); + if (res.ok) { + state.flags = await res.json(); + updateFlagsSummary(); + return; + } + } catch (_) {} + // Fallback: localStorage + try { + const stored = localStorage.getItem(LS_FLAGS_KEY); + if (stored) { state.flags = JSON.parse(stored); updateFlagsSummary(); } + } catch (_) {} +} + +function clearPass1Queue() { + const doneCount = Object.values(state.flags).filter(f => f === 'done').length; + if (doneCount === 0) { + setStatus('Pass 1 queue is already empty — no done pages to clear.'); + return; + } + if (!confirm( + 'Clear Pass 1 queue?\n\n' + + 'This will remove all ' + doneCount + ' done flag(s) from Pass 1.\n' + + 'The Pass 2 review queue will become empty.\n\n' + + 'Problem flags are NOT affected.\n' + + 'This cannot be undone.' + )) return; + + // Remove all done flags; keep problem flags intact + Object.keys(state.flags).forEach(k => { + if (state.flags[k] === 'done') delete state.flags[k]; + }); + updateFlagButtons(state.currentPage); + updateFlagsSummary(); // also calls updatePass2Banner if in pass2 + saveFlags(); + setStatus('Pass 1 queue cleared — ' + doneCount + ' done flag(s) removed. Pass 2 queue is now empty.'); +} + +document.getElementById('btn-clear-p1-queue').addEventListener('click', clearPass1Queue); + +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' + + ' • Diff legend preference\n\n' + + 'This cannot be undone.' + )) return; + + // Clear localStorage + localStorage.removeItem(LS_CORRECTIONS_KEY); + localStorage.removeItem(LS_FLAGS_KEY); + localStorage.removeItem(LS_PASS2_FLAGS_KEY); + localStorage.removeItem('p44-role'); + + // Reset in-memory state + state.corrections = {}; + state.flags = {}; + pass2Flags = {}; + + // Reset role to Pass 1 + applyRole('pass1'); + + // Clear server-side files (best-effort) + fetch(SERVER_CLEAR_ENDPOINT, { method: 'POST' }).catch(() => {}); + + // Refresh UI + updateFlagButtons(state.currentPage); + updateFlagsSummary(); + updateCorrectionIndicator(state.currentPage); + if (state.pdfDoc) showOCRPage(state.currentPage); + setStatus('All saved data cleared — fresh start.'); +} + +document.getElementById('btn-clear-all').addEventListener('click', clearAllLocalData); + +// ───────────────────────────────────────────────────────────────── +// LINE NUMBERS — update and scroll-sync with textarea +// ───────────────────────────────────────────────────────────────── + +// Returns a newline-joined string of line number labels that accounts for +// soft-wrapped visual rows in the textarea. Wrapped continuation rows are +// represented by empty strings so the gutter stays in sync with scroll. +function buildWrappedLineNumbers(text, textarea) { + const lines = text.split('\n'); + if (!lines.length) return ''; + + // Lazily create a hidden canvas for text measurement (reused across calls). + if (!buildWrappedLineNumbers._canvas) { + buildWrappedLineNumbers._canvas = document.createElement('canvas'); + } + const ctx = buildWrappedLineNumbers._canvas.getContext('2d'); + const style = window.getComputedStyle(textarea); + // Compose the CSS font shorthand that canvas expects + ctx.font = style.fontSize + ' ' + style.fontFamily; + + // Width available for text inside the textarea (exclude horizontal padding). + const paddingL = parseFloat(style.paddingLeft) || 0; + const paddingR = parseFloat(style.paddingRight) || 0; + const availWidth = Math.max(50, textarea.clientWidth - paddingL - paddingR); + + const result = []; + lines.forEach((line, i) => { + result.push(String(i + 1)); + if (line.length > 0) { + // Estimate the number of extra visual rows this line wraps onto. + // We measure total line width and floor-divide by the available width. + // This is an approximation — word-wrap boundaries differ slightly — + // but it keeps the gutter aligned for typical OCR paragraph lengths. + const lineWidth = ctx.measureText(line).width; + const extraRows = Math.max(0, Math.floor(lineWidth / availWidth)); + for (let r = 0; r < extraRows; r++) result.push(''); + } + }); + + return result.join('\n'); +} + +function updateLineNumbers() { + lineNumbers.textContent = buildWrappedLineNumbers(textEditor.value, textEditor); + syncLineNumbers(); +} + +function syncLineNumbers() { + lineNumbers.scrollTop = textEditor.scrollTop; +} + +textEditor.addEventListener('input', updateLineNumbers); +textEditor.addEventListener('scroll', syncLineNumbers); + +// ───────────────────────────────────────────────────────────────── +// PHASE 5 — INLINE TEXT EDITING +// ───────────────────────────────────────────────────────────────── + +function 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)'; +} + +async function postCorrection(pageNum, corrObj) { + const body = JSON.stringify({ page: pageNum, correction: corrObj }); + try { + const res = await fetch(SERVER_CORRECTIONS_END, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + if (res.ok) return; + } catch (_) {} + try { + const stored = JSON.parse(localStorage.getItem(LS_CORRECTIONS_KEY) || '{}'); + if (corrObj) { stored[pageNum] = corrObj; } else { delete stored[pageNum]; } + localStorage.setItem(LS_CORRECTIONS_KEY, JSON.stringify(stored)); + } catch (_) {} +} + +async function loadCorrectionsOnStartup() { + let loaded = null; + try { + const res = await fetch('/' + SERVER_CORRECTIONS_FILE); + if (res.ok) loaded = await res.json(); + } catch (_) {} + if (!loaded) { + try { + const stored = localStorage.getItem(LS_CORRECTIONS_KEY); + if (stored) loaded = JSON.parse(stored); + } catch (_) {} + } + if (loaded) { + // Migrate old flat-string corrections to new object format + Object.keys(loaded).forEach(k => { + if (typeof loaded[k] === 'string') { + if (loaded[k].trim()) { + loaded[k] = { pass1: loaded[k].trim() }; + } else { + delete loaded[k]; + } + } else if (!loaded[k] || (!loaded[k].pass1 && !loaded[k].pass2)) { + delete loaded[k]; + } + }); + state.corrections = loaded; + try { localStorage.setItem(LS_CORRECTIONS_KEY, JSON.stringify(state.corrections)); } catch (_) {} + } +} + +// ───────────────────────────────────────────────────────────────── +// PASS 2 REVERT BAR +// ───────────────────────────────────────────────────────────────── +function showPass2RevertBar(pageNum) { + const bar = document.getElementById('pass2-revert-bar'); + if (currentRole !== 'pass2') { bar.style.display = 'none'; return; } + + const corr = state.corrections && state.corrections[pageNum]; + if (!corr || !corr.pass1) { bar.style.display = 'none'; return; } + + const originalText = corr.original || state.ocrPages[pageNum] || ''; + const pass1Text = corr.pass1; + + bar.innerHTML = + 'Pass 1 corrections on this page' + + '' + + ''; + bar.style.display = 'flex'; + + document.getElementById('btn-revert-pass1').addEventListener('click', () => { + if (!confirm('Revert to Pass 1 text?\n\nYour Pass 2 changes on this page will be lost.')) return; + textEditor.value = pass1Text; + setStatus('Reverted to Pass 1 text — navigate away or click Edit to save.'); + }); + + document.getElementById('btn-revert-original').addEventListener('click', () => { + const typed = prompt( + 'This will discard ALL corrections on this page — both Pass 1 and Pass 2.\n\n' + + 'Type REVERT to confirm:' + ); + if (typed !== 'REVERT') { setStatus('Revert cancelled.'); return; } + textEditor.value = originalText; + delete state.corrections[pageNum]; + postCorrection(pageNum, null); + updateCorrectionIndicator(pageNum); + bar.style.display = 'none'; + setStatus('Reverted to original OCR — all corrections on this page cleared.'); + }); +} + +function 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); +} + +// ───────────────────────────────────────────────────────────────── +// PHASE 6 — KEYBOARD NAVIGATION +// ───────────────────────────────────────────────────────────────── +document.addEventListener('keydown', e => { + // Skip shortcuts when typing in the page-number input or the editor textarea + const tag = document.activeElement && document.activeElement.tagName; + if (tag === 'INPUT' && document.activeElement.id === 'page-input') return; + if (tag === 'TEXTAREA') return; + + switch (e.key) { + case 'ArrowRight': case 'l': + e.preventDefault(); goToPage(state.currentPage + 1); break; + case 'ArrowLeft': case 'j': + e.preventDefault(); goToPage(state.currentPage - 1); break; + case 'v': + setFlag('done'); break; + case 'p': + setFlag('problem'); break; + } +}); + +// ───────────────────────────────────────────────────────────────── +// HOOK INTO showOCRPage TO APPLY FLAGS/CORRECTIONS/EDIT STATE +// ───────────────────────────────────────────────────────────────── +// Override goToPage to save edits before navigating +function goToPage(n) { + saveCurrentPage(); + + if (!state.pdfDoc) return; + n = Math.max(1, Math.min(state.totalPages, n)); + if (state.ocrOverlay) showOCROverlay(n); + if (n === state.currentPage && !state.rendering) return; + if (state.rendering) { _pendingPage = n; return; } + state.currentPage = n; + renderCurrentPage(); +} + +// ───────────────────────────────────────────────────────────────── +// STARTUP — load persistent data +// ───────────────────────────────────────────────────────────────── +loadPass2FlagsOnStartup(); +loadFlagsOnStartup(); +loadCorrectionsOnStartup(); +// Restore saved role after all DOM refs are ready +(function() { + const saved = localStorage.getItem('p44-role') || 'pass1'; + applyRole(saved); +})(); + + + + + + + + + + diff --git a/prompts/OCR-Viewer.md b/prompts/OCR-Viewer.md deleted file mode 100644 index ecc9034..0000000 --- a/prompts/OCR-Viewer.md +++ /dev/null @@ -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 diff --git a/prompts/ocr-vierwer-redesign6.md b/prompts/ocr-vierwer-redesign6.md new file mode 100644 index 0000000..2e7d256 --- /dev/null +++ b/prompts/ocr-vierwer-redesign6.md @@ -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 => +'' + t + '' +).join(''); + +const actionBtn = isViewOnly + ? '' + : ''; + +return ` +
+
+ +
+
${diary.title}
+
${diary.subtitle}
+
${tagHtml}
+
+
+
+
+ Pass 1 +
+
+
+ ${prog.pass1Pct}% +
+
+ Pass 2 +
+
+
+ ${prog.pass2Pct}% +
+
+
${actionBtn}
+
+ `; +} + +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 + + + + +P44 OCR — War Diary Transcription + + + + +
+

⚜ P44 War Diary Transcription

+ Pass 1 — Volunteer +
+ +
+
War diaries available for transcription
+
+
+ + + + + + + +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 \ No newline at end of file diff --git a/prompts/ocr-viewer-audit.md b/prompts/ocr-viewer-audit.md new file mode 100644 index 0000000..8e34818 --- /dev/null +++ b/prompts/ocr-viewer-audit.md @@ -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 `
`. 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 `
+
+
+With:
+html
+
+
+
+
+
+
+In the right panel header, add a diff toggle button immediately before #btn-edit:
+html
+
+
+
+Remove  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 ''  + escH(t.text) + '';
+if (t.type === 'ins')  return ''  + escH(t.text) + '';
+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 =
+'
' + +' removed' + +' added' + +'
' + +'' + result.total + ' change' + (result.total === 1 ? '' : 's') + ' from Pass 1'; +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 = +'
' +
+result.html + '
'; +} + +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 = +'
Pass 1 corrections — reference
' + +'
' +
+result.html + '
'; +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 diff --git a/prompts/ocr-viewer-redesign3.md b/prompts/ocr-viewer-redesign3.md new file mode 100644 index 0000000..5043dce --- /dev/null +++ b/prompts/ocr-viewer-redesign3.md @@ -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: + + +
+
+
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 +Inside #text-panel-body, immediately before #text-output, add the original OCR reference pane: +html + + +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 \ No newline at end of file diff --git a/prompts/ocr-viewer-redesign5.md b/prompts/ocr-viewer-redesign5.md new file mode 100644 index 0000000..0c26403 --- /dev/null +++ b/prompts/ocr-viewer-redesign5.md @@ -0,0 +1,187 @@ +PART 1 — HTML (p44-ocr-viewer.html) +Remove the Edit button: +html +Remove the correction dot (no longer needed without edit mode state): +html
+Remove #text-output div — the textarea replaces it permanently: +html +The #editor-wrap should now be visible by default — remove style="display:none;" from it: +html
+
+ +
+ +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 = '✓ Done — 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 \ No newline at end of file diff --git a/scripts/ocr_confidence.py b/scripts/ocr_confidence.py new file mode 100644 index 0000000..839e9b1 --- /dev/null +++ b/scripts/ocr_confidence.py @@ -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() + diff --git a/scripts/ocr_wardiaries.py b/scripts/ocr_wardiaries.py index 4be960e..43ed365 100644 --- a/scripts/ocr_wardiaries.py +++ b/scripts/ocr_wardiaries.py @@ -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() \ No newline at end of file