19 KiB
Text Editing & Green Overlay Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add green text overlay, editable segment inputs, and font/position controls to the redaction-centric UI so users can pixel-align the overlay and correct OCR before solving.
Architecture: Three frontend additions (font toolbar, text edit bar, enhanced canvas rendering) plus one backend enhancement (offset guess in analyze response). All state lives in redactions[id].overrides which is initialized from the analysis response and modified by UI controls.
Tech Stack: Python/FastAPI (backend), vanilla JS/CSS (frontend), Pillow getlength (offset computation)
Design doc: docs/plans/2026-02-16-text-editing-overlay-design.md
Task 1: Backend — add offset guess to analyze response
Files:
- Modify:
unredact/app.py:150-219(analyze_redaction endpoint) - Modify:
tests/test_app.py(update test_redaction_analyze)
Step 1: Update the test
In tests/test_app.py, update test_redaction_analyze to also check for offset fields:
Add these assertions after the existing ones:
assert "offset_x" in data
assert "offset_y" in data
assert isinstance(data["offset_x"], (int, float))
assert isinstance(data["offset_y"], (int, float))
Step 2: Run test to verify it fails
Run: python -m pytest tests/test_app.py::test_redaction_analyze -v
Expected: FAIL — "offset_x" not in data
Step 3: Add offset computation to the endpoint
In unredact/app.py, in the analyze_redaction function, after computing left_text and before building chars_json, add:
# Compute initial offset guess: align end of left text with gap start
pil_font = font_match.to_pil_font()
if left_text:
left_rendered_width = pil_font.getlength(left_text)
offset_x = float(rx - left_rendered_width - best_line.x)
else:
offset_x = 0.0
offset_y = 0.0
Then add to the return dict (alongside the existing "segments", "gap", etc.):
"offset_x": round(offset_x, 1),
"offset_y": round(offset_y, 1),
Step 4: Run test to verify it passes
Run: python -m pytest tests/test_app.py::test_redaction_analyze -v
Expected: PASS
Step 5: Commit
git add unredact/app.py tests/test_app.py
git commit -m "feat: add offset_x/offset_y to analyze response for alignment guess"
Task 2: Frontend — add overrides to state model and initialize on analysis
Files:
- Modify:
unredact/static/app.js(analyzeRedaction, openPopover, state)
Step 1: Initialize overrides when analysis completes
In analyzeRedaction(), after r.analysis = data; (currently at line ~313), add:
// Initialize overrides from analysis
r.overrides = {
fontId: data.font.id,
fontSize: data.font.size,
offsetX: data.offset_x || 0,
offsetY: data.offset_y || 0,
gapWidth: data.gap.w,
leftText: data.segments.length > 0 ? data.segments[0].text : "",
rightText: data.segments.length > 1 ? data.segments[1].text : "",
};
Step 2: Update startSolve to read from overrides
In startSolve() (line ~721), replace the section that reads from a.font and a.gap:
const o = r.overrides || {};
const fontId = o.fontId || a.font.id;
const fontSize = o.fontSize || a.font.size;
const gapWidth = o.gapWidth || a.gap.w;
const leftText = o.leftText ?? (a.segments.length > 0 ? a.segments[0].text : "");
const rightText = o.rightText ?? (a.segments.length > 1 ? a.segments[1].text : "");
const leftCtx = leftText.length > 0 ? leftText[leftText.length - 1] : "";
const rightCtx = rightText.length > 0 ? rightText[0] : "";
Step 3: Update preview/solution canvas rendering to use overrides
In drawRedactionPreview and drawRedactionSolution, replace hardcoded a.font.name/a.font.size reads with override-aware reads:
const o = r.overrides || {};
const fontName = state.fonts.find(f => f.id === (o.fontId || a.font.id))?.name || a.font.name;
const fontSize = o.fontSize || a.font.size;
const fontStr = `${fontSize}px "${fontName}"`;
const gapW = o.gapWidth || a.gap.w;
const renderX = a.line.x + (o.offsetX || 0);
const renderY = a.line.y + (o.offsetY || 0);
Use gapW instead of a.gap.w for box widths, and renderX/renderY for text positioning.
Step 4: Commit
git add unredact/static/app.js
git commit -m "feat: add overrides state model and wire into solver + canvas"
Task 3: Frontend — green overlay rendering for active redaction
Files:
- Modify:
unredact/static/app.js(drawRedactionAnalyzed, renderCanvas)
Step 1: Rewrite drawRedactionAnalyzed for active redaction
When the redaction is active and analyzed, draw the full green overlay with segments and gap. Replace the current drawRedactionAnalyzed:
function drawRedactionAnalyzed(r, isActive) {
if (!isActive) {
// Non-active: just blue box like before
drawRedactionUnanalyzed(r, false);
return;
}
// Active: render green overlay with segments and gap
const a = r.analysis;
const o = r.overrides || {};
const fontName = state.fonts.find(f => f.id === (o.fontId || a.font.id))?.name || a.font.name;
const fontSize = o.fontSize || a.font.size;
const fontStr = `${fontSize}px "${fontName}"`;
const gapW = o.gapWidth || a.gap.w;
const startX = a.line.x + (o.offsetX || 0);
const startY = a.line.y + (o.offsetY || 0);
ctx.font = fontStr;
ctx.textBaseline = "top";
let cursorX = startX;
// Draw left segment in green
const leftText = o.leftText ?? "";
if (leftText) {
ctx.fillStyle = "rgba(0, 200, 0, 0.7)";
ctx.fillText(leftText, cursorX, startY);
cursorX += ctx.measureText(leftText).width;
}
// Draw redaction gap as red box
const pad = fontSize * 0.15;
ctx.fillStyle = "rgba(211, 47, 47, 0.5)";
ctx.fillRect(cursorX, startY - pad, gapW, fontSize + pad * 2);
ctx.strokeStyle = "rgba(211, 47, 47, 0.8)";
ctx.lineWidth = 2;
ctx.strokeRect(cursorX, startY - pad, gapW, fontSize + pad * 2);
// Gap width label
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
ctx.font = `bold ${Math.min(fontSize * 0.5, 16)}px sans-serif`;
const label = `${Math.round(gapW)}px`;
const labelW = ctx.measureText(label).width;
ctx.fillText(label, cursorX + (gapW - labelW) / 2, startY + fontSize * 0.3);
ctx.font = fontStr; // restore
cursorX += gapW;
// Draw right segment in green
const rightText = o.rightText ?? "";
if (rightText) {
ctx.fillStyle = "rgba(0, 200, 0, 0.7)";
ctx.fillText(rightText, cursorX, startY);
}
// Draw bounding box for the original line area
ctx.strokeStyle = "rgba(0, 200, 0, 0.3)";
ctx.lineWidth = 1;
ctx.strokeRect(a.line.x, a.line.y, a.line.w, a.line.h);
}
Also update drawRedactionPreview and drawRedactionSolution similarly — when active, draw the green segments around the gap, with preview/solution text inside the gap instead of the red box.
Step 2: Commit
git add unredact/static/app.js
git commit -m "feat: green overlay rendering with segments and gap for active redaction"
Task 4: Frontend — floating font toolbar
Files:
- Modify:
unredact/static/index.html(add toolbar HTML) - Modify:
unredact/static/app.js(toolbar logic) - Modify:
unredact/static/style.css(toolbar styles)
Step 1: Add HTML
In index.html, add inside #right-panel before #popover:
<div id="font-toolbar" hidden>
<label>
Font
<select id="font-select"></select>
</label>
<span class="toolbar-sep"></span>
<div class="size-control">
<span>Size</span>
<button id="size-down" class="tb-btn">-</button>
<input type="range" id="size-slider" min="8" max="120" step="1">
<button id="size-up" class="tb-btn">+</button>
<span id="size-value">32</span>px
</div>
<span class="toolbar-sep"></span>
<div class="pos-control">
<span>Pos</span>
<div class="dpad">
<button id="pos-up" class="tb-btn dpad-up">▲</button>
<div class="dpad-mid">
<button id="pos-left" class="tb-btn">◀</button>
<span id="pos-display" class="pos-display">0, 0</span>
<button id="pos-right" class="tb-btn">▶</button>
</div>
<button id="pos-down" class="tb-btn dpad-down">▼</button>
</div>
<button id="pos-reset" class="tb-btn" style="font-size:0.65rem">Reset</button>
</div>
<span class="toolbar-sep"></span>
<div class="gap-control">
<span>Gap</span>
<button id="gap-down" class="tb-btn">-</button>
<span id="gap-value">0</span>px
<button id="gap-up" class="tb-btn">+</button>
</div>
</div>
Step 2: Add JS logic
In app.js, add DOM references and event handlers:
const fontToolbar = document.getElementById("font-toolbar");
const fontSelect = document.getElementById("font-select");
const sizeSlider = document.getElementById("size-slider");
const sizeValue = document.getElementById("size-value");
const sizeDown = document.getElementById("size-down");
const sizeUp = document.getElementById("size-up");
const posUp = document.getElementById("pos-up");
const posDown = document.getElementById("pos-down");
const posLeft = document.getElementById("pos-left");
const posRight = document.getElementById("pos-right");
const posReset = document.getElementById("pos-reset");
const posDisplay = document.getElementById("pos-display");
const gapDown = document.getElementById("gap-down");
const gapUp = document.getElementById("gap-up");
const gapValue = document.getElementById("gap-value");
Populate fontSelect in loadFonts() (after fonts are loaded):
fontSelect.innerHTML = "";
for (const f of state.fonts.filter(f => f.available)) {
const opt = document.createElement("option");
opt.value = f.id;
opt.textContent = f.name;
fontSelect.appendChild(opt);
}
Show/hide toolbar in openPopover/closePopover:
// In openPopover, after popover.hidden = false:
fontToolbar.hidden = false;
fontSelect.value = r.overrides.fontId;
sizeSlider.value = r.overrides.fontSize;
sizeValue.textContent = r.overrides.fontSize;
gapValue.textContent = Math.round(r.overrides.gapWidth);
updatePosDisplay();
// In closePopover:
fontToolbar.hidden = true;
Event handlers for all toolbar controls:
fontSelect.addEventListener("change", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.fontId = fontSelect.value;
renderCanvas();
});
sizeSlider.addEventListener("input", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.fontSize = parseInt(sizeSlider.value);
sizeValue.textContent = sizeSlider.value;
renderCanvas();
});
sizeDown.addEventListener("click", () => adjustSize(-1));
sizeUp.addEventListener("click", () => adjustSize(1));
function adjustSize(delta) {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.fontSize = Math.max(8, Math.min(120, r.overrides.fontSize + delta));
sizeSlider.value = r.overrides.fontSize;
sizeValue.textContent = r.overrides.fontSize;
renderCanvas();
}
posUp.addEventListener("click", () => nudge(0, -1));
posDown.addEventListener("click", () => nudge(0, 1));
posLeft.addEventListener("click", () => nudge(-1, 0));
posRight.addEventListener("click", () => nudge(1, 0));
function nudge(dx, dy) {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.offsetX += dx;
r.overrides.offsetY += dy;
updatePosDisplay();
renderCanvas();
}
function updatePosDisplay() {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
posDisplay.textContent = `${Math.round(r.overrides.offsetX)}, ${Math.round(r.overrides.offsetY)}`;
}
posReset.addEventListener("click", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides || !r.analysis) return;
r.overrides.offsetX = r.analysis.offset_x || 0;
r.overrides.offsetY = r.analysis.offset_y || 0;
updatePosDisplay();
renderCanvas();
});
gapDown.addEventListener("click", () => adjustGap(-1));
gapUp.addEventListener("click", () => adjustGap(1));
function adjustGap(delta) {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.gapWidth = Math.max(1, r.overrides.gapWidth + delta);
gapValue.textContent = Math.round(r.overrides.gapWidth);
renderCanvas();
}
Step 3: Add CSS
#font-toolbar {
position: absolute;
top: 0.5rem;
left: 0.5rem;
right: 0.5rem;
z-index: 10;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(22, 33, 62, 0.92);
backdrop-filter: blur(6px);
border-radius: 6px;
border: 1px solid #0f3460;
font-size: 0.8rem;
color: #aaa;
}
/* ... plus .tb-btn, .toolbar-sep, .size-control, .pos-control, .dpad, .gap-control styles
(same patterns as the old #font-controls styles from the original CSS) */
Step 4: Commit
git add unredact/static/app.js unredact/static/index.html unredact/static/style.css
git commit -m "feat: add floating font toolbar with size, position, and gap controls"
Task 5: Frontend — bottom text edit bar
Files:
- Modify:
unredact/static/index.html(add bar HTML) - Modify:
unredact/static/app.js(text input logic) - Modify:
unredact/static/style.css(bar styles)
Step 1: Add HTML
In index.html, add inside #right-panel after #doc-container:
<div id="text-edit-bar" hidden>
<input type="text" id="left-text-input" class="seg-input" spellcheck="false" autocomplete="off" placeholder="left text">
<span id="redaction-marker" class="redaction-marker">???</span>
<input type="text" id="right-text-input" class="seg-input" spellcheck="false" autocomplete="off" placeholder="right text">
<button id="text-reset" class="tb-btn" title="Reset to OCR">Reset</button>
</div>
Step 2: Add JS logic
const textEditBar = document.getElementById("text-edit-bar");
const leftTextInput = document.getElementById("left-text-input");
const rightTextInput = document.getElementById("right-text-input");
const redactionMarker = document.getElementById("redaction-marker");
const textReset = document.getElementById("text-reset");
Show/hide in openPopover/closePopover:
// In openPopover:
textEditBar.hidden = false;
leftTextInput.value = r.overrides.leftText;
rightTextInput.value = r.overrides.rightText;
redactionMarker.textContent = r.preview || "???";
// In closePopover:
textEditBar.hidden = true;
Event handlers:
leftTextInput.addEventListener("input", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.leftText = leftTextInput.value;
renderCanvas();
});
rightTextInput.addEventListener("input", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides) return;
r.overrides.rightText = rightTextInput.value;
renderCanvas();
});
textReset.addEventListener("click", () => {
const r = state.redactions[state.activeRedaction];
if (!r?.overrides || !r.analysis) return;
const a = r.analysis;
r.overrides.leftText = a.segments.length > 0 ? a.segments[0].text : "";
r.overrides.rightText = a.segments.length > 1 ? a.segments[1].text : "";
leftTextInput.value = r.overrides.leftText;
rightTextInput.value = r.overrides.rightText;
renderCanvas();
});
Update redactionMarker when preview changes (in handleSolveEvent where preview is set):
redactionMarker.textContent = data.text;
redactionMarker.className = "redaction-marker preview";
Step 3: Add CSS
#text-edit-bar {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
right: 0.5rem;
z-index: 10;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
background: rgba(22, 33, 62, 0.92);
backdrop-filter: blur(6px);
border-radius: 6px;
border: 1px solid #0f3460;
font-size: 0.8rem;
}
.seg-input {
flex: 1;
min-width: 3rem;
background: #0a1628;
color: #e0e0e0;
border: 1px solid #1a3a6e;
border-radius: 4px;
padding: 0.3rem 0.4rem;
font-family: monospace;
font-size: 0.85rem;
}
.seg-input:focus {
outline: none;
border-color: #00d474;
}
.redaction-marker {
flex-shrink: 0;
padding: 0.25rem 0.4rem;
background: #d32f2f;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 3px;
}
.redaction-marker.preview {
background: #b8860b;
color: #ffe082;
}
Step 4: Commit
git add unredact/static/app.js unredact/static/index.html unredact/static/style.css
git commit -m "feat: add bottom text edit bar with editable segment inputs"
Task 6: Wire everything together — popover context updates
Files:
- Modify:
unredact/static/app.js
Step 1: Remove static context display from popover
The popover currently shows a static popoverContext display and popoverFontInfo. These are now redundant — the font toolbar and text edit bar serve those purposes. Remove or simplify the popoverContext and popoverFontInfo population in openPopover(). The popover should now just contain the solver controls.
Optionally keep popoverContext as a compact read-only summary: "...left_text [GAP 150px] right_text..." that updates when text inputs change.
Step 2: Ensure closePopover hides all three UI elements
function closePopover() {
popover.hidden = true;
fontToolbar.hidden = true;
textEditBar.hidden = true;
stopSolve();
}
And when deactivating (clicking away, changing page):
// In loadPage, after state.activeRedaction = null:
closePopover();
Step 3: Ensure activateRedaction shows all controls for already-analyzed
When clicking a redaction that's already analyzed, openPopover should also show the toolbar and text bar with the saved overrides.
Step 4: Commit
git add unredact/static/app.js
git commit -m "refactor: wire font toolbar and text bar into popover lifecycle"
Execution Order
Tasks 1 is backend (independent). Tasks 2-6 are frontend (sequential, each builds on previous).
Dependencies:
- Task 2 depends on Task 1 (uses offset_x/offset_y from response)
- Task 3 depends on Task 2 (uses overrides for rendering)
- Task 4 depends on Task 2 (modifies overrides)
- Task 5 depends on Task 2 (modifies overrides)
- Task 6 depends on Tasks 3-5 (wires them together)