attempting to make redaction guessing better

This commit is contained in:
Alex-Gilbert committed 2026-02-18 10:35:42 -06:00
1 parent cddbf74492
commit a5505c1fbb
17 files changed
+19102 -65

No files matched your search

+1
View File
@@ -6,5 +6,6 @@ dist/
build/
.worktrees/
.pids/
.env
solver_rs/target/
scripts/.cache/
+18 -3
View File
@@ -1,4 +1,4 @@
.PHONY: build build-solver run stop restart solver app logs status clean dev kill build-associates build-emails
.PHONY: build build-solver run stop restart solver app logs status clean dev kill build-associates build-emails check-api-key
SOLVER_BIN = solver_rs/target/release/unredact-solver
SOLVER_PORT ?= 3100
@@ -9,6 +9,10 @@ VENV = .venv/bin
PYTHON = $(VENV)/python
UVICORN = $(VENV)/uvicorn
# Load .env if it exists
-include .env
export ANTHROPIC_API_KEY
$(PID_DIR):
mkdir -p $(PID_DIR)
@@ -19,6 +23,17 @@ build-solver:
build: build-solver
# ── API key ──
check-api-key:
@if [ -z "$(ANTHROPIC_API_KEY)" ]; then \
printf "Enter your Anthropic API key (from console.anthropic.com): "; \
read key; \
echo "ANTHROPIC_API_KEY=$$key" > .env; \
echo "Saved to .env — run make again."; \
exit 1; \
fi
# ── Run (background, use `make logs` to see output) ──
solver: $(PID_DIR) build-solver
@@ -37,7 +52,7 @@ solver: $(PID_DIR) build-solver
fi \
fi
app: $(PID_DIR) solver
app: check-api-key $(PID_DIR) solver
@if [ -f $(PID_DIR)/app.pid ] && kill -0 $$(cat $(PID_DIR)/app.pid) 2>/dev/null; then \
echo "App already running (pid $$(cat $(PID_DIR)/app.pid))"; \
else \
@@ -56,7 +71,7 @@ app: $(PID_DIR) solver
# ── Run with live logs (foreground, Ctrl-C stops everything) ──
run: $(PID_DIR) build-solver
run: check-api-key $(PID_DIR) build-solver
@SOLVER_PORT=$(SOLVER_PORT) DATA_DIR=$(DATA_DIR) $(SOLVER_BIN) > $(PID_DIR)/solver.log 2>&1 & \
echo $$! > $(PID_DIR)/solver.pid; \
sleep 0.5; \
+329
View File
@@ -0,0 +1,329 @@
{
"docId": "daec25840655",
"pageCount": 2,
"pages": {
"1": [
{
"id": "5e276b48",
"x": 757,
"y": 741,
"w": 401,
"h": 52,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": -3,
"offsetY": 6,
"gapWidth": 402,
"leftText": "On Aug 27, 2012, at 2:02 PM, ",
"rightText": " wrote:"
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.6055935578837994
},
"gap": {
"x": 757,
"w": 401
},
"line": {
"x": 141,
"y": 739,
"w": 1154,
"h": 57,
"text": "On Aug 27, 2012, at 2:02 PM, — wrote:"
},
"segments": [
{
"text": "On Aug 27, 2012, at 2:02 PM,"
},
{
"text": "wrote:"
}
],
"offset_x": -1,
"offset_y": 0
}
},
{
"id": "2b001506",
"x": 393,
"y": 903,
"w": 64,
"h": 52,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": -4,
"offsetY": 11,
"gapWidth": 64,
"leftText": "Got it. Sent ",
"rightText": " an email."
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.6231324492194057
},
"gap": {
"x": 393,
"w": 64
},
"line": {
"x": 157,
"y": 897,
"w": 492,
"h": 66,
"text": "Got it. Sent[ an email."
},
"segments": [
{
"text": "Got it. Se"
},
{
"text": "an email."
}
],
"offset_x": -2,
"offset_y": 0
}
},
{
"id": "da6ec78e",
"x": 434,
"y": 2029,
"w": 120,
"h": 51,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": -4,
"offsetY": 6,
"gapWidth": 122,
"leftText": "If you can let ",
"rightText": " or"
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.7184559531747796
},
"gap": {
"x": 434,
"w": 120
},
"line": {
"x": 159,
"y": 2027,
"w": 2071,
"h": 55,
"text": "If you can let ]l or ] know what to do with the tuna that would be great. i} is still on the ranch"
},
"segments": [
{
"text": "If you can let"
},
{
"text": "or ] know what to do with the tuna that would be great. i} is still on the ranch"
}
],
"offset_x": -4,
"offset_y": 0
}
},
{
"id": "aed57ad3",
"x": 623,
"y": 2029,
"w": 64,
"h": 51,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": 408.8804627529084,
"offsetY": 6.328932996818563,
"gapWidth": 67,
"leftText": "or ",
"rightText": " know what to do with the tuna that would be great. "
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.7184559531747796
},
"gap": {
"x": 623,
"w": 64
},
"line": {
"x": 159,
"y": 2027,
"w": 2071,
"h": 55,
"text": "If you can let ]l or ] know what to do with the tuna that would be great. i} is still on the ranch"
},
"segments": [
{
"text": "If you can let ]l or"
},
{
"text": "know what to do with the tuna that would be great. i} is still on the ranch"
}
],
"offset_x": -4,
"offset_y": 0
}
},
{
"id": "dba911fb",
"x": 1734,
"y": 2029,
"w": 105,
"h": 51,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": 1448.6845532639488,
"offsetY": 5.996294050598364,
"gapWidth": 107,
"leftText": "great. ",
"rightText": " is still on the ranch"
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.7184559531747796
},
"gap": {
"x": 1734,
"w": 105
},
"line": {
"x": 159,
"y": 2027,
"w": 2071,
"h": 55,
"text": "If you can let ]l or ] know what to do with the tuna that would be great. i} is still on the ranch"
},
"segments": [
{
"text": "If you can let ]l or ] know what to do with the tuna that would be great."
},
{
"text": "is still on the ranch"
}
],
"offset_x": -23,
"offset_y": 0
}
},
{
"id": "8284779b",
"x": 798,
"y": 2085,
"w": 401,
"h": 52,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": -4.2575926823615635,
"offsetY": 9.674352435350166,
"gapWidth": 403,
"leftText": "On Aug 27, 2012, at 12:52 PM, ",
"rightText": " wrote:"
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.4268292682926829
},
"gap": {
"x": 798,
"w": 401
},
"line": {
"x": 157,
"y": 2079,
"w": 1180,
"h": 65,
"text": "On Aug 27, 2012, at 12:52 PM, [G ot"
},
"segments": [
{
"text": "On Aug 27, 2012, at 12:52 PM,"
},
{
"text": "ot"
}
],
"offset_x": -2,
"offset_y": -13
}
}
],
"2": [
{
"id": "05cc88f7",
"x": 546,
"y": 171,
"w": 400,
"h": 53,
"status": "analyzed",
"overrides": {
"fontId": "dejavu-sans",
"fontSize": 66,
"offsetX": -12,
"offsetY": -20,
"gapWidth": 400,
"leftText": "To: francis derby-",
"rightText": ""
},
"analysis": {
"font": {
"name": "DejaVu Sans",
"id": "dejavu-sans",
"size": 66,
"score": 0.18376703041260412
},
"gap": {
"x": 546,
"w": 400
},
"line": {
"x": 172,
"y": 132,
"w": 803,
"h": 99,
"text": "To: francis derby--"
},
"segments": [
{
"text": "To: francis derby-"
}
],
"offset_x": -12,
"offset_y": -20
}
}
]
}
}
+136
View File
@@ -0,0 +1,136 @@
"""Capture OCR + LLM output from a PDF for use as test fixtures.
Usage:
python scripts/capture_llm_fixture.py /path/to/document.pdf [page_num]
Outputs JSON to stdout with:
- ocr_lines: serialized OCR lines with char positions
- llm_prompt: the prompt sent to the LLM
- llm_tool_input: the raw tool_use input from the LLM response
- llm_redactions: parsed LlmRedaction objects
"""
import asyncio
import json
import os
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
# Load .env manually (no dotenv dependency)
env_path = Path(__file__).parent.parent / ".env"
if env_path.exists():
for _line in env_path.read_text().splitlines():
_line = _line.strip()
if _line and not _line.startswith("#") and "=" in _line:
_k, _v = _line.split("=", 1)
os.environ.setdefault(_k.strip(), _v.strip())
from unredact.pipeline.rasterize import rasterize_pdf
from unredact.pipeline.ocr import ocr_page
from unredact.pipeline.llm_detect import (
_build_prompt, _parse_response, _get_client, _TOOL,
)
import os
def serialize_ocr_lines(lines):
"""Convert OCR lines to JSON-serializable dicts."""
result = []
for line in lines:
chars = []
for c in line.chars:
chars.append({
"text": c.text,
"x": c.x, "y": c.y,
"w": c.w, "h": c.h,
"conf": c.conf,
})
result.append({
"chars": chars,
"x": line.x, "y": line.y,
"w": line.w, "h": line.h,
"text": line.text,
})
return result
async def main():
pdf_path = Path(sys.argv[1])
page_num = int(sys.argv[2]) if len(sys.argv) > 2 else 1
print(f"Rasterizing {pdf_path}...", file=sys.stderr)
pages = rasterize_pdf(pdf_path)
if page_num > len(pages):
print(f"Only {len(pages)} pages in PDF", file=sys.stderr)
sys.exit(1)
page_img = pages[page_num - 1]
print(f"Page {page_num}: {page_img.size}", file=sys.stderr)
print("Running OCR...", file=sys.stderr)
lines = ocr_page(page_img)
print(f"Found {len(lines)} lines", file=sys.stderr)
prompt = _build_prompt(lines)
print(f"Prompt length: {len(prompt)} chars", file=sys.stderr)
model = os.environ.get("UNREDACT_LLM_MODEL", "claude-haiku-4-5-20251001")
print(f"Calling {model}...", file=sys.stderr)
client = _get_client()
response = await client.messages.create(
model=model,
max_tokens=2048,
tools=[_TOOL],
tool_choice={"type": "tool", "name": "report_redactions"},
messages=[{"role": "user", "content": prompt}],
)
# Extract tool_use block
tool_input = None
for block in response.content:
if block.type == "tool_use" and block.name == "report_redactions":
tool_input = block.input
break
if tool_input is None:
print("ERROR: No tool_use block in response", file=sys.stderr)
sys.exit(1)
# Parse redactions
redactions = _parse_response(tool_input, lines)
print(f"LLM found {len(tool_input['redactions'])} redactions", file=sys.stderr)
print(f"Parsed to {len(redactions)} with valid positions", file=sys.stderr)
# Dump fixture
fixture = {
"source_pdf": pdf_path.name,
"page": page_num,
"ocr_lines": serialize_ocr_lines(lines),
"llm_prompt": prompt,
"llm_tool_input": tool_input,
"llm_redactions": [
{
"line_index": r.line_index,
"left_word": r.left_word,
"right_word": r.right_word,
"left_x": r.left_x,
"right_x": r.right_x,
"line_y": r.line_y,
"line_h": r.line_h,
}
for r in redactions
],
}
json.dump(fixture, sys.stdout, indent=2)
print(file=sys.stderr)
print("Done!", file=sys.stderr)
if __name__ == "__main__":
asyncio.run(main())
+329
View File
@@ -0,0 +1,329 @@
{
"docId": "daec25840655",
"pageCount": 2,
"pages": {
"1": [
{
"id": "5e276b48",
"x": 757,
"y": 741,
"w": 401,
"h": 52,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": -3,
"offsetY": 6,
"gapWidth": 402,
"leftText": "On Aug 27, 2012, at 2:02 PM, ",
"rightText": " wrote:"
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.6055935578837994
},
"gap": {
"x": 757,
"w": 401
},
"line": {
"x": 141,
"y": 739,
"w": 1154,
"h": 57,
"text": "On Aug 27, 2012, at 2:02 PM, — wrote:"
},
"segments": [
{
"text": "On Aug 27, 2012, at 2:02 PM,"
},
{
"text": "wrote:"
}
],
"offset_x": -1,
"offset_y": 0
}
},
{
"id": "2b001506",
"x": 393,
"y": 903,
"w": 64,
"h": 52,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": -4,
"offsetY": 11,
"gapWidth": 64,
"leftText": "Got it. Sent ",
"rightText": " an email."
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.6231324492194057
},
"gap": {
"x": 393,
"w": 64
},
"line": {
"x": 157,
"y": 897,
"w": 492,
"h": 66,
"text": "Got it. Sent[ an email."
},
"segments": [
{
"text": "Got it. Se"
},
{
"text": "an email."
}
],
"offset_x": -2,
"offset_y": 0
}
},
{
"id": "da6ec78e",
"x": 434,
"y": 2029,
"w": 120,
"h": 51,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": -4,
"offsetY": 6,
"gapWidth": 122,
"leftText": "If you can let ",
"rightText": " or"
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.7184559531747796
},
"gap": {
"x": 434,
"w": 120
},
"line": {
"x": 159,
"y": 2027,
"w": 2071,
"h": 55,
"text": "If you can let ]l or ] know what to do with the tuna that would be great. i} is still on the ranch"
},
"segments": [
{
"text": "If you can let"
},
{
"text": "or ] know what to do with the tuna that would be great. i} is still on the ranch"
}
],
"offset_x": -4,
"offset_y": 0
}
},
{
"id": "aed57ad3",
"x": 623,
"y": 2029,
"w": 64,
"h": 51,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": 408.8804627529084,
"offsetY": 6.328932996818563,
"gapWidth": 67,
"leftText": "or ",
"rightText": " know what to do with the tuna that would be great. "
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.7184559531747796
},
"gap": {
"x": 623,
"w": 64
},
"line": {
"x": 159,
"y": 2027,
"w": 2071,
"h": 55,
"text": "If you can let ]l or ] know what to do with the tuna that would be great. i} is still on the ranch"
},
"segments": [
{
"text": "If you can let ]l or"
},
{
"text": "know what to do with the tuna that would be great. i} is still on the ranch"
}
],
"offset_x": -4,
"offset_y": 0
}
},
{
"id": "dba911fb",
"x": 1734,
"y": 2029,
"w": 105,
"h": 51,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": 1448.6845532639488,
"offsetY": 5.996294050598364,
"gapWidth": 107,
"leftText": "great. ",
"rightText": " is still on the ranch"
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.7184559531747796
},
"gap": {
"x": 1734,
"w": 105
},
"line": {
"x": 159,
"y": 2027,
"w": 2071,
"h": 55,
"text": "If you can let ]l or ] know what to do with the tuna that would be great. i} is still on the ranch"
},
"segments": [
{
"text": "If you can let ]l or ] know what to do with the tuna that would be great."
},
{
"text": "is still on the ranch"
}
],
"offset_x": -23,
"offset_y": 0
}
},
{
"id": "8284779b",
"x": 798,
"y": 2085,
"w": 401,
"h": 52,
"status": "analyzed",
"overrides": {
"fontId": "times-new-roman",
"fontSize": 50,
"offsetX": -4.2575926823615635,
"offsetY": 9.674352435350166,
"gapWidth": 403,
"leftText": "On Aug 27, 2012, at 12:52 PM, ",
"rightText": " wrote:"
},
"analysis": {
"font": {
"name": "Times New Roman",
"id": "times-new-roman",
"size": 50,
"score": 0.4268292682926829
},
"gap": {
"x": 798,
"w": 401
},
"line": {
"x": 157,
"y": 2079,
"w": 1180,
"h": 65,
"text": "On Aug 27, 2012, at 12:52 PM, [G ot"
},
"segments": [
{
"text": "On Aug 27, 2012, at 12:52 PM,"
},
{
"text": "ot"
}
],
"offset_x": -2,
"offset_y": -13
}
}
],
"2": [
{
"id": "05cc88f7",
"x": 546,
"y": 171,
"w": 400,
"h": 53,
"status": "analyzed",
"overrides": {
"fontId": "dejavu-sans",
"fontSize": 66,
"offsetX": -12,
"offsetY": -20,
"gapWidth": 400,
"leftText": "To: francis derby-",
"rightText": ""
},
"analysis": {
"font": {
"name": "DejaVu Sans",
"id": "dejavu-sans",
"size": 66,
"score": 0.18376703041260412
},
"gap": {
"x": 546,
"w": 400
},
"line": {
"x": 172,
"y": 132,
"w": 803,
"h": 99,
"text": "To: francis derby--"
},
"segments": [
{
"text": "To: francis derby-"
}
],
"offset_x": -12,
"offset_y": -20
}
}
]
}
}
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

