Add interactive font tuning UI with split-panel layout

Replace static overlay with client-side canvas rendering. Left panel
shows clickable OCR lines with font controls (dropdown + size slider).
Right panel renders green text overlay on the document via canvas.
Fonts loaded from backend TTF files using the FontFace API.

New endpoints: GET /api/fonts, GET /api/font/{id}

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-15 20:17:57 -06:00
1 parent 9b23ad827b
commit b9df273521
5 files changed
+477 -48

No files matched your search

+47
View File
@@ -79,4 +79,51 @@ async def test_get_page_data(pdf_bytes: bytes):
line = data["lines"][0]
assert "font" in line
assert line["font"]["name"]
assert line["font"]["id"] # new: url-safe slug
assert line["font"]["size"] > 0
@pytest.mark.anyio
async def test_list_fonts():
"""GET /api/fonts should return list of candidate fonts."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/fonts")
assert resp.status_code == 200
data = resp.json()
assert "fonts" in data
assert len(data["fonts"]) > 0
font = data["fonts"][0]
assert "name" in font
assert "id" in font
assert "available" in font
# At least one font should be available on the system
assert any(f["available"] for f in data["fonts"])
@pytest.mark.anyio
async def test_get_font_ttf():
"""GET /api/font/{id} should serve a TTF file."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
# Get list first to find an available font
resp = await client.get("/api/fonts")
fonts = resp.json()["fonts"]
available = [f for f in fonts if f["available"]]
assert available, "No fonts available on system"
font_id = available[0]["id"]
resp = await client.get(f"/api/font/{font_id}")
assert resp.status_code == 200
assert resp.headers["content-type"] == "font/ttf"
# TTF files start with specific bytes or have substantial size
assert len(resp.content) > 1000
@pytest.mark.anyio
async def test_get_font_not_found():
"""GET /api/font/{id} with bad id should 404."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/font/nonexistent-font")
assert resp.status_code == 404
+32 -1
View File
@@ -1,4 +1,5 @@
import io
import re
import uuid
from pathlib import Path
from tempfile import TemporaryDirectory
@@ -10,7 +11,7 @@ from PIL import Image
from unredact.pipeline.rasterize import rasterize_pdf
from unredact.pipeline.ocr import ocr_page
from unredact.pipeline.font_detect import detect_fonts
from unredact.pipeline.font_detect import detect_fonts, CANDIDATE_FONTS, _find_font_path
from unredact.pipeline.overlay import render_overlay
app = FastAPI(title="Unredact")
@@ -21,6 +22,22 @@ _docs: dict[str, dict] = {}
STATIC_DIR = Path(__file__).parent / "static"
def _make_font_id(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
# Build font lookup at module level
_font_id_to_path: dict[str, Path] = {}
_font_list: list[dict] = []
for _name in CANDIDATE_FONTS:
_fid = _make_font_id(_name)
_path = _find_font_path(_name)
if _path:
_font_id_to_path[_fid] = _path
_font_list.append({"name": _name, "id": _fid, "available": _path is not None})
@app.get("/")
async def root():
return RedirectResponse("/static/index.html")
@@ -91,6 +108,19 @@ async def get_page_overlay(doc_id: str, page: int):
return Response(content=png, media_type="image/png")
@app.get("/api/fonts")
async def list_fonts():
return {"fonts": _font_list}
@app.get("/api/font/{font_id}")
async def get_font(font_id: str):
path = _font_id_to_path.get(font_id)
if not path:
return JSONResponse({"error": "font not found"}, status_code=404)
return Response(content=path.read_bytes(), media_type="font/ttf")
@app.get("/api/doc/{doc_id}/page/{page}/data")
async def get_page_data(doc_id: str, page: int):
doc = _docs.get(doc_id)
@@ -111,6 +141,7 @@ async def get_page_data(doc_id: str, page: int):
"chars": chars_json,
"font": {
"name": fm.font_name,
"id": _make_font_id(fm.font_name),
"size": fm.font_size,
"score": fm.score,
},
+235 -25
View File
@@ -1,22 +1,69 @@
// DOM elements
const dropZone = document.getElementById("drop-zone");
const fileInput = document.getElementById("file-input");
const uploadSection = document.getElementById("upload-section");
const viewerSection = document.getElementById("viewer-section");
const docImage = document.getElementById("doc-image");
const canvas = document.getElementById("overlay-canvas");
const ctx = canvas.getContext("2d");
const pageInfo = document.getElementById("page-info");
const prevBtn = document.getElementById("prev-page");
const nextBtn = document.getElementById("next-page");
const overlayToggle = document.getElementById("show-overlay");
const fontInfo = document.getElementById("font-info");
const lineList = document.getElementById("line-list");
const fontControls = document.getElementById("font-controls");
const fontSelect = document.getElementById("font-select");
const sizeSlider = document.getElementById("size-slider");
const sizeValue = document.getElementById("size-value");
const sizeDown = document.getElementById("size-down");
const sizeUp = document.getElementById("size-up");
let state = {
// State
const state = {
docId: null,
pageCount: 0,
currentPage: 1,
pageData: {},
pageData: {}, // page -> {lines: [...]}
selectedLine: null, // index into current page's lines
lineOverrides: {}, // "page-lineIdx" -> {fontId, fontSize}
fonts: [], // [{name, id, available}]
fontsReady: false,
};
// Drag and drop
// ── Font loading ──
async function loadFonts() {
const resp = await fetch("/api/fonts");
const data = await resp.json();
state.fonts = data.fonts;
// Load available fonts via FontFace API
const promises = state.fonts
.filter((f) => f.available)
.map(async (f) => {
const face = new FontFace(f.name, `url(/api/font/${f.id})`);
try {
const loaded = await face.load();
document.fonts.add(loaded);
} catch (e) {
console.warn(`Failed to load font ${f.name}:`, e);
}
});
await Promise.all(promises);
state.fontsReady = true;
// Populate font dropdown
fontSelect.innerHTML = "";
for (const f of state.fonts.filter((f) => f.available)) {
const opt = document.createElement("option");
opt.value = f.id;
opt.textContent = f.name;
fontSelect.appendChild(opt);
}
}
// ── Drag and drop ──
dropZone.addEventListener("click", () => fileInput.click());
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
@@ -32,12 +79,16 @@ fileInput.addEventListener("change", () => {
if (fileInput.files.length) uploadFile(fileInput.files[0]);
});
// ── Upload ──
async function uploadFile(file) {
uploadSection.innerHTML = '<p class="loading">Analyzing document...</p>';
// Start font loading in parallel with upload
const fontPromise = loadFonts();
const form = new FormData();
form.append("file", file);
const resp = await fetch("/api/upload", { method: "POST", body: form });
const data = await resp.json();
@@ -45,41 +96,36 @@ async function uploadFile(file) {
state.pageCount = data.page_count;
state.currentPage = 1;
await fontPromise; // ensure fonts are ready before rendering
uploadSection.hidden = true;
viewerSection.hidden = false;
await loadPage(1);
}
// ── Page loading ──
async function loadPage(page) {
state.currentPage = page;
updateControls();
state.selectedLine = null;
fontControls.hidden = true;
updatePageControls();
const showOverlay = overlayToggle.checked;
const endpoint = showOverlay ? "overlay" : "original";
docImage.src = `/api/doc/${state.docId}/page/${page}/${endpoint}`;
// Load the original page image
docImage.src = `/api/doc/${state.docId}/page/${page}/original`;
// Load page data (font info, lines)
// Load page data if not cached
if (!state.pageData[page]) {
const resp = await fetch(`/api/doc/${state.docId}/page/${page}/data`);
state.pageData[page] = await resp.json();
}
const pd = state.pageData[page];
// Summarize per-line fonts: count unique font+size combos
const fontCounts = {};
for (const line of pd.lines) {
const key = `${line.font.name} ${line.font.size}px`;
fontCounts[key] = (fontCounts[key] || 0) + 1;
}
const summary = Object.entries(fontCounts)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${k} (${v} lines)`)
.join(", ");
fontInfo.textContent = summary;
renderLineList();
clearCanvas();
}
function updateControls() {
function updatePageControls() {
pageInfo.textContent = `Page ${state.currentPage} / ${state.pageCount}`;
prevBtn.disabled = state.currentPage <= 1;
nextBtn.disabled = state.currentPage >= state.pageCount;
@@ -91,4 +137,168 @@ prevBtn.addEventListener("click", () => {
nextBtn.addEventListener("click", () => {
if (state.currentPage < state.pageCount) loadPage(state.currentPage + 1);
});
overlayToggle.addEventListener("change", () => loadPage(state.currentPage));
// ── Line list ──
function renderLineList() {
const pd = state.pageData[state.currentPage];
if (!pd) return;
lineList.innerHTML = "";
pd.lines.forEach((line, idx) => {
const div = document.createElement("div");
div.className = "line-item";
div.dataset.idx = idx;
const textEl = document.createElement("div");
textEl.className = "line-text";
textEl.textContent = line.text;
const overrideKey = `${state.currentPage}-${idx}`;
const override = state.lineOverrides[overrideKey];
const fontName = override
? state.fonts.find((f) => f.id === override.fontId)?.name || line.font.name
: line.font.name;
const fontSize = override ? override.fontSize : line.font.size;
const metaEl = document.createElement("div");
metaEl.className = "line-meta";
metaEl.textContent = `${fontName} ${fontSize}px (score: ${line.font.score.toFixed(1)})`;
div.appendChild(textEl);
div.appendChild(metaEl);
div.addEventListener("click", () => selectLine(idx));
lineList.appendChild(div);
});
}
// ── Line selection ──
function selectLine(idx) {
state.selectedLine = idx;
// Update selected class
lineList.querySelectorAll(".line-item").forEach((el, i) => {
el.classList.toggle("selected", i === idx);
});
const pd = state.pageData[state.currentPage];
const line = pd.lines[idx];
const overrideKey = `${state.currentPage}-${idx}`;
const override = state.lineOverrides[overrideKey];
// Populate font controls
const fontId = override ? override.fontId : line.font.id;
const fontSize = override ? override.fontSize : line.font.size;
fontSelect.value = fontId;
sizeSlider.value = fontSize;
sizeValue.textContent = fontSize;
fontControls.hidden = false;
renderOverlay();
scrollToLine(line);
}
function scrollToLine(line) {
// Scroll the right panel so the selected line is visible
const rightPanel = document.getElementById("right-panel");
const imgRect = docImage.getBoundingClientRect();
const scale = docImage.clientHeight / docImage.naturalHeight;
const lineY = line.y * scale;
const panelRect = rightPanel.getBoundingClientRect();
const lineScreenY = imgRect.top - panelRect.top + lineY;
// Center the line in the panel
const target = lineScreenY - rightPanel.clientHeight / 3;
rightPanel.scrollTop += target;
}
// ── Font controls ──
fontSelect.addEventListener("change", () => saveOverrideAndRender());
sizeSlider.addEventListener("input", () => {
sizeValue.textContent = sizeSlider.value;
saveOverrideAndRender();
});
sizeDown.addEventListener("click", () => {
sizeSlider.value = Math.max(8, parseInt(sizeSlider.value) - 1);
sizeValue.textContent = sizeSlider.value;
saveOverrideAndRender();
});
sizeUp.addEventListener("click", () => {
sizeSlider.value = Math.min(120, parseInt(sizeSlider.value) + 1);
sizeValue.textContent = sizeSlider.value;
saveOverrideAndRender();
});
function saveOverrideAndRender() {
if (state.selectedLine === null) return;
const key = `${state.currentPage}-${state.selectedLine}`;
state.lineOverrides[key] = {
fontId: fontSelect.value,
fontSize: parseInt(sizeSlider.value),
};
renderOverlay();
// Update the meta text in the line list
const items = lineList.querySelectorAll(".line-item");
if (items[state.selectedLine]) {
const meta = items[state.selectedLine].querySelector(".line-meta");
const fontName = state.fonts.find((f) => f.id === fontSelect.value)?.name || "?";
meta.textContent = `${fontName} ${sizeSlider.value}px (override)`;
}
}
// ── Canvas rendering ──
function clearCanvas() {
canvas.width = 0;
canvas.height = 0;
}
function renderOverlay() {
if (state.selectedLine === null || !state.fontsReady) return;
if (!docImage.naturalWidth) return;
const pd = state.pageData[state.currentPage];
const line = pd.lines[state.selectedLine];
const overrideKey = `${state.currentPage}-${state.selectedLine}`;
const override = state.lineOverrides[overrideKey];
const fontId = override ? override.fontId : line.font.id;
const fontSize = override ? override.fontSize : line.font.size;
const fontName = state.fonts.find((f) => f.id === fontId)?.name || line.font.name;
// Match canvas resolution to native image resolution
canvas.width = docImage.naturalWidth;
canvas.height = docImage.naturalHeight;
// Match display size to image display size
canvas.style.width = docImage.clientWidth + "px";
canvas.style.height = docImage.clientHeight + "px";
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = `${fontSize}px "${fontName}"`;
ctx.fillStyle = "rgba(0, 200, 0, 0.6)";
ctx.textBaseline = "top";
ctx.fillText(line.text, line.x, line.y);
// Draw a subtle bounding box for reference
ctx.strokeStyle = "rgba(0, 200, 0, 0.3)";
ctx.lineWidth = 1;
ctx.strokeRect(line.x, line.y, line.w, line.h);
}
// ── Resize handling ──
const resizeObserver = new ResizeObserver(() => {
if (state.selectedLine !== null) {
renderOverlay();
}
});
// Start observing once image loads
docImage.addEventListener("load", () => {
resizeObserver.observe(docImage);
if (state.selectedLine !== null) {
renderOverlay();
}
});
+25 -7
View File
@@ -25,14 +25,32 @@
<button id="prev-page" disabled>&lt; Prev</button>
<span id="page-info">Page 1 / 1</span>
<button id="next-page" disabled>Next &gt;</button>
<label id="overlay-toggle">
<input type="checkbox" id="show-overlay" checked>
Show overlay
</label>
<span id="font-info"></span>
</div>
<div id="doc-container">
<img id="doc-image" alt="Document page">
<div id="workspace">
<div id="left-panel">
<div id="line-list"></div>
<div id="font-controls" hidden>
<label>
Font
<select id="font-select"></select>
</label>
<div class="size-control">
<span>Size</span>
<button id="size-down" class="size-btn">-</button>
<input type="range" id="size-slider" min="8" max="120" step="1">
<button id="size-up" class="size-btn">+</button>
<span id="size-value">32</span>px
</div>
</div>
</div>
<div id="right-panel">
<div id="doc-container">
<img id="doc-image" alt="Document page">
<canvas id="overlay-canvas"></canvas>
</div>
</div>
</div>
</section>
</main>
+138 -15
View File
@@ -5,10 +5,12 @@ body {
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: 1.5rem 2rem;
padding: 1rem 2rem;
background: #16213e;
border-bottom: 2px solid #0f3460;
}
@@ -26,7 +28,13 @@ header h1 {
margin-top: 0.25rem;
}
main { padding: 2rem; }
main {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem;
overflow: hidden;
}
#drop-zone {
border: 2px dashed #0f3460;
@@ -46,21 +54,23 @@ main { padding: 2rem; }
#drop-zone p { font-size: 1.1rem; color: #aaa; }
/* Controls bar */
#controls {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
padding: 0.5rem 1rem;
background: #16213e;
border-radius: 8px;
flex-shrink: 0;
}
#controls button {
background: #0f3460;
color: #e0e0e0;
border: none;
padding: 0.5rem 1rem;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
}
@@ -70,30 +80,143 @@ main { padding: 2rem; }
cursor: not-allowed;
}
#overlay-toggle {
margin-left: auto;
cursor: pointer;
user-select: none;
/* Workspace: split panel */
#workspace {
display: flex;
flex: 1;
gap: 0.75rem;
overflow: hidden;
}
#font-info {
/* Left panel */
#left-panel {
width: 350px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: #16213e;
border-radius: 8px;
overflow: hidden;
}
#line-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
}
.line-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
font-size: 0.8rem;
line-height: 1.4;
}
.line-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.line-item.selected {
border-left-color: #00d474;
background: rgba(0, 212, 116, 0.1);
}
.line-item .line-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #e0e0e0;
}
.line-item .line-meta {
font-size: 0.7rem;
color: #888;
margin-top: 0.15rem;
}
/* Font controls */
#font-controls {
flex-shrink: 0;
padding: 0.75rem;
border-top: 1px solid #0f3460;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
#font-controls label,
#font-controls .size-control {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #aaa;
}
#font-select {
flex: 1;
background: #0f3460;
color: #e0e0e0;
border: 1px solid #1a3a6e;
border-radius: 4px;
padding: 0.3rem 0.4rem;
font-size: 0.8rem;
}
#size-slider {
flex: 1;
accent-color: #00d474;
}
.size-btn {
background: #0f3460;
color: #e0e0e0;
border: 1px solid #1a3a6e;
border-radius: 4px;
width: 1.5rem;
height: 1.5rem;
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
#size-value {
color: #00d474;
min-width: 2rem;
text-align: right;
}
/* Right panel */
#right-panel {
flex: 1;
overflow: auto;
background: #111;
border-radius: 8px;
}
#doc-container {
background: #111;
border-radius: 8px;
overflow: auto;
max-height: 80vh;
text-align: center;
position: relative;
display: inline-block;
}
#doc-image {
display: block;
max-width: 100%;
height: auto;
}
#overlay-canvas {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.loading {
color: #00d474;
text-align: center;