OCR-Viewer (#1)

Co-authored-by: nathan <nathan.kehler@gmail.com>
Reviewed-on: #1
This commit is contained in:
2026-05-19 22:03:28 +00:00
parent 727f4fcc57
commit 330b703539
104 changed files with 144649 additions and 2 deletions

View File

@@ -0,0 +1,721 @@
# P44 OCR Viewer — Rebuild Prompts
*Reference: ocr-viewer-audit.md — read this before starting any step.*
*Each step must be confirmed working before the next step starts.*
*Three files: p44-ocr-viewer.html / p44-ocr-viewer.css / p44-ocr-viewer.js*
---
## Before you start
Read `ocr-viewer-audit.md` in full. It contains the complete audit of the previous codebase, the root causes of every bug we hit, and the rebuild specification. Every architectural decision in these prompts comes from that document.
Key rules that must never be violated in this rebuild:
- `#text-output` is display only — never set `contentEditable` on it
- `#text-editor` is the only edit surface — always a `<textarea>`, always plain text
- No markdown parser — OCR output is plain text, display with `<pre>`
- No confidence scoring — removed by design
- Each step confirmed working before the next begins
---
## Step 1 — HTML/CSS shell
**No JavaScript. Layout and visual design only.**
Create three empty files: `p44-ocr-viewer.html`, `p44-ocr-viewer.css`, `p44-ocr-viewer.js`
Build the complete HTML structure and CSS. Nothing should be functional.
**HTML structure required:**
```
#app (grid: toolbar / pass2-banner / panels / footer)
#toolbar
h1 "⚜ P44 OCR VIEWER"
#btn-load-ch button "▶ Calgary Highlanders — Sep 44"
PDF override: label.file-btn > input#input-pdf[type=file accept=.pdf]
OCR override: label.file-btn > input#input-text[type=file accept=.txt,.md]
JSON override: label.file-btn > input#input-json[type=file accept=.json]
#btn-example "Load sample"
#btn-sync-scroll "Sync Scroll" (class active by default)
role switcher: label "ROLE" + select#role-select
option value="pass1" "Pass 1 — Volunteer"
option value="pass2" "Pass 2 — Senior Editor"
#btn-clear-all "⚠ Clear all data" (red-tinted border and text)
#pass2-banner (display:none by default)
span#pass2-banner-text
button#btn-pass2-reset "↺ Reset Pass 2"
#panels (two-column flex)
Left panel div.panel
div.panel-header
span "Source Document — Page Scan"
span#zoom-label "100%"
button#btn-zoom-reset "1:1"
div.page-nav
button#btn-prev "◀" disabled
input#page-input type=number value=1 min=1
button#btn-next "▶" disabled
span#page-label "/ —"
div.panel-body#image-panel-body
div#image-placeholder
div#img-spinner style=display:none
div#img-ph-text "War diary scan loads automatically."
div#pdf-container style=display:none
canvas#pdf-canvas
div#overlay
Right panel div.panel
div.panel-header
span "OCR Text Output"
span#ocr-sync-label
div#correction-dot style=display:none title="This page has been edited"
button#btn-flag-verified class="tb-btn flag-btn" "✓ Verified"
button#btn-flag-review class="tb-btn flag-btn" "⚑ Review"
button#btn-flag-error class="tb-btn flag-btn" "✗ Error"
button#btn-flag-clear class="tb-btn flag-btn" "Clear"
button#btn-edit class="tb-btn" "Edit"
div.panel-body#text-panel-body
div#text-placeholder
div#txt-spinner style=display:none
div#txt-ph-text "OCR transcript loads automatically."
div#text-output style=display:none
textarea#text-editor style=display:none
#footer
span#status-msg "Ready."
span#flags-summary
span#kbd-hint "← → navigate v/r/e flag esc close"
```
**CSS variables — use exactly these:**
```css
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--gold: #c8a84b;
--gold-dim: #8a6f2e;
--text: #ddd4bb;
--text-dim: #7a7260;
--text-muted:#4a4538;
--border: #2e2e4e;
--green: #4caf70;
--green-bg: #1a3326;
--amber: #e8a838;
--amber-bg: #2e2010;
--red: #d05050;
--red-bg: #2e1a1a;
--toolbar-h: 52px;
--footer-h: 34px;
}
```
**CSS layout rules:**
- `#app`: CSS grid `grid-template-rows: var(--toolbar-h) auto 1fr var(--footer-h)`, `height: 100vh`, `overflow: hidden`
- `#panels`: flex row, each `.panel` takes `width: 50%`, `display: flex`, `flex-direction: column`, `overflow: hidden`
- `.panel-body`: `flex: 1`, `overflow-y: auto`, `overflow-x: hidden`
- `#pass2-banner`: `display: none`, slim flex row, `background: #1a1200`, `border-bottom: 1px solid var(--gold-dim)`, `padding: 6px 14px`, `font-size: 11px`, `color: var(--amber)`
- `#text-editor`: `display: none`, `width: 100%`, `height: 100%`, `resize: none`, `font-family: Georgia serif`, `font-size: 12.5px`, `line-height: 1.75`, `background: var(--surface2)`, `color: var(--text)`, `border: none`, `border-top: 1px solid var(--gold-dim)`, `padding: 16px 20px`, `box-sizing: border-box`, `outline: none`
- `#text-editor:focus`: `border-top-color: var(--gold)`
- `.tb-btn`: `padding: 3px 9px`, `background: transparent`, `border: 1px solid var(--gold-dim)`, `border-radius: 3px`, `color: var(--text-dim)`, `font-family: Georgia serif`, `font-size: 10px`, `cursor: pointer`, `letter-spacing: 0.04em`
- `.tb-btn:hover`: `border-color: var(--gold)`, `color: var(--gold)`
- `.tb-btn.active`: `border-color: var(--gold)`, `color: var(--gold)`
- `.file-btn`: same as `.tb-btn` but as a `<label>` with hidden `<input type="file">` inside
- `#btn-clear-all`: `border-color: #d05050`, `color: #d05050`
- `#btn-clear-all:hover`: `border-color: #e07070`, `color: #e07070`
- `.flag-btn.active`: `border-color: var(--gold)`, `color: var(--gold)`
- `#correction-dot`: `width: 8px`, `height: 8px`, `border-radius: 50%`, `background: var(--gold)`, `display: none`, `flex-shrink: 0`
- `.panel-header`: `display: flex`, `align-items: center`, `gap: 6px`, `padding: 0 10px`, `height: 38px`, `background: var(--surface)`, `border-bottom: 1px solid var(--border)`, `flex-shrink: 0`, `font-size: 9px`, `letter-spacing: 0.1em`, `color: var(--text-dim)`, `text-transform: uppercase`
- `#toolbar`: `display: flex`, `align-items: center`, `gap: 8px`, `padding: 0 12px`, `background: var(--surface)`, `border-bottom: 2px solid var(--gold-dim)`
- `#toolbar h1`: `font-size: 13px`, `font-weight: normal`, `letter-spacing: 0.1em`, `color: var(--gold)`, `white-space: nowrap`
- `.divider`: `width: 1px`, `height: 26px`, `background: var(--border)`, `flex-shrink: 0`
- `#footer`: `display: flex`, `align-items: center`, `justify-content: space-between`, `padding: 0 14px`, `background: var(--surface)`, `border-top: 1px solid var(--border)`, `font-size: 10px`, `color: var(--text-dim)`
- `#pdf-container`: `position: relative`, `display: inline-block`
- `#overlay`: `position: absolute`, `top: 0`, `left: 0`, `pointer-events: none`
- `.word-box`: `position: absolute`, `cursor: crosshair`, `border: 1px solid transparent`, `border-radius: 2px`
- `.word-box:hover, .word-box.highlight`: `background: rgba(200,168,75,0.18)`, `border-color: var(--gold-dim)`
- `.panel-placeholder`: `display: flex`, `flex-direction: column`, `align-items: center`, `justify-content: center`, `height: 100%`, `color: var(--text-dim)`, `font-size: 12px`, `text-align: center`, `gap: 10px`
- `.spinner`: `width: 24px`, `height: 24px`, `border: 2px solid var(--border)`, `border-top-color: var(--gold)`, `border-radius: 50%`, `animation: spin 0.8s linear infinite`
- `@keyframes spin`: `to { transform: rotate(360deg) }`
- `#page-input`: `width: 48px`, `text-align: center`, `background: var(--surface2)`, `border: 1px solid var(--border)`, `color: var(--text)`, `font-family: Georgia serif`, `font-size: 12px`, `padding: 2px 4px`, `border-radius: 3px`
- `select#role-select`: same styling as `.tb-btn` but as a select element, `background: var(--surface2)`
- `#ocr-sync-label`: `font-size: 9px`, `color: var(--gold-dim)`, `letter-spacing: 0.06em`
**Do not write any JavaScript yet.**
Confirm when complete by listing every `id` and every CSS class defined.
---
## Step 2 — PDF loading and page navigation
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
Add the following in this order:
1. PDF.js worker setup:
```javascript
if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
}
```
2. Data paths:
```javascript
const REAL_PDF_PATH = './Inputs/Calgary-Highlanders_War-Diary_Sep44.pdf';
const REAL_TEXT_PATH = './Inputs/ocr-output/Calgary-Highlanders_War-Diary_Sep44_olmocr.md';
```
3. State object — only these properties to start:
```javascript
const state = {
mode: 'none', // 'pdf' | 'image' | 'none'
pdfDoc: null,
currentPage: 1,
totalPages: 0,
zoomScale: 1.0, // Ctrl+scroll zoom multiplier (0.54.0)
wordData: [],
rendering: false,
lastRenderTime: 0,
ocrPages: {}, // { [pageNum]: string } — populated in Step 3
flags: {}, // populated in Step 5
corrections: {}, // populated in Step 6
editMode: false, // populated in Step 6
};
```
4. All DOM refs (getElementById for every id in the HTML)
5. `escH(s)` utility function
6. `setStatus(msg, warn)` utility function
7. `showImgSpinner(on)`, `showTxtSpinner(on)`, `showImgPlaceholder(html)`, `showTxtPlaceholder(html)` utility functions
8. `loadPDFBuffer(arrayBuffer)` — loads PDF into PDF.js, sets `state.pdfDoc`, `state.totalPages`, `state.mode = 'pdf'`, calls `renderCurrentPage()`
9. `renderCurrentPage()` — fits page to panel width, applies `state.zoomScale`, uses DPR for HiDPI, renders to `#pdf-canvas`, calls `buildPDFWordOverlay()`. Wrap `showOCRPage()` call in try/catch (showOCRPage is added in Step 3). Include `_pendingPage` queue and debounce guard.
10. `buildPDFWordOverlay(page, vp, dpr)` — builds word boxes from `state.wordData` if loaded, otherwise leaves overlay blank
11. `makeWordBox(id, x, y, w, h, title)` — creates positioned div with hover highlight
12. `goToPage(n)` — validates range, guards against re-render of same page, calls `renderCurrentPage()`. No edit mode save yet (added in Step 6).
13. `_pendingPage = null` module variable
14. `updateZoomLabel()` — updates `#zoom-label` text
15. Event listeners:
- `#btn-prev` click → `goToPage(state.currentPage - 1)`
- `#btn-next` click → `goToPage(state.currentPage + 1)`
- `#page-input` change → `goToPage(parseInt(value))`
- `#btn-zoom-reset` click → reset `state.zoomScale` to 1.0, re-render
- `window resize` → re-render if PDF loaded and not rendering
- `#input-pdf` change → read file, call `loadPDFBuffer()`
- `#input-json` change → read file, parse JSON, set `state.wordData`, re-render
16. Ctrl+scroll zoom on `#image-panel-body`:
- **MUST check `e.ctrlKey` — only zoom when Ctrl is held**
- Only call `e.preventDefault()` when `e.ctrlKey` is true
- Without Ctrl: let the event pass through normally for scrolling
- With Ctrl: adjust `state.zoomScale` by ±0.1, clamp to 0.54.0, re-render
- After re-render, scroll to keep cursor anchor point in view
17. `DOMContentLoaded` auto-load:
```javascript
window.addEventListener('DOMContentLoaded', () => {
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
setTimeout(loadCalgaryHighlanders, 200);
}
});
```
18. `loadCalgaryHighlanders()` — fetches PDF only for now (OCR text fetch added in Step 3). Uses `Promise.allSettled`.
**Test: PDF loads, pages navigate with buttons and keyboard input, zoom works with Ctrl+scroll, normal scroll works without Ctrl.**
---
## Step 3 — OCR text loading and plain text display
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
1. `parseOCRByPage(rawText)`:
- Splits on `## Page N` headings
- Stores content between headings in `state.ocrPages[N]`
- **Does NOT include the `## Page N` heading line in the content**
- `buf = []` not `buf = [line]`
2. `loadOCRText(rawText)`:
- Calls `parseOCRByPage(rawText)`
- Does NOT call `showOCRPage()` — only calls it if `state.pdfDoc` is already loaded
- This prevents double-render on startup
3. `showOCRPage(pageNum)`:
- Clears `textOutput.innerHTML = ''`
- Sets `textOutput.dataset.page = pageNum`
- Hides placeholder, shows `textOutput`
- If no OCR pages loaded: shows "OCR text not loaded yet" message
- If no content for this page: shows "No OCR text for page N" message
- Gets display text: `corrections[pageNum]?.pass1 || state.ocrPages[pageNum]` (corrections object added in Step 6, guard with optional chaining for now)
- Renders as: `<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;line-height:1.75;padding:16px 20px;">{escH(displayText)}</pre>`
- Scrolls text panel to top
- Updates `#ocr-sync-label` text
4. Update `loadCalgaryHighlanders()` to also fetch `REAL_TEXT_PATH` and call `loadOCRText()`
5. Add `#input-text` change event listener → `loadOCRText()`
6. Add `showOCRPage(state.currentPage)` call inside `renderCurrentPage()` after PDF page renders
**Test: OCR text loads alongside PDF, correct page shown for each page number, no `## Page N` heading visible in text panel, plain text renders correctly.**
---
## Step 4 — Synchronized scrolling
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
1. Module variables:
```javascript
let _syncScrolling = false;
let _syncActive = true;
```
2. `onImgScroll()` — when left panel scrolls, scroll right panel to same proportional position. Use `requestAnimationFrame` to reset `_syncScrolling` flag.
3. `onTxtScroll()` — when right panel scrolls, scroll left panel to same proportional position.
4. Attach scroll listeners to `#image-panel-body` and `#text-panel-body`
5. `#btn-sync-scroll` click → toggle `_syncActive`, toggle `.active` class on button
**Test: Scrolling either panel scrolls the other proportionally. Toggle button disables sync. Normal mouse wheel scroll works on left panel (no zoom without Ctrl).**
---
## Step 5 — Flag system
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
1. `updateFlagButtons(pageNum)` — toggles `.active` class on flag buttons based on `state.flags[pageNum]`
2. `updateFlagsSummary()` — counts verified/review/error flags, updates `#flags-summary` text
3. `setFlag(type)` — sets or clears `state.flags[state.currentPage]`, calls `updateFlagButtons()`, `updateFlagsSummary()`, `saveFlags()`
4. `saveFlags()`:
- POST to `/save-flags` with `state.flags` as JSON body
- On failure: fallback to `localStorage.setItem('p44-flags', ...)`
5. `loadFlagsOnStartup()`:
- Fetch `/flags.json`
- On failure: fallback to `localStorage.getItem('p44-flags')`
- Call `updateFlagsSummary()` after loading
6. `clearPass1Queue()` — removes all `verified` flags, keeps review/error, confirm dialog required
7. Event listeners:
- `#btn-flag-verified``setFlag('verified')`
- `#btn-flag-review``setFlag('review')`
- `#btn-flag-error``setFlag('error')`
- `#btn-flag-clear``setFlag(null)`
- `#btn-clear-p1-queue``clearPass1Queue()`
8. Call `loadFlagsOnStartup()` on `DOMContentLoaded`
9. Call `updateFlagButtons(pageNum)` at the end of `showOCRPage()`
**Test: Flags save and persist across refresh. Footer summary updates. Clear queue removes only verified flags.**
---
## Step 6 — Edit mode (Pass 1 only)
**This is the most critical step. Read the architecture rules before starting.**
**Architecture rules — must not be violated:**
- `#text-output` is NEVER set to `contentEditable`. It is display only.
- `#text-editor` (`<textarea>`) is the ONLY edit surface.
- When edit mode is active: hide `#text-output`, show `#text-editor`
- When edit mode is inactive: hide `#text-editor`, show `#text-output`
- `#text-editor` always contains plain text — never HTML
**Correction data model:**
```javascript
state.corrections[pageNum] = {
original: string, // raw OCR snapshot, taken ONCE on first edit-open, never overwritten
pass1: string, // Pass 1 saved text (absent if unchanged from original)
pass2: string, // Pass 2 saved text (added in Step 8, absent for now)
}
```
**Add to `p44-ocr-viewer.js`:**
1. `updateCorrectionIndicator(pageNum)` — shows/hides `#correction-dot` based on whether `corrections[pageNum]?.pass1` exists
2. `postCorrection(pageNum, corrObj)`:
- POST to `/save-corrections` with `{ page: pageNum, correction: corrObj }`
- On failure: fallback to localStorage `p44-corrections`
3. `loadCorrectionsOnStartup()`:
- Fetch `/corrections.json`
- On failure: fallback to localStorage
- Remove any entries where both `pass1` and `pass2` are absent
- Set `state.corrections`
4. `enterEditMode()`:
```
- Get pageNum = state.currentPage
- If corrections[pageNum] does not exist: create { original: state.ocrPages[pageNum].trim() }
- If corrections[pageNum].original does not exist: set it to state.ocrPages[pageNum].trim()
- Set textEditor.value = corrections[pageNum].pass1 || corrections[pageNum].original
- Set textEditor.dataset.page = pageNum
- Hide textOutput (display:none)
- Show textEditor (display:block)
- Focus textEditor
- Set state.editMode = true
- Update btnEdit text to "Editing", add .active class
```
5. `exitEditMode()`:
```
- If !state.editMode: return
- Get pageNum = parseInt(textEditor.dataset.page)
- Get editedText = textEditor.value.trim()
- Get originalText = corrections[pageNum]?.original || state.ocrPages[pageNum]?.trim() || ''
- If editedText !== originalText AND editedText is not empty:
corrections[pageNum].pass1 = editedText
Else:
delete corrections[pageNum].pass1
- If neither pass1 nor pass2 exist: delete corrections[pageNum] entirely
- Call postCorrection(pageNum, corrections[pageNum] || null)
- Call updateCorrectionIndicator(pageNum)
- Hide textEditor (display:none), clear textEditor.value
- Show textOutput (display:block)
- Set state.editMode = false
- Update btnEdit text to "Edit", remove .active class
- Call showOCRPage(pageNum) to re-render display with saved correction
```
6. Update `goToPage()` — call `exitEditMode()` at the very top before any other logic
7. `#btn-edit` click → toggle edit mode
8. Update `showOCRPage()` display text logic:
```javascript
const corr = state.corrections[pageNum];
const displayText = (corr?.pass1) || state.ocrPages[pageNum] || '';
```
9. Call `loadCorrectionsOnStartup()` on `DOMContentLoaded`
10. Call `updateCorrectionIndicator(pageNum)` at end of `showOCRPage()`
**Test thoroughly before proceeding:**
- Enter edit mode: textarea appears, display hidden
- Make a change and exit: display re-renders with correction, gold dot appears
- Navigate away and back: correction persists, dot still shows
- Enter and exit without changing: no correction saved, no dot
- Refresh page: correction loads from localStorage/server, dot shows correctly
- Confirm `## Page N` never appears in the textarea
---
## Step 7 — Corrections persistence in launch_viewer.py
**Add to `launch_viewer.py` only. Do not change any viewer files.**
Add a `do_POST` method to `QuietHandler` that handles these endpoints:
1. `POST /save-flags` — read JSON body, write to `flags.json` in ROOT
2. `POST /save-corrections` — read JSON body (`{ page, correction }`), load existing `corrections.json`, update the entry for `page`, write back
3. `POST /save-pass2-flags` — read JSON body, write to `pass2_flags.json` in ROOT
4. `POST /clear-all-data` — delete `flags.json`, `corrections.json`, `pass2_flags.json` if they exist, respond 200
All endpoints:
- Read body with `self.rfile.read(int(self.headers['Content-Length']))`
- Parse with `json.loads()`
- Respond `200 OK` on success
- Respond `400` on parse error
- Never crash the server on bad input — wrap in try/except
**Test: Save a flag, restart the server, reload viewer — flag persists from file not just localStorage.**
---
## Step 8 — Role switcher and Pass 2 mode
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
1. Module variables:
```javascript
let currentRole = 'pass1';
let pass2Flags = {};
const PASS1_LABELS = { verified: '✓ Verified', review: '⚑ Review', error: '✗ Error' };
const PASS2_LABELS = { verified: '✓ Pass 2 approved', review: '⚑ Needs work', error: '✕ Rejected' };
```
2. `getVerifiedPages()` — returns sorted array of page numbers where `state.flags[n] === 'verified'`
3. `updatePass2NavButtons()` — in Pass 2 mode, disables prev/next if no verified pages before/after current
4. `updatePass2Banner()` — updates banner text with count of verified pages ready for review
5. `applyRole(role)`:
- Sets `currentRole`
- Saves to `localStorage('p44-role')`
- Shows/hides `#pass2-banner`
- Updates flag button labels to PASS1 or PASS2 labels
- Calls `updatePass2Banner()` if pass2
- Re-renders current page via `showOCRPage(state.currentPage)`
6. Update `#btn-prev` / `#btn-next` click handlers — in Pass 2 mode, jump between verified pages only
7. Update `showOCRPage()` — at the end, if `currentRole === 'pass2'`, call `enterEditMode()` if not already in edit mode. Also show pass2-specific display text: `corr?.pass2 || corr?.pass1 || state.ocrPages[pageNum]`
8. `savePass2Flags()` — POST to `/save-pass2-flags`, fallback to localStorage `p44-pass2-flags`
9. `loadPass2FlagsOnStartup()` — fetch `/pass2_flags.json`, fallback to localStorage
10. Update `setFlag()` — in Pass 2 mode, write to `pass2Flags` and call `savePass2Flags()` instead of `state.flags`
11. Update `updateFlagButtons()` — read from `pass2Flags` in Pass 2 mode
12. `resetPass2()` — clears `pass2Flags` and all corrections, confirm dialog, cannot be undone
13. `#btn-pass2-reset` click → `resetPass2()`
14. `#role-select` change → `applyRole(value)`
15. On `DOMContentLoaded`, restore role: `applyRole(localStorage.getItem('p44-role') || 'pass1')`
16. Call `loadPass2FlagsOnStartup()` on `DOMContentLoaded`
**Test: Switch roles, Pass 2 nav jumps between verified pages, Pass 2 flags stored separately, role persists on refresh.**
---
## Step 9 — Pass 2 change summary panel
**Add to HTML, CSS, and JS.**
**HTML — add inside `#text-panel-body` after `#text-placeholder`, before `#text-output`:**
```html
<div id="pass2-change-summary" style="display:none;"></div>
```
**CSS — add:**
```css
#pass2-change-summary {
display: none;
padding: 10px 20px;
background: var(--surface2);
border-bottom: 1px solid var(--gold-dim);
flex-shrink: 0;
font-size: 11px;
}
#pass2-change-summary .cs-title {
font-size: 10px;
color: var(--gold-dim);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
#pass2-change-summary .cs-list {
color: var(--text-dim);
line-height: 1.8;
max-height: 100px;
overflow-y: auto;
margin-bottom: 8px;
font-family: Georgia, serif;
}
#pass2-change-summary .cs-changed { color: var(--amber); }
#pass2-change-summary .cs-added { color: var(--green); }
#pass2-change-summary .cs-deleted { color: var(--red); }
#pass2-change-summary .cs-actions { display: flex; gap: 8px; }
.cs-revert-btn {
font-size: 10px;
font-family: Georgia, serif;
padding: 3px 10px;
border-radius: 3px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
letter-spacing: 0.04em;
}
.cs-revert-btn:hover { border-color: var(--gold-dim); color: var(--gold); }
.cs-revert-btn.danger { border-color: var(--red); color: var(--red); }
```
**JS — add:**
1. `buildChangeSummary(originalText, pass1Text)`:
- Split both strings on whitespace: `str.trim().split(/\s+/)`
- Walk both arrays simultaneously
- Produce array of `{ type: 'changed'|'added'|'deleted', from?, to?, word? }`
- Cap at 50 changes, add `{ type: 'truncated' }` if over
- Return empty array if strings are identical
2. `showPass2ChangeSummary(pageNum)`:
- Get `corr = state.corrections[pageNum]`
- If no `corr?.pass1` or `currentRole !== 'pass2'`: hide panel, return
- Build changes from `corr.original` vs `corr.pass1`
- If no changes: hide panel, return
- Render `cs-title` with change count
- Render `cs-list` with each change coloured by type
- Render two action buttons: "↺ Revert to Pass 1" and "⚠ Revert to original OCR"
- Show panel
- Wire revert buttons:
- Revert to Pass 1: `confirm()` dialog, set `textEditor.value = corr.pass1`, status message
- Revert to original: `prompt('Type REVERT to confirm')`, if confirmed delete `corrections[pageNum]`, call `postCorrection(pageNum, null)`, update indicator, hide summary, status message
3. Call `showPass2ChangeSummary(state.currentPage)` at end of `enterEditMode()`
4. Hide `#pass2-change-summary` at start of `exitEditMode()`
**Test: Switch to Pass 2, navigate to a page with Pass 1 corrections — change summary appears above textarea showing exactly what was changed. Revert to Pass 1 restores Pass 1 text. Revert to original requires typing REVERT and clears all corrections.**
---
## Step 10 — Keyboard navigation
**Add to `p44-ocr-viewer.js` only.**
1. `document.addEventListener('keydown', e => {...})`
2. Guard: if `state.editMode` is true, return immediately (let all keys go to textarea)
3. Guard: if focus is on any `INPUT`, `TEXTAREA`, or `SELECT`, return
4. Key mappings:
- `ArrowRight` or `l` → `goToPage(state.currentPage + 1)`
- `ArrowLeft` or `j` → `goToPage(state.currentPage - 1)`
- `v` → `setFlag('verified')`
- `r` → `setFlag('review')`
- `e` → `setFlag('error')`
- `Escape` → if edit mode active: `exitEditMode()`. Otherwise: nothing. **Do NOT reference `zoomLens` — that element does not exist.**
**Test: Arrow keys navigate pages, v/r/e set flags, Escape exits edit mode, no errors thrown.**
---
## Step 11 — Clear all data
**Add to `p44-ocr-viewer.js` and `launch_viewer.py`.**
**JS — add `clearAllLocalData()`:**
```javascript
function clearAllLocalData() {
if (!confirm(
'Clear ALL saved data?\n\n' +
'This will wipe:\n' +
' • All Pass 1 and Pass 2 flags\n' +
' • All text corrections\n' +
' • Role setting\n\n' +
'This cannot be undone.'
)) return;
localStorage.removeItem('p44-corrections');
localStorage.removeItem('p44-flags');
localStorage.removeItem('p44-pass2-flags');
localStorage.removeItem('p44-role');
state.corrections = {};
state.flags = {};
pass2Flags = {};
applyRole('pass1');
updateFlagButtons(state.currentPage);
updateFlagsSummary();
updateCorrectionIndicator(state.currentPage);
if (state.pdfDoc) showOCRPage(state.currentPage);
fetch('/clear-all-data', { method: 'POST' }).catch(() => {});
setStatus('All saved data cleared — fresh start.');
}
```
Wire `#btn-clear-all` click → `clearAllLocalData()`
**`launch_viewer.py` — confirm `/clear-all-data` endpoint exists from Step 7. If not added yet, add it now.**
**Test: Click clear, confirm, all flags gone, all corrections gone, role reset, page re-renders as raw OCR, server files deleted.**
---
## Step 12 — Sample data
**Add to `p44-ocr-viewer.js` only.**
1. Add `SAMPLE_TEXT` constant (plain prose — no markdown)
2. Add `SAMPLE_JSON` constant (word bbox data)
3. `buildSampleImageDataURL()` — canvas-drawn sample page image
4. `loadSampleData()`:
- Sets `state.mode = 'image'`
- Draws sample image to canvas
- Loads sample JSON word data
- Calls `loadOCRText(SAMPLE_TEXT)`
- Status: `'Sample data loaded.'` — **do not reference any removed features**
5. `#btn-example` click → `loadSampleData()`
**Test: Sample button loads image and OCR text, page displays correctly.**
---
## Step 13 — Final cleanup and launch_viewer.py review
**Review all three files against this checklist:**
**JS cleanup:**
- [ ] No reference to `zoomLens` anywhere
- [ ] No `contentEditable` set on `#text-output` anywhere
- [ ] No `convertMarkdown()` or `inlineMd()` function
- [ ] No `sanitizeCorrection()` function
- [ ] No `stripHTML()` function
- [ ] No word-level diff functions (`wordDiff`, `lcsWords`, `tokenizeWords`, `renderDiff`)
- [ ] No diff legend DOM refs or event listeners
- [ ] `state.scale` removed (it was dead — there is no scale dropdown)
- [ ] `state.ocrRawFull` removed (dead — never read)
- [ ] `p44_diff_legend_dismissed` NOT in `clearAllLocalData()` (key was never written)
- [ ] localStorage keys are consistent: `p44-flags`, `p44-pass2-flags`, `p44-corrections`, `p44-role`
**launch_viewer.py review:**
- [ ] `/save-flags` POST handler exists
- [ ] `/save-corrections` POST handler exists
- [ ] `/save-pass2-flags` POST handler exists
- [ ] `/clear-all-data` POST handler exists
- [ ] All handlers wrapped in try/except
- [ ] `Cache-Control: no-store` headers present on all responses
**Final test — full workflow:**
1. Load Calgary Highlanders
2. Navigate to page 10
3. Flag as Verified (Pass 1)
4. Enter edit mode, make a correction, exit
5. Refresh — correction and flag persist
6. Switch to Pass 2
7. Navigate to page 10 (only verified page)
8. Change summary shows Pass 1 edits
9. Make a Pass 2 correction
10. Revert to original (type REVERT)
11. Clear all data
12. Refresh — everything is gone
---
*End of rebuild prompts.*