13 tasks covering: OCR caching, spot analysis, detect button, inline canvas editing, drag handles, and full integration. Co-Authored-By: Claude Opus 4.6 <[email protected]>
33 KiB
Redaction Workflow Redesign Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Decouple auto-detection from upload, make double-click (spot) redactions fully analyzed, and move to inline-on-canvas editing.
Architecture: OCR runs at upload time and is cached. Both the "Detect Redactions" button and double-click spot use cached OCR data. A new analyze_spot_redaction() function runs font detection + context extraction on a single known bounding box. The frontend creates spot redactions as "analyzed" with full data, identical to auto-detected ones.
Tech Stack: Python/FastAPI backend, vanilla JS frontend (ES modules), Canvas2D rendering, SSE streaming.
Task 1: Add OCR caching to document state
Files:
- Modify:
unredact/app.py:91-104(upload endpoint, page_data structure) - Test:
tests/test_app.py
Step 1: Modify upload to initialize OCR cache slot
In unredact/app.py, change the page_data structure at lines 91-96 to include an ocr_lines field:
page_data = {}
for i, page_img in enumerate(pages, start=1):
page_data[i] = {
"original": page_img,
"analysis": None,
"ocr_lines": None, # Cached OCR results
}
Step 2: Run test to verify nothing breaks
Run: pytest tests/test_app.py -v
Expected: All existing tests PASS (adding a field doesn't break anything)
Step 3: Commit
git add unredact/app.py
git commit -m "feat: add ocr_lines cache slot to page data structure"
Task 2: Create OCR SSE endpoint
Files:
- Modify:
unredact/app.py(add new endpoint after line 127) - Modify:
unredact/pipeline/ocr.py(exportOcrLineandOcrChar— already exported) - Test:
tests/test_app.py(add test)
Step 1: Write the failing test
Add to tests/test_app.py:
@pytest.mark.anyio
async def test_ocr_endpoint_streams_results(pdf_bytes: bytes):
"""GET /api/doc/{id}/ocr should stream OCR progress and cache results."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/upload",
files={"file": ("test.pdf", pdf_bytes, "application/pdf")},
)
doc_id = resp.json()["doc_id"]
resp = await client.get(f"/api/doc/{doc_id}/ocr")
assert resp.status_code == 200
assert "text/event-stream" in resp.headers.get("content-type", "")
Step 2: Run test to verify it fails
Run: pytest tests/test_app.py::test_ocr_endpoint_streams_results -v
Expected: FAIL (404 or route not found)
Step 3: Implement the OCR SSE endpoint
Add to unredact/app.py after line 127 (after get_font):
@app.get("/api/doc/{doc_id}/ocr")
async def ocr_doc(doc_id: str):
"""SSE endpoint that runs OCR on all pages and caches results."""
doc = _docs.get(doc_id)
if not doc:
return JSONResponse({"error": "not found"}, status_code=404)
async def event_generator():
for page_num, pd in doc["pages"].items():
# Skip if already cached
if pd["ocr_lines"] is not None:
yield json.dumps({
"event": "page_ocr_complete",
"page": page_num,
"num_lines": len(pd["ocr_lines"]),
})
continue
page_img = pd["original"]
try:
lines = await asyncio.to_thread(ocr_page, page_img)
except Exception as exc:
yield json.dumps({
"event": "error",
"page": page_num,
"message": str(exc),
})
continue
pd["ocr_lines"] = lines
yield json.dumps({
"event": "page_ocr_complete",
"page": page_num,
"num_lines": len(lines),
})
yield json.dumps({"event": "ocr_complete"})
return EventSourceResponse(event_generator())
Also add the import at the top of app.py (around line 16):
from unredact.pipeline.ocr import ocr_page, OcrLine
Step 4: Run test to verify it passes
Run: pytest tests/test_app.py::test_ocr_endpoint_streams_results -v
Expected: PASS
Step 5: Commit
git add unredact/app.py tests/test_app.py
git commit -m "feat: add /api/doc/{id}/ocr SSE endpoint for cached OCR"
Task 3: Refactor analyze_page to accept pre-computed OCR
Files:
- Modify:
unredact/pipeline/analyze_page.py:45-69 - Test:
tests/test_analyze_page.py
Step 1: Write the failing test
Add to tests/test_analyze_page.py:
@pytest.mark.anyio
async def test_analyze_page_uses_precomputed_ocr(sample_page_image):
"""analyze_page should use pre-computed OCR lines when provided."""
from unredact.pipeline.ocr import ocr_page
from unredact.pipeline.analyze_page import analyze_page
# Pre-compute OCR
lines = ocr_page(sample_page_image)
assert len(lines) > 0
# Pass pre-computed lines — should not re-run OCR
result = await analyze_page(sample_page_image, ocr_lines=lines)
assert result.lines is lines # Same object, not re-computed
Note: You'll need to check if sample_page_image fixture exists in conftest. If not, create one that rasterizes the sample PDF's first page. Check test_analyze_page.py for existing fixtures.
Step 2: Run test to verify it fails
Run: pytest tests/test_analyze_page.py::test_analyze_page_uses_precomputed_ocr -v
Expected: FAIL (unexpected keyword argument ocr_lines)
Step 3: Modify analyze_page signature
In unredact/pipeline/analyze_page.py, change the function signature at line 45:
async def analyze_page(
page_image: Image.Image,
on_progress: callable | None = None,
ocr_lines: list[OcrLine] | None = None,
) -> PageAnalysis:
And change line 69 from:
lines: list[OcrLine] = await asyncio.to_thread(ocr_page, page_image)
to:
if ocr_lines is not None:
lines = ocr_lines
else:
lines = await asyncio.to_thread(ocr_page, page_image)
Step 4: Run test to verify it passes
Run: pytest tests/test_analyze_page.py::test_analyze_page_uses_precomputed_ocr -v
Expected: PASS
Step 5: Run all tests to verify no regressions
Run: pytest tests/ -v --timeout=120
Expected: All tests PASS
Step 6: Commit
git add unredact/pipeline/analyze_page.py tests/test_analyze_page.py
git commit -m "feat: allow analyze_page to accept pre-computed OCR lines"
Task 4: Wire analyze endpoint to use cached OCR
Files:
- Modify:
unredact/app.py:129-175(analyze_doc endpoint)
Step 1: Modify analyze_doc to use cached OCR
In the event_generator() inside analyze_doc() (line 136-173), change lines 139-140 from:
page_img = pd["original"]
try:
analysis = await analyze_page(page_img)
to:
page_img = pd["original"]
ocr_lines = pd.get("ocr_lines")
try:
analysis = await analyze_page(page_img, ocr_lines=ocr_lines)
Also, after analysis completes (after line 149 pd["analysis"] = analysis), cache the OCR lines if they weren't already:
pd["analysis"] = analysis
# Cache OCR lines if not already cached
if pd.get("ocr_lines") is None:
pd["ocr_lines"] = analysis.lines
Step 2: Run existing tests
Run: pytest tests/test_app.py -v
Expected: All PASS
Step 3: Commit
git add unredact/app.py
git commit -m "feat: wire analyze endpoint to use cached OCR data"
Task 5: Create analyze_spot_redaction function
This is the core new function that runs the analysis mini-pipeline on a single known bounding box using cached OCR data.
Files:
- Modify:
unredact/pipeline/analyze_page.py(add new function) - Test:
tests/test_analyze_page.py(add test)
Step 1: Write the failing test
Add to tests/test_analyze_page.py:
def test_analyze_spot_redaction_returns_analysis(sample_page_image):
"""analyze_spot_redaction should return full analysis for a known bbox."""
from unredact.pipeline.ocr import ocr_page
from unredact.pipeline.detect_redactions import Redaction
from unredact.pipeline.analyze_page import analyze_spot_redaction
lines = ocr_page(sample_page_image)
assert len(lines) > 0
# Use a synthetic redaction box in the middle of the first line
line = lines[0]
box = Redaction(
id="test123",
x=line.x + line.w // 3,
y=line.y,
w=line.w // 3,
h=line.h,
)
result = analyze_spot_redaction(sample_page_image, lines, box)
assert result is not None
assert result.box is box
assert result.line is line
assert result.font is not None
assert result.font.font_size > 0
assert isinstance(result.offset_x, float)
assert isinstance(result.offset_y, float)
Step 2: Run test to verify it fails
Run: pytest tests/test_analyze_page.py::test_analyze_spot_redaction_returns_analysis -v
Expected: FAIL (cannot import analyze_spot_redaction)
Step 3: Implement analyze_spot_redaction
Add to unredact/pipeline/analyze_page.py after the analyze_page function (after line 196):
def analyze_spot_redaction(
page_image: Image.Image,
ocr_lines: list[OcrLine],
box: Redaction,
) -> RedactionAnalysis | None:
"""Run analysis on a single known redaction bounding box.
Uses cached OCR data to:
1. Find the OCR line containing the redaction
2. Detect the font (with redaction masking)
3. Extract left/right context text
4. Compute pixel alignment offsets
Args:
page_image: PIL Image of the document page.
ocr_lines: Pre-computed OCR lines for the page.
box: Known redaction bounding box.
Returns:
RedactionAnalysis or None if no suitable OCR line found.
"""
# Find the OCR line that best contains this redaction box.
# Use vertical overlap: the line whose vertical range overlaps most with the box.
best_line = None
best_overlap = 0
box_top = box.y
box_bottom = box.y + box.h
for line in ocr_lines:
line_top = line.y
line_bottom = line.y + line.h
overlap = max(0, min(box_bottom, line_bottom) - max(box_top, line_top))
if overlap > best_overlap:
best_overlap = overlap
best_line = line
if best_line is None:
log.warning("No OCR line found for spot redaction at (%d,%d)", box.x, box.y)
return None
line = best_line
redaction_boxes = [(box.x, box.y, box.w, box.h)]
# Font detection with masking
font = detect_font_masked(line, page_image, redaction_boxes)
# Extract left/right text using center-point char filtering
left_chars = [c for c in line.chars if c.x + c.w / 2 < box.x]
right_chars = [c for c in line.chars if c.x + c.w / 2 > box.x + box.w]
left_text = "".join(c.text for c in left_chars).strip()
right_text = "".join(c.text for c in right_chars).strip()
# Pixel alignment
offset_x = 0.0
offset_y = 0.0
if left_text:
pil_font = font.to_pil_font()
text_region_x1 = max(0, line.x - 20)
text_region_x2 = min(page_image.width, box.x + 20)
text_region_y1 = max(0, line.y - 10)
text_region_y2 = min(page_image.height, line.y + line.h + 10)
text_crop = np.array(page_image.convert("L").crop(
(text_region_x1, text_region_y1, text_region_x2, text_region_y2)
))
align_dx, align_dy = align_text_to_page(
left_text, pil_font, text_crop,
)
offset_x = float(text_region_x1 + align_dx - line.x)
offset_y = float(text_region_y1 + align_dy - line.y)
return RedactionAnalysis(
box=box,
line=line,
font=font,
left_text=left_text,
right_text=right_text,
offset_x=offset_x,
offset_y=offset_y,
)
Step 4: Run test to verify it passes
Run: pytest tests/test_analyze_page.py::test_analyze_spot_redaction_returns_analysis -v
Expected: PASS
Step 5: Commit
git add unredact/pipeline/analyze_page.py tests/test_analyze_page.py
git commit -m "feat: add analyze_spot_redaction for single-box analysis"
Task 6: Enrich the spot endpoint to return full analysis
Files:
- Modify:
unredact/app.py:459-470(spot endpoint) - Test:
tests/test_app.py
Step 1: Write the failing test
Add to tests/test_app.py:
@pytest.mark.anyio
async def test_spot_returns_analysis(pdf_bytes: bytes):
"""POST /spot should return full analysis data, not just bbox."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
# Upload
resp = await client.post(
"/api/upload",
files={"file": ("test.pdf", pdf_bytes, "application/pdf")},
)
doc_id = resp.json()["doc_id"]
# Run OCR first (required for spot analysis)
resp = await client.get(f"/api/doc/{doc_id}/ocr")
assert resp.status_code == 200
# Get page dimensions to pick a spot
resp = await client.get(f"/api/doc/{doc_id}/page/1/original")
assert resp.status_code == 200
# Try to spot a redaction (we pick center of page — may or may not find one)
resp = await client.post(
f"/api/doc/{doc_id}/page/1/spot",
json={"x": 300, "y": 300},
)
if resp.status_code == 404:
pytest.skip("No redaction found at test coordinates")
data = resp.json()
# Should have bbox fields
assert "id" in data
assert "x" in data
assert "w" in data
# Should ALSO have analysis data
assert "analysis" in data
assert data["analysis"] is not None or data.get("analysis_error") is not None
Step 2: Run test to verify it fails
Run: pytest tests/test_app.py::test_spot_returns_analysis -v
Expected: FAIL ("analysis" not in data)
Step 3: Modify spot endpoint
Replace the spot endpoint in unredact/app.py (lines 459-470) with:
@app.post("/api/doc/{doc_id}/page/{page}/spot")
async def spot(doc_id: str, page: int, data: dict):
doc = _docs.get(doc_id)
if not doc or page not in doc["pages"]:
return JSONResponse({"error": "not found"}, status_code=404)
click_x = int(data["x"])
click_y = int(data["y"])
pd = doc["pages"][page]
page_img = pd["original"]
result = spot_redaction(page_img, click_x, click_y)
if result is None:
return JSONResponse({"error": "no redaction found"}, status_code=404)
# Run analysis if OCR data is available
ocr_lines = pd.get("ocr_lines")
analysis_json = None
if ocr_lines:
ra = await asyncio.to_thread(
analyze_spot_redaction, page_img, ocr_lines, result,
)
if ra is not None:
font_id = _make_font_id(ra.font.font_name)
segments = []
if ra.left_text:
segments.append({"text": ra.left_text})
if ra.right_text:
segments.append({"text": ra.right_text})
analysis_json = {
"segments": segments,
"gap": {"x": ra.box.x, "w": ra.box.w},
"font": {
"name": ra.font.font_name,
"id": font_id,
"size": ra.font.font_size,
"score": ra.font.score,
},
"line": {
"x": ra.line.x,
"y": ra.line.y,
"w": ra.line.w,
"h": ra.line.h,
"text": ra.line.text,
},
"offset_x": ra.offset_x,
"offset_y": ra.offset_y,
}
return {
"id": result.id,
"x": result.x, "y": result.y,
"w": result.w, "h": result.h,
"analysis": analysis_json,
}
Also add the import at the top of app.py:
from unredact.pipeline.analyze_page import analyze_page, analyze_spot_redaction
(Change the existing from unredact.pipeline.analyze_page import analyze_page to include analyze_spot_redaction.)
Step 4: Run test to verify it passes
Run: pytest tests/test_app.py::test_spot_returns_analysis -v
Expected: PASS
Step 5: Run all app tests
Run: pytest tests/test_app.py -v
Expected: All PASS
Step 6: Commit
git add unredact/app.py tests/test_app.py
git commit -m "feat: spot endpoint returns full analysis data"
Task 7: Frontend — Add OCR SSE and remove auto-analyze
Files:
- Modify:
unredact/static/main.js:76-105(uploadFile function) - Modify:
unredact/static/main.js:107-130(remove startAnalysisSSE auto-trigger, keep function for button)
Step 1: Add startOcrSSE function and modify uploadFile
In unredact/static/main.js, add a new function after startAnalysisSSE (around line 131):
function startOcrSSE() {
const es = new EventSource(`/api/doc/${state.docId}/ocr`);
showToast("Running OCR...", "info");
es.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.event === "page_ocr_complete") {
// OCR cached for this page on the backend
} else if (data.event === "ocr_complete") {
es.close();
state.ocrReady = true;
showToast("OCR complete — ready to detect redactions", "success");
if (detectBtn) detectBtn.disabled = false;
} else if (data.event === "error") {
showToast(`OCR error on page ${data.page}: ${data.message}`, "error");
}
};
es.onerror = () => {
es.close();
showToast("OCR connection lost", "error");
};
}
Step 2: Modify uploadFile to call OCR instead of analysis
In uploadFile() (line 76), change line 100 from:
startAnalysisSSE();
to:
startOcrSSE();
Step 3: Add ocrReady to state
In unredact/static/state.js, add to the state object:
ocrReady: false,
Step 4: Commit
git add unredact/static/main.js unredact/static/state.js
git commit -m "feat: run OCR on upload instead of full analysis"
Task 8: Frontend — Add "Detect Redactions" button
Files:
- Modify:
unredact/static/index.html:36-38(left panel) - Modify:
unredact/static/dom.js(add detectBtn export) - Modify:
unredact/static/main.js(import detectBtn, wire click handler)
Step 1: Add button to HTML
In unredact/static/index.html, change lines 36-38 from:
<div id="left-panel">
<div id="redaction-list"></div>
</div>
to:
<div id="left-panel">
<button id="detect-btn" disabled>Detect Redactions</button>
<div id="redaction-list"></div>
</div>
Step 2: Add DOM reference
In unredact/static/dom.js, add after the redactionListEl line (around line 14):
export const detectBtn = document.getElementById("detect-btn");
Step 3: Wire click handler in main.js
In unredact/static/main.js, import detectBtn:
import { dropZone, fileInput, uploadSection, viewerSection, docImage,
canvas, pageInfo, prevBtn, nextBtn, redactionListEl, detectBtn,
rightPanel, fontSelect, solveAccept, gapValue, showToast } from './dom.js';
Add a click handler (after the startOcrSSE function):
if (detectBtn) {
detectBtn.addEventListener("click", () => {
detectBtn.disabled = true;
detectBtn.textContent = "Detecting...";
startAnalysisSSE();
});
}
Also modify startAnalysisSSE to re-enable the button when done. In the done event handler (around line 120-122), add:
if (detectBtn) {
detectBtn.disabled = false;
detectBtn.textContent = "Detect Redactions";
}
Step 4: Add basic styling for the detect button
In unredact/static/index.html or style.css, add styling. Check where existing styles are defined and add:
#detect-btn {
width: 100%;
padding: 8px 12px;
margin-bottom: 8px;
cursor: pointer;
}
#detect-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
Step 5: Commit
git add unredact/static/index.html unredact/static/dom.js unredact/static/main.js
git commit -m "feat: add Detect Redactions button in left panel"
Task 9: Frontend — Make spot redactions first-class
Files:
- Modify:
unredact/static/main.js:309-334(dblclick handler)
Step 1: Update the dblclick handler
Replace lines 309-334 in unredact/static/main.js:
canvas.addEventListener("dblclick", async (e) => {
const rect = rightPanel.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const doc = screenToDoc(sx, sy);
const resp = await fetch(`/api/doc/${state.docId}/page/${state.currentPage}/spot`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ x: Math.round(doc.x), y: Math.round(doc.y) }),
});
if (!resp.ok) {
showToast("No redaction found at that spot", "error");
return;
}
const r = await resp.json();
const redaction = {
id: r.id, x: r.x, y: r.y, w: r.w, h: r.h,
page: state.currentPage,
status: r.analysis ? "analyzed" : "unanalyzed",
analysis: r.analysis || null,
solution: null,
preview: null,
};
if (r.analysis) {
redaction.overrides = {
fontId: r.analysis.font.id,
fontSize: r.analysis.font.size,
offsetX: r.analysis.offset_x || 0,
offsetY: r.analysis.offset_y || 0,
gapWidth: r.analysis.gap.w,
leftText: r.analysis.segments[0]?.text || "",
rightText: r.analysis.segments[1]?.text || "",
};
}
state.redactions[r.id] = redaction;
renderRedactionList();
renderCanvas();
activateRedaction(r.id);
});
This is the key change: the frontend now processes the full analysis data from the spot response, creating the redaction with status: "analyzed" and a populated overrides object — identical to how loadPageData creates auto-detected redactions.
Step 2: Manual verification
Run the app (make run or equivalent), upload a PDF, wait for OCR to complete, then double-click a black bar. Verify:
- The redaction appears as green/analyzed (not blue/unanalyzed)
- The popover opens with font and text data
- The text edit bar populates with left/right context
- Ctrl+drag adjusts offset
- Shift+drag adjusts gap width
- The solver works
Step 3: Commit
git add unredact/static/main.js
git commit -m "feat: spot redactions now return fully analyzed with overrides"
Task 10: Frontend — Inline text editing on canvas
This adds HTML <input> overlays positioned on the canvas at the left/right text locations, so the user feels like they're typing on the document.
Files:
- Modify:
unredact/static/index.html:130-133(add overlay container inside doc-container) - Modify:
unredact/static/dom.js(add new element refs) - Create:
unredact/static/inline-edit.js(new module for inline editing logic) - Modify:
unredact/static/main.js(integrate inline edit) - Modify:
unredact/static/popover.js(sync with inline edits)
Step 1: Add overlay container in HTML
In unredact/static/index.html, after the canvas element (line 132), add:
<div id="inline-edit-layer" style="position:absolute;top:0;left:0;pointer-events:none;transform-origin:0 0;"></div>
The layer sits on top of the canvas inside doc-container, transforms with the same zoom/pan.
Step 2: Create inline-edit.js module
Create unredact/static/inline-edit.js:
/**
* Inline text editing — HTML inputs positioned on the canvas at
* the left/right text locations so users edit directly on the document.
*/
import { state } from './state.js';
import { renderCanvas } from './canvas.js';
const layer = document.getElementById("inline-edit-layer");
let leftInput = null;
let rightInput = null;
/**
* Show inline text inputs for the active redaction.
* @param {string} id - Redaction ID
*/
export function showInlineEdit(id) {
hideInlineEdit();
const r = state.redactions[id];
if (!r?.analysis || !r?.overrides) return;
const a = r.analysis;
const o = r.overrides;
const fontSize = o.fontSize || a.font.size;
const offsetX = o.offsetX || 0;
const offsetY = o.offsetY || 0;
const lineX = a.line.x + offsetX;
const lineY = a.line.y + offsetY;
const gapWidth = o.gapWidth || a.gap.w;
// Measure approximate left text width (rough: fontSize * 0.6 * charCount)
const leftText = o.leftText || "";
const rightText = o.rightText || "";
// Left input: positioned at the start of the line, width up to the gap
leftInput = _createInput(leftText, lineX, lineY, r.x - lineX, a.line.h, fontSize);
leftInput.style.textAlign = "right";
leftInput.addEventListener("input", () => {
r.overrides.leftText = leftInput.value;
renderCanvas();
});
// Right input: positioned after the gap
const rightX = r.x + gapWidth;
const rightW = (a.line.x + a.line.w) - rightX + 20;
rightInput = _createInput(rightText, rightX, lineY, Math.max(50, rightW), a.line.h, fontSize);
rightInput.addEventListener("input", () => {
r.overrides.rightText = rightInput.value;
renderCanvas();
});
layer.appendChild(leftInput);
layer.appendChild(rightInput);
}
/** Remove all inline edit inputs. */
export function hideInlineEdit() {
if (leftInput) { leftInput.remove(); leftInput = null; }
if (rightInput) { rightInput.remove(); rightInput = null; }
}
/** Update input values from state (e.g., after text reset). */
export function syncInlineEdit(id) {
const r = state.redactions[id];
if (!r?.overrides) return;
if (leftInput) leftInput.value = r.overrides.leftText;
if (rightInput) rightInput.value = r.overrides.rightText;
}
function _createInput(value, x, y, w, h, fontSize) {
const input = document.createElement("input");
input.type = "text";
input.value = value;
input.style.cssText = `
position: absolute;
left: ${x}px;
top: ${y}px;
width: ${Math.max(30, w)}px;
height: ${h}px;
font-size: ${fontSize}px;
background: rgba(255,255,255,0.7);
border: 1px solid rgba(0,180,0,0.5);
padding: 0 2px;
pointer-events: auto;
box-sizing: border-box;
outline: none;
font-family: sans-serif;
`;
return input;
}
Step 3: Integrate into main.js
In main.js, import the inline edit functions:
import { showInlineEdit, hideInlineEdit, syncInlineEdit } from './inline-edit.js';
In activateRedaction() (around line 287-289), after openPopover(id), add:
showInlineEdit(id);
In closePopover calls throughout main.js, also call hideInlineEdit(). Or better: in the setOnPopoverClose callback setup (line 462), add hideInlineEdit:
setOnPopoverClose(() => { stopSolve(); hideInlineEdit(); });
Step 4: Sync inline edits with text edit bar
In popover.js, the leftTextInput and rightTextInput input event handlers (lines 138-150) should also update inline inputs. Import syncInlineEdit and call it:
import { syncInlineEdit } from './inline-edit.js';
In the leftTextInput handler (line 138-143), after setting r.overrides.leftText, add:
syncInlineEdit(state.activeRedaction);
Same for rightTextInput handler (line 145-150).
Same for textReset handler (line 152-161).
Step 5: Commit
git add unredact/static/inline-edit.js unredact/static/index.html unredact/static/dom.js unredact/static/main.js unredact/static/popover.js
git commit -m "feat: inline text editing on canvas for redaction context"
Task 11: Frontend — Drag handles for bbox resize
Files:
- Modify:
unredact/static/main.js(add drag handle logic near existing modDrag) - Modify:
unredact/static/canvas.js(draw resize handles when active)
Step 1: Draw resize handles in canvas.js
Add a new function to canvas.js:
function drawResizeHandles(r) {
const sz = 6;
ctx.fillStyle = "rgba(0, 120, 255, 0.8)";
// Left edge
ctx.fillRect(r.x - sz/2, r.y + r.h/2 - sz/2, sz, sz);
// Right edge
ctx.fillRect(r.x + r.w - sz/2, r.y + r.h/2 - sz/2, sz, sz);
// Top edge
ctx.fillRect(r.x + r.w/2 - sz/2, r.y - sz/2, sz, sz);
// Bottom edge
ctx.fillRect(r.x + r.w/2 - sz/2, r.y + r.h - sz/2, sz, sz);
}
Call drawResizeHandles(r) in renderCanvas() after drawing the active redaction (inside the loop, when r.id === state.activeRedaction).
Step 2: Add resize drag handling in main.js
Add a new drag handler (alongside existing modDrag) for bbox resize. When the user clicks near a handle (within ~8px in doc coords), start a resize drag:
let resizeDrag = null;
canvas.addEventListener("mousedown", (e) => {
if (e.button !== 0 || e.ctrlKey || e.shiftKey) return;
const r = state.redactions[state.activeRedaction];
if (!r) return;
const rect = rightPanel.getBoundingClientRect();
const doc = screenToDoc(e.clientX - rect.left, e.clientY - rect.top);
const threshold = 8 / state.zoom;
// Check if near an edge handle
let edge = null;
if (Math.abs(doc.x - r.x) < threshold && Math.abs(doc.y - (r.y + r.h/2)) < threshold) edge = "left";
else if (Math.abs(doc.x - (r.x + r.w)) < threshold && Math.abs(doc.y - (r.y + r.h/2)) < threshold) edge = "right";
else if (Math.abs(doc.y - r.y) < threshold && Math.abs(doc.x - (r.x + r.w/2)) < threshold) edge = "top";
else if (Math.abs(doc.y - (r.y + r.h)) < threshold && Math.abs(doc.x - (r.x + r.w/2)) < threshold) edge = "bottom";
if (!edge) return;
resizeDrag = { edge, startX: e.clientX, startY: e.clientY, origX: r.x, origY: r.y, origW: r.w, origH: r.h };
e.stopPropagation();
e.preventDefault();
}, { capture: true });
window.addEventListener("mousemove", (e) => {
if (!resizeDrag) return;
const r = state.redactions[state.activeRedaction];
if (!r) return;
const dx = (e.clientX - resizeDrag.startX) / state.zoom;
const dy = (e.clientY - resizeDrag.startY) / state.zoom;
if (resizeDrag.edge === "left") {
r.x = Math.round(resizeDrag.origX + dx);
r.w = Math.max(10, Math.round(resizeDrag.origW - dx));
} else if (resizeDrag.edge === "right") {
r.w = Math.max(10, Math.round(resizeDrag.origW + dx));
} else if (resizeDrag.edge === "top") {
r.y = Math.round(resizeDrag.origY + dy);
r.h = Math.max(10, Math.round(resizeDrag.origH - dy));
} else if (resizeDrag.edge === "bottom") {
r.h = Math.max(10, Math.round(resizeDrag.origH + dy));
}
// Update gap width in overrides to match
if (r.overrides && (resizeDrag.edge === "left" || resizeDrag.edge === "right")) {
r.overrides.gapWidth = r.w;
gapValue.textContent = String(Math.round(r.w));
}
renderCanvas();
});
window.addEventListener("mouseup", () => {
if (resizeDrag) resizeDrag = null;
});
Step 3: Manual verification
Run the app, create a redaction, verify:
- Small blue squares appear at the edges of the active redaction
- Dragging left/right edges changes width
- Dragging top/bottom edges changes height
- Gap width display updates when width changes
Step 4: Commit
git add unredact/static/main.js unredact/static/canvas.js
git commit -m "feat: drag handles for resizing redaction bounding boxes"
Task 12: Style the Detect Redactions button and polish
Files:
- Modify:
unredact/static/index.htmlor CSS file (style the button) - Modify:
unredact/static/main.js(clean up any loose ends)
Step 1: Style the button
Check how existing styles are defined (inline in HTML or in a CSS file). Match the existing visual style. The button should be prominent but not overwhelming.
Step 2: Clean up OCR status indicator
Add a small status line near the detect button showing OCR progress:
<div id="ocr-status" style="font-size:12px;color:#888;margin-bottom:4px;"></div>
Update startOcrSSE to show page-by-page progress in this element.
Step 3: Handle edge case: double-click before OCR completes
In the dblclick handler, if !state.ocrReady, still allow the spot (it will return analysis: null), but show a toast: "OCR still processing — redaction created without analysis. Try again after OCR completes."
Step 4: Commit
git add unredact/static/index.html unredact/static/main.js
git commit -m "style: polish detect button, OCR status, and edge cases"
Task 13: Run full test suite and verify
Files: None (verification only)
Step 1: Run all Python tests
Run: pytest tests/ -v --timeout=120
Expected: All PASS
Step 2: Run the app end-to-end
Run: make run (or equivalent)
Verify the complete workflow:
- Upload a PDF
- OCR runs automatically, "Detect Redactions" button enables
- Double-click a black bar → redaction appears analyzed with popover
- Text edit bar shows left/right context
- Inline text inputs appear on the canvas
- Ctrl+drag adjusts offset, Shift+drag adjusts gap
- Drag handles resize the box
- Solver runs and returns results
- Click "Detect Redactions" → full LLM pipeline runs, all redactions appear
- Delete key removes a redaction
- Page navigation works
- Export (Ctrl+E) includes all data
Step 3: Commit any fixes
git add -A && git commit -m "fix: address issues found in end-to-end verification"