diff --git a/tests/test_app.py b/tests/test_app.py index 4a58c5a..973e59a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 diff --git a/unredact/app.py b/unredact/app.py index 9e0b602..7cbb0c4 100644 --- a/unredact/app.py +++ b/unredact/app.py @@ -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, }, diff --git a/unredact/static/app.js b/unredact/static/app.js index dd6d674..c30346e 100644 --- a/unredact/static/app.js +++ b/unredact/static/app.js @@ -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 = '
Analyzing document...
'; + // 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(); + } +}); diff --git a/unredact/static/index.html b/unredact/static/index.html index 21ca9eb..83bd5a1 100644 --- a/unredact/static/index.html +++ b/unredact/static/index.html @@ -25,14 +25,32 @@ Page 1 / 1 - - -