File diff suppressed because it is too large. Load diff
+147
View File
@@ -0,0 +1,147 @@
"""Font detection tests using real document data.
Uses captured OCR + LLM fixtures from EFTA00554620.pdf to verify that
font detection returns Times New Roman (or Liberation Serif) at ~50px
for the body text.
"""
import json
from pathlib import Path
import numpy as np
import pytest
from PIL import Image
from unredact.pipeline.font_detect import (
detect_font_for_line,
detect_font_masked,
_find_font_path,
)
from unredact.pipeline.ocr import OcrChar, OcrLine
FIXTURES = Path(__file__).parent / "fixtures"
PAGE1_JSON = FIXTURES / "EFTA00554620_page1.json"
PAGE1_PNG = FIXTURES / "EFTA00554620_page1.png"
def _load_fixture():
"""Load OCR lines from the JSON fixture as OcrLine objects."""
data = json.loads(PAGE1_JSON.read_text())
lines = []
for ld in data["ocr_lines"]:
chars = [
OcrChar(text=c["text"], x=c["x"], y=c["y"], w=c["w"], h=c["h"], conf=c["conf"])
for c in ld["chars"]
]
lines.append(OcrLine(chars=chars, x=ld["x"], y=ld["y"], w=ld["w"], h=ld["h"]))
return data, lines
def _load_page_image():
return Image.open(PAGE1_PNG)
def _is_serif(name: str) -> bool:
"""Check if the font name is a serif font."""
return any(s in name for s in ("Times", "Liberation Serif", "Georgia", "DejaVu Serif"))
# Skip all tests if fixtures missing
pytestmark = pytest.mark.skipif(
not PAGE1_JSON.exists() or not PAGE1_PNG.exists(),
reason="Real document fixtures not available",
)
class TestCleanLines:
"""Font detection on lines without redactions."""
def test_date_line(self):
"""Line 3: 'Date: Mon, 27 Aug 2012 18:57:08 +0000'"""
data, lines = _load_fixture()
page_img = _load_page_image()
line = lines[3]
result = detect_font_for_line(line, page_img)
assert _is_serif(result.font_name), f"Expected serif, got {result.font_name}"
assert 45 <= result.font_size <= 55, f"Expected ~50px, got {result.font_size}"
def test_body_text_line(self):
"""Line 10: 'I need to know if he's out of jerky...'"""
data, lines = _load_fixture()
page_img = _load_page_image()
line = lines[10]
result = detect_font_for_line(line, page_img)
assert _is_serif(result.font_name), f"Expected serif, got {result.font_name}"
assert 45 <= result.font_size <= 55, f"Expected ~50px, got {result.font_size}"
def test_short_clean_line(self):
"""Line 5: 'through it like crazy'"""
data, lines = _load_fixture()
page_img = _load_page_image()
line = lines[5]
result = detect_font_for_line(line, page_img)
assert _is_serif(result.font_name), f"Expected serif, got {result.font_name}"
assert 45 <= result.font_size <= 55, f"Expected ~50px, got {result.font_size}"
class TestMaskedLines:
"""Font detection on lines with redactions, using LLM output."""
def _get_redaction_boxes_for_line(self, data, line_index):
"""Get approximate redaction boxes from LLM output for a given line."""
boxes = []
for r in data["llm_redactions"]:
if r["line_index"] == line_index:
line_data = data["ocr_lines"][line_index]
# Use LLM's x range as the redaction box
x = r["left_x"]
w = r["right_x"] - r["left_x"]
y = line_data["y"]
h = line_data["h"]
if w > 0:
boxes.append((x, y, w, h))
return boxes
def test_got_it_sent_line(self):
"""Line 7: 'Got it. Sent[ an email.' — the screenshot case."""
data, lines = _load_fixture()
page_img = _load_page_image()
line = lines[7]
boxes = self._get_redaction_boxes_for_line(data, 7)
assert len(boxes) > 0, "Expected redaction boxes for line 7"
result = detect_font_masked(line, page_img, boxes)
assert _is_serif(result.font_name), f"Expected serif, got {result.font_name}"
assert 45 <= result.font_size <= 55, f"Expected ~50px, got {result.font_size}"
def test_from_line_with_redaction(self):
"""Line 0: 'From: |-' — redacted email address."""
data, lines = _load_fixture()
page_img = _load_page_image()
line = lines[0]
boxes = self._get_redaction_boxes_for_line(data, 0)
assert len(boxes) > 0, "Expected redaction boxes for line 0"
result = detect_font_masked(line, page_img, boxes)
assert _is_serif(result.font_name), f"Expected serif, got {result.font_name}"
assert 45 <= result.font_size <= 55, f"Expected ~50px, got {result.font_size}"
def test_on_aug_line(self):
"""Line 6: 'On Aug 27, 2012, at 2:02 PM, — wrote:' — redacted name."""
data, lines = _load_fixture()
page_img = _load_page_image()
line = lines[6]
boxes = self._get_redaction_boxes_for_line(data, 6)
assert len(boxes) > 0, "Expected redaction boxes for line 6"
result = detect_font_masked(line, page_img, boxes)
assert _is_serif(result.font_name), f"Expected serif, got {result.font_name}"
assert 45 <= result.font_size <= 55, f"Expected ~50px, got {result.font_size}"
def test_let_or_know_line(self):
"""Line 19: multiple redactions — 'let ]l or ] know what to do...'"""
data, lines = _load_fixture()
page_img = _load_page_image()
line = lines[19]
boxes = self._get_redaction_boxes_for_line(data, 19)
assert len(boxes) >= 2, f"Expected multiple redaction boxes, got {len(boxes)}"
result = detect_font_masked(line, page_img, boxes)
assert _is_serif(result.font_name), f"Expected serif, got {result.font_name}"
assert 45 <= result.font_size <= 55, f"Expected ~50px, got {result.font_size}"
+121
View File
@@ -0,0 +1,121 @@
"""Pipeline tests against hand-annotated ground truth.
Uses EFTA00554620 page 1 ground truth (header lines excluded).
Validates that the pipeline produces correct bounding boxes, font
detection, and text segmentation for body-text redactions.
"""
import json
from pathlib import Path
import pytest
from PIL import Image
from unredact.pipeline.ocr import OcrChar, OcrLine
from unredact.pipeline.font_detect import detect_font_masked, _find_font_path
from unredact.pipeline.detect_redactions import find_redaction_in_region
FIXTURES = Path(__file__).parent / "fixtures"
GROUND_TRUTH = FIXTURES / "EFTA00554620_ground_truth.json"
PAGE1_JSON = FIXTURES / "EFTA00554620_page1.json"
PAGE1_PNG = FIXTURES / "EFTA00554620_page1.png"
pytestmark = pytest.mark.skipif(
not all(p.exists() for p in [GROUND_TRUTH, PAGE1_JSON, PAGE1_PNG]),
reason="Fixtures not available",
)
def _load_ground_truth_page1():
data = json.loads(GROUND_TRUTH.read_text())
return data["pages"]["1"]
def _load_ocr_lines():
data = json.loads(PAGE1_JSON.read_text())
lines = []
for ld in data["ocr_lines"]:
chars = [
OcrChar(text=c["text"], x=c["x"], y=c["y"], w=c["w"], h=c["h"], conf=c["conf"])
for c in ld["chars"]
]
lines.append(OcrLine(chars=chars, x=ld["x"], y=ld["y"], w=ld["w"], h=ld["h"]))
return data, lines
def _load_page_image():
return Image.open(PAGE1_PNG)
class TestBoundingBoxes:
"""Verify that guided OpenCV finds boxes matching ground truth."""
@pytest.fixture
def page_img(self):
return _load_page_image()
@pytest.fixture
def ground_truth(self):
return _load_ground_truth_page1()
@pytest.mark.parametrize("idx", range(6))
def test_box_detection(self, page_img, ground_truth, idx):
gt = ground_truth[idx]
# Search in a generous region around the ground truth box
pad = gt["h"]
box = find_redaction_in_region(
page_img,
gt["x"], gt["y"],
gt["x"] + gt["w"], gt["y"] + gt["h"],
padding=pad,
)
assert box is not None, f"No box found for redaction at ({gt['x']}, {gt['y']})"
# Box should be close to ground truth (within 15px on each edge)
assert abs(box.x - gt["x"]) < 15, f"x off: {box.x} vs {gt['x']}"
assert abs(box.y - gt["y"]) < 15, f"y off: {box.y} vs {gt['y']}"
assert abs(box.w - gt["w"]) < 30, f"w off: {box.w} vs {gt['w']}"
assert abs(box.h - gt["h"]) < 15, f"h off: {box.h} vs {gt['h']}"
class TestFontDetection:
"""Verify font detection matches ground truth on redacted lines."""
@pytest.fixture
def page_img(self):
return _load_page_image()
@pytest.fixture
def ocr_data(self):
return _load_ocr_lines()
@pytest.fixture
def ground_truth(self):
return _load_ground_truth_page1()
def _find_ocr_line_for_gt(self, lines, gt):
"""Find the OCR line that contains this ground truth redaction."""
gy = gt["y"] + gt["h"] / 2
for line in lines:
if line.y <= gy <= line.y + line.h:
return line
return None
@pytest.mark.parametrize("idx", range(6))
def test_font_match(self, page_img, ocr_data, ground_truth, idx):
gt = ground_truth[idx]
expected_font = gt["overrides"]["fontId"]
expected_size = gt["overrides"]["fontSize"]
_, lines = ocr_data
line = self._find_ocr_line_for_gt(lines, gt)
assert line is not None, f"No OCR line found for redaction at y={gt['y']}"
boxes = [(gt["x"], gt["y"], gt["w"], gt["h"])]
result = detect_font_masked(line, page_img, boxes)
# Font family should be serif (TNR or Liberation Serif)
is_serif = any(s in result.font_name for s in ("Times", "Liberation Serif", "Georgia", "DejaVu Serif"))
assert is_serif, f"Expected serif font, got {result.font_name}"
assert abs(result.font_size - expected_size) <= 5, (
f"Expected size ~{expected_size}, got {result.font_size}"
)
+5 -3
View File
@@ -2,7 +2,7 @@
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from unredact.pipeline.font_detect import _find_font_path, _full_search
from unredact.pipeline.font_detect import _find_font_path, _full_search, _score_font_line_pixel
from unredact.pipeline.ocr import OcrChar, OcrLine
@@ -61,7 +61,8 @@ def test_full_search_finds_correct_serif():
text = "Got it. Sent an email."
line, crop = _make_line_and_crop("Times New Roman", 50, text)
best = _full_search(line, crop)
scorer = lambda font: _score_font_line_pixel(font, line, crop)
best = _full_search(line.h, scorer)
assert best is not None
assert "Times" in best.font_name or "Liberation Serif" in best.font_name, (
f"Expected serif font, got {best.font_name}"
@@ -76,7 +77,8 @@ def test_full_search_finds_correct_sans():
text = "The quick brown fox jumps."
line, crop = _make_line_and_crop("Arial", 44, text)
best = _full_search(line, crop)
scorer = lambda font: _score_font_line_pixel(font, line, crop)
best = _full_search(line.h, scorer)
assert best is not None
assert "Arial" in best.font_name or "Liberation Sans" in best.font_name, (
f"Expected sans-serif font, got {best.font_name}"
+24
View File
@@ -19,6 +19,7 @@ from sse_starlette.sse import EventSourceResponse
from unredact.pipeline.rasterize import rasterize_pdf
from unredact.pipeline.font_detect import CANDIDATE_FONTS, _find_font_path
from unredact.pipeline.analyze_page import analyze_page
from unredact.pipeline.detect_redactions import spot_redaction
from unredact.pipeline.solver import build_constraint, SolveResult
from unredact.pipeline.dictionary import DictionaryStore, solve_dictionary
from unredact.pipeline.word_filter import _get_emails
@@ -135,7 +136,16 @@ async def analyze_doc(doc_id: str):
async def event_generator():
for page_num, pd in doc["pages"].items():
page_img = pd["original"]
try:
analysis = await analyze_page(page_img)
except Exception as exc:
yield json.dumps({
"event": "error",
"page": page_num,
"message": str(exc),
})
continue
pd["analysis"] = analysis
redactions_json = []
@@ -454,6 +464,20 @@ async def get_associates():
return _get_associates()
@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"])
page_img = doc["pages"][page]["original"]
result = spot_redaction(page_img, click_x, click_y)
if result is None:
return JSONResponse({"error": "no redaction found"}, status_code=404)
return {"id": result.id, "x": result.x, "y": result.y, "w": result.w, "h": result.h}
# Static files mount MUST be after all route definitions
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
+4
View File
@@ -88,6 +88,9 @@ async def analyze_page(
# LlmRedaction to Redaction box (or None if not found).
llm_to_box: dict[int, Redaction | None] = {}
for i, llm_red in enumerate(llm_redactions):
# Use line height as padding — OCR positions near redactions are
# unreliable because artifacts compress the visual gap.
pad = llm_red.line_h
box = await asyncio.to_thread(
find_redaction_in_region,
page_image,
@@ -95,6 +98,7 @@ async def analyze_page(
llm_red.line_y,
llm_red.right_x,
llm_red.line_y + llm_red.line_h,
pad,
)
llm_to_box[i] = box
+144 -33
View File
@@ -1,11 +1,12 @@
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Callable
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from unredact.pipeline.ocr import OcrLine
from unredact.pipeline.ocr import OcrChar, OcrLine
# Candidate fonts to test (common document fonts available on the system)
CANDIDATE_FONTS: list[str] = [
@@ -202,14 +203,21 @@ def align_text_to_page(
return (-best_dx, -best_dy)
def _full_search(line: OcrLine, line_crop: np.ndarray) -> FontMatch | None:
"""Full search across all candidate fonts and sizes for one line."""
def _full_search(
line_h: int,
scorer: Callable[[ImageFont.FreeTypeFont], float],
) -> FontMatch | None:
"""Full search across all candidate fonts and sizes.
Args:
line_h: OCR line height in pixels (used to bound the size search).
scorer: Callable that takes a PIL font and returns a score (0.0–1.0).
"""
best: FontMatch | None = None
# Constrain size range using the OCR line height.
# Pixel scoring has sharp peaks at the exact size, so we scan
# every integer size (step=1) instead of using a coarse step.
line_h = line.h
min_size = max(12, int(line_h * 0.6))
max_size = min(120, int(line_h * 1.4))
@@ -224,7 +232,7 @@ def _full_search(line: OcrLine, line_crop: np.ndarray) -> FontMatch | None:
except Exception:
continue
score = _score_font_line_pixel(font, line, line_crop)
score = scorer(font)
if best is None or score > best.score:
best = FontMatch(
@@ -237,7 +245,10 @@ def _full_search(line: OcrLine, line_crop: np.ndarray) -> FontMatch | None:
return best
def _fine_search(line: OcrLine, line_crop: np.ndarray, coarse: FontMatch) -> FontMatch:
def _fine_search(
coarse: FontMatch,
scorer: Callable[[ImageFont.FreeTypeFont], float],
) -> FontMatch:
"""Fine search: ±3 around the coarse best size in steps of 1."""
best = coarse
for size in range(max(8, coarse.font_size - 3), coarse.font_size + 4):
@@ -247,7 +258,7 @@ def _fine_search(line: OcrLine, line_crop: np.ndarray, coarse: FontMatch) -> Fon
font = ImageFont.truetype(str(coarse.font_path), size)
except Exception:
continue
score = _score_font_line_pixel(font, line, line_crop)
score = scorer(font)
if score > best.score:
best = FontMatch(
font_name=coarse.font_name,
@@ -268,10 +279,112 @@ def detect_font_for_line_from_crop(
(e.g. the non-redacted portion of a line). The line's x/y should
be 0,0 (crop-relative coordinates).
"""
best = _full_search(line, line_crop)
scorer = lambda font: _score_font_line_pixel(font, line, line_crop)
best = _full_search(line.h, scorer)
if best is None:
raise RuntimeError("No matching font found. Check system fonts.")
return _fine_search(line, line_crop, best)
return _fine_search(best, scorer)
def _filter_clean_chars(
chars: list[OcrChar],
redaction_boxes: list[tuple[int, int, int, int]],
) -> list[OcrChar]:
"""Return chars whose center doesn't fall inside any redaction box."""
clean = []
for c in chars:
cx = c.x + c.w / 2
cy = c.y + c.h / 2
in_box = False
for rx, ry, rw, rh in redaction_boxes:
if rx <= cx <= rx + rw and ry <= cy <= ry + rh:
in_box = True
break
if not in_box:
clean.append(c)
return clean
def _group_chars_into_runs(chars: list[OcrChar]) -> list[list[OcrChar]]:
"""Group consecutive chars into word-like runs for rendering.
Chars that are spatially close (same y, adjacent x) are grouped so
they can be rendered as a single string, preserving kerning.
"""
if not chars:
return []
runs: list[list[OcrChar]] = []
current = [chars[0]]
for c in chars[1:]:
prev = current[-1]
# Same vertical position and horizontally adjacent (within 5px gap)
if c.y == prev.y and c.x <= prev.x + prev.w + 5:
current.append(c)
else:
runs.append(current)
current = [c]
runs.append(current)
return runs
def _score_font_masked_pixel(
font: ImageFont.FreeTypeFont,
char_runs: list[list[OcrChar]],
line_x: int,
line_y: int,
line_crop: np.ndarray,
) -> float:
"""Score font by rendering clean char runs at their OCR positions.
Unlike _score_font_line_pixel which renders the full line text as a
single string, this renders each word-like run at its actual OCR
position. This correctly handles the spatial gap where a redaction
was masked out, preventing:
- OCR artifacts from producing false ink
- Right-side text from rendering at the wrong x position
- Large fonts from getting inflated scores due to canvas overflow
"""
h, w = line_crop.shape
if h < 5 or w < 10:
return 0.0
page_bin = line_crop < 128
page_ink = int(page_bin.sum())
if page_ink < 10:
return 0.0
# Render each run at its OCR position
rendered_img = Image.new("L", (w, h), 255)
draw = ImageDraw.Draw(rendered_img)
for run in char_runs:
text = "".join(c.text for c in run)
if not text.strip():
continue
# Crop-relative position of the run's first char
cx = run[0].x - line_x
cy = run[0].y - line_y
bbox = font.getbbox(text)
draw.text((cx - bbox[0], cy - bbox[1]), text, font=font, fill=0)
rendered_arr = np.array(rendered_img)
rendered_bin = rendered_arr < 128
rendered_ink = int(rendered_bin.sum())
if rendered_ink < 10:
return 0.0
# Try small shifts to find best alignment, using Dice coefficient
best_score = 0.0
total_ink = page_ink + rendered_ink
for dy in range(-3, 4):
for dx in range(-3, 4):
shifted = _shift_2d(rendered_bin, dx, dy)
intersection = int((page_bin & shifted).sum())
score = 2.0 * intersection / total_ink if total_ink > 0 else 0.0
if score > best_score:
best_score = score
return best_score
def detect_font_masked(
@@ -279,13 +392,11 @@ def detect_font_masked(
page_image: Image.Image,
redaction_boxes: list[tuple[int, int, int, int]],
) -> FontMatch:
"""Detect the best font by masking redaction boxes to white before scoring.
"""Detect the best font by masking redaction boxes and scoring clean chars.
Instead of cropping just the unredacted portion of a line (which loses
signal from narrow crops and coordinate mismatches), this masks each
redaction box to white (255) in the full line crop. The Dice-based pixel
scoring naturally ignores the masked (white) regions since they contain
no ink pixels.
Masks each redaction box to white in the page crop, then renders only
the non-redacted characters at their OCR positions for scoring. This
ensures the rendered text matches the spatial layout of the page.
Args:
line: OCR line with page-relative coordinates.
@@ -301,33 +412,31 @@ def detect_font_masked(
page_image.convert("L").crop((line.x, line.y, line.x + line.w, line.y + line.h))
)
# 2. Mask each redaction box to white (255) — convert page-relative to crop-relative
# 2. Mask each redaction box to white (255)
for rx, ry, rw, rh in redaction_boxes:
# Convert from page coordinates to crop-relative coordinates
cx = rx - line.x
cy = ry - line.y
# Clip to the crop bounds
x0 = max(0, cx)
y0 = max(0, cy)
x1 = min(line.w, cx + rw)
y1 = min(line.h, cy + rh)
if x0 < x1 and y0 < y1:
line_crop[y0:y1, x0:x1] = 255
# 3. Create a scoring line with crop-relative coordinates (x=0, y=0)
scoring_line = OcrLine(
chars=line.chars,
x=0, y=0,
w=line.w, h=line.h,
)
# 3. Filter out chars in redaction zones, group into runs
clean_chars = _filter_clean_chars(line.chars, redaction_boxes)
if not clean_chars:
raise RuntimeError("No clean chars outside redaction boxes.")
char_runs = _group_chars_into_runs(clean_chars)
# 4. Full search then fine search
best = _full_search(scoring_line, line_crop)
# 4. Search using position-aware masked scoring
scorer = lambda font: _score_font_masked_pixel(
font, char_runs, line.x, line.y, line_crop,
)
best = _full_search(line.h, scorer)
if best is None:
raise RuntimeError("No matching font found. Check system fonts.")
return _fine_search(scoring_line, line_crop, best)
return _fine_search(best, scorer)
def detect_font_for_line(
@@ -352,6 +461,8 @@ def detect_font_for_line(
w=line.w, h=line.h,
)
scorer = lambda font: _score_font_line_pixel(font, scoring_line, line_crop)
# Lines with too few characters can't be scored reliably
if len(line.text.strip()) < 3:
if prior is not None:
@@ -363,24 +474,24 @@ def detect_font_for_line(
if prior is not None:
try:
prior_font = prior.to_pil_font()
prior_score = _score_font_line_pixel(prior_font, scoring_line, line_crop)
prior_score = scorer(prior_font)
except Exception:
pass
# Full search
best = _full_search(scoring_line, line_crop)
best = _full_search(line.h, scorer)
if best is None:
if prior is not None:
return prior
raise RuntimeError("No matching font found. Check system fonts.")
# Fine-tune the full search winner
best = _fine_search(scoring_line, line_crop, best)
best = _fine_search(best, scorer)
# If we have a prior and it's close enough, prefer it for consistency
if prior is not None and prior_score >= best.score * (1.0 / PRIOR_BIAS):
# Fine-tune the prior too
return _fine_search(scoring_line, line_crop, prior)
return _fine_search(prior, scorer)
return best
+5 -1
View File
@@ -65,6 +65,10 @@ def _get_client() -> anthropic.AsyncAnthropic:
"""Return a cached async Anthropic client."""
global _client
if _client is None:
if not os.environ.get("ANTHROPIC_API_KEY"):
raise RuntimeError(
"ANTHROPIC_API_KEY not set. Export it before running the server."
)
_client = anthropic.AsyncAnthropic()
return _client
@@ -249,7 +253,7 @@ async def detect_redactions_llm(
response = await client.messages.create(
model=model,
max_tokens=1024,
max_tokens=2048,
tools=[_TOOL],
tool_choice={"type": "tool", "name": "report_redactions"},
messages=[{"role": "user", "content": prompt}],
+82 -25
View File
@@ -4,6 +4,72 @@
import { state, getPageRedactions } from './state.js';
import { canvas, ctx, docImage } from './dom.js';
/**
* Parse text with **bold** markers into styled segments.
* @param {string} text
* @returns {{text: string, bold: boolean}[]}
*/
function parseStyledText(text) {
const segments = [];
const re = /\*\*(.+?)\*\*/g;
let last = 0;
let m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
segments.push({ text: text.slice(last, m.index), bold: false });
}
segments.push({ text: m[1], bold: true });
last = m.index + m[0].length;
}
if (last < text.length) {
segments.push({ text: text.slice(last), bold: false });
}
return segments.length ? segments : [{ text, bold: false }];
}
/**
* Draw styled text on canvas, switching font weight for bold segments.
* @param {string} text - Text with optional **bold** markers.
* @param {number} x
* @param {number} y
* @param {string} fontName
* @param {number} fontSize
* @param {string} fillStyle
* @returns {number} Total width drawn.
*/
function drawStyledText(text, x, y, fontName, fontSize, fillStyle) {
const segments = parseStyledText(text);
let cx = x;
ctx.fillStyle = fillStyle;
for (const seg of segments) {
ctx.font = seg.bold
? `bold ${fontSize}px "${fontName}"`
: `${fontSize}px "${fontName}"`;
ctx.fillText(seg.text, cx, y);
cx += ctx.measureText(seg.text).width;
}
return cx - x;
}
/**
* Measure styled text width, accounting for bold segments.
* @param {string} text
* @param {string} fontName
* @param {number} fontSize
* @returns {number}
*/
function measureStyledText(text, fontName, fontSize) {
const segments = parseStyledText(text);
let w = 0;
for (const seg of segments) {
ctx.font = seg.bold
? `bold ${fontSize}px "${fontName}"`
: `${fontSize}px "${fontName}"`;
w += ctx.measureText(seg.text).width;
}
return w;
}
export function renderCanvas() {
if (!docImage.naturalWidth || !state.fontsReady) return;
@@ -67,16 +133,13 @@ function drawRedactionAnalyzed(r, isActive) {
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;
const leftText = o.leftText ?? "";
if (leftText) {
ctx.fillStyle = "rgba(0, 200, 0, 0.7)";
ctx.fillText(leftText, cursorX, startY);
cursorX += ctx.measureText(leftText).width;
cursorX += drawStyledText(leftText, cursorX, startY, fontName, fontSize, "rgba(0, 200, 0, 0.7)");
}
const pad = fontSize * 0.15;
@@ -91,14 +154,12 @@ function drawRedactionAnalyzed(r, isActive) {
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;
cursorX += gapW;
const rightText = o.rightText ?? "";
if (rightText) {
ctx.fillStyle = "rgba(0, 200, 0, 0.7)";
ctx.fillText(rightText, cursorX, startY);
drawStyledText(rightText, cursorX, startY, fontName, fontSize, "rgba(0, 200, 0, 0.7)");
}
ctx.strokeStyle = "rgba(0, 200, 0, 0.3)";
@@ -123,16 +184,13 @@ function drawRedactionPreview(r, isActive) {
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;
const leftText = o.leftText ?? "";
if (leftText) {
ctx.fillStyle = "rgba(0, 200, 0, 0.7)";
ctx.fillText(leftText, cursorX, startY);
cursorX += ctx.measureText(leftText).width;
cursorX += drawStyledText(leftText, cursorX, startY, fontName, fontSize, "rgba(0, 200, 0, 0.7)");
}
const pad = fontSize * 0.15;
@@ -150,26 +208,27 @@ function drawRedactionPreview(r, isActive) {
const rightText = o.rightText ?? "";
if (rightText) {
ctx.fillStyle = "rgba(0, 200, 0, 0.7)";
ctx.fillText(rightText, cursorX, startY);
drawStyledText(rightText, cursorX, startY, fontName, fontSize, "rgba(0, 200, 0, 0.7)");
}
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);
} else {
const gapX = a.gap.x + (o.offsetX ?? 0);
const textY = a.line.y + (o.offsetY ?? 0);
const pad = fontSize * 0.1;
ctx.fillStyle = "rgba(255, 200, 0, 0.12)";
ctx.fillRect(a.gap.x, r.y - pad, gapW, r.h + pad * 2);
ctx.fillRect(gapX, textY - pad, gapW, fontSize + pad * 2);
ctx.strokeStyle = "rgba(255, 200, 0, 0.5)";
ctx.lineWidth = 1;
ctx.strokeRect(a.gap.x, r.y - pad, gapW, r.h + pad * 2);
ctx.strokeRect(gapX, textY - pad, gapW, fontSize + pad * 2);
ctx.font = fontStr;
ctx.textBaseline = "top";
ctx.fillStyle = "rgba(255, 200, 0, 0.9)";
ctx.fillText(r.preview, a.gap.x, a.line.y);
ctx.fillText(r.preview, gapX, textY);
}
}
@@ -190,16 +249,13 @@ function drawRedactionSolution(r, isActive) {
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;
const leftText = o.leftText ?? "";
if (leftText) {
ctx.fillStyle = "rgba(0, 200, 0, 0.7)";
ctx.fillText(leftText, cursorX, startY);
cursorX += ctx.measureText(leftText).width;
cursorX += drawStyledText(leftText, cursorX, startY, fontName, fontSize, "rgba(0, 200, 0, 0.7)");
}
const pad = fontSize * 0.15;
@@ -217,25 +273,26 @@ function drawRedactionSolution(r, isActive) {
const rightText = o.rightText ?? "";
if (rightText) {
ctx.fillStyle = "rgba(0, 200, 0, 0.7)";
ctx.fillText(rightText, cursorX, startY);
drawStyledText(rightText, cursorX, startY, fontName, fontSize, "rgba(0, 200, 0, 0.7)");
}
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);
} else {
const gapX = a.gap.x + (o.offsetX ?? 0);
const textY = a.line.y + (o.offsetY ?? 0);
const pad = fontSize * 0.1;
ctx.fillStyle = "rgba(0, 212, 116, 0.08)";
ctx.fillRect(a.gap.x, r.y - pad, gapW, r.h + pad * 2);
ctx.fillRect(gapX, textY - pad, gapW, fontSize + pad * 2);
ctx.strokeStyle = "rgba(0, 212, 116, 0.4)";
ctx.lineWidth = 1;
ctx.strokeRect(a.gap.x, r.y - pad, gapW, r.h + pad * 2);
ctx.strokeRect(gapX, textY - pad, gapW, fontSize + pad * 2);
ctx.font = fontStr;
ctx.textBaseline = "top";
ctx.fillStyle = "rgba(0, 212, 116, 0.95)";
ctx.fillText(r.solution.text, a.gap.x, a.line.y);
ctx.fillText(r.solution.text, gapX, textY);
}
}
+90
View File
@@ -115,6 +115,8 @@ function startAnalysisSSE() {
if (data.event === "page_complete") {
// Reload the page data to pick up pre-computed analysis
loadPageData(data.page);
} else if (data.event === "error") {
showToast(`Page ${data.page}: ${data.message}`, "error");
} else if (data.event === "done") {
es.close();
showToast("Analysis complete");
@@ -304,6 +306,33 @@ canvas.addEventListener("mousedown", (e) => {
}
});
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();
state.redactions[r.id] = {
id: r.id, x: r.x, y: r.y, w: r.w, h: r.h,
page: state.currentPage,
status: "unanalyzed",
analysis: null, solution: null, preview: null,
};
renderRedactionList();
renderCanvas();
activateRedaction(r.id);
});
canvas.addEventListener("mousemove", (e) => {
const rect = rightPanel.getBoundingClientRect();
const sx = e.clientX - rect.left;
@@ -367,6 +396,67 @@ solveAccept.addEventListener("click", () => {
renderCanvas();
});
// ── Export annotations (Ctrl+E) ──
function exportAnnotations() {
const pages = {};
for (const r of Object.values(state.redactions)) {
if (!pages[r.page]) pages[r.page] = [];
const entry = {
id: r.id,
x: r.x, y: r.y, w: r.w, h: r.h,
status: r.status,
};
if (r.overrides) {
entry.overrides = { ...r.overrides };
}
if (r.analysis) {
entry.analysis = {
font: r.analysis.font,
gap: r.analysis.gap,
line: r.analysis.line,
segments: r.analysis.segments,
offset_x: r.analysis.offset_x,
offset_y: r.analysis.offset_y,
};
}
if (r.solution) {
entry.solution = r.solution;
}
pages[r.page].push(entry);
}
// Sort each page's redactions top-to-bottom, left-to-right
for (const p of Object.values(pages)) {
p.sort((a, b) => Math.abs(a.y - b.y) > 5 ? a.y - b.y : a.x - b.x);
}
const data = { docId: state.docId, pageCount: state.pageCount, pages };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `annotations-${state.docId}.json`;
a.click();
URL.revokeObjectURL(url);
showToast("Exported annotations");
}
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
e.preventDefault();
exportAnnotations();
}
if ((e.key === "Delete" || e.key === "Backspace") && state.activeRedaction) {
// Don't delete if user is typing in an input
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
delete state.redactions[state.activeRedaction];
state.activeRedaction = null;
closePopover();
renderRedactionList();
renderCanvas();
showToast("Redaction deleted");
}
});
// ── Initialize all modules ──
setOnPopoverClose(stopSolve);