mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
attempting to make redaction guessing better
This commit is contained in:
1 parent
cddbf74492
commit
a5505c1fbb
17 files changed
+19102
-65
No files matched your search
@@ -6,5 +6,6 @@ dist/
|
||||
build/
|
||||
.worktrees/
|
||||
.pids/
|
||||
.env
|
||||
solver_rs/target/
|
||||
scripts/.cache/
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+11585
File diff suppressed because it is too large.
Load diff
BIN
Binary file not shown.
|
After Width: | Height: | Size: 944 KiB |
+6082
File diff suppressed because it is too large.
Load diff
@@ -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}"
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user