diff --git a/app/routes/documents.py b/app/routes/documents.py index ca257d6..4ee37fc 100644 --- a/app/routes/documents.py +++ b/app/routes/documents.py @@ -1820,6 +1820,8 @@ def _layout_review_group_words_into_lines(words, y_tol: float = 12.0): "id": word.get("id"), "text": (word.get("text") or "").strip(), "bbox": [x1, y1, x2, y2], + "font_size_guess": float(word.get("font_size_guess") or max(6.0, (y2 - y1) * 0.75)), + "font_family_guess": (word.get("font_family_guess") or "Helvetica"), }) normalized.sort(key=lambda w: (w["bbox"][1], w["bbox"][0])) @@ -1845,12 +1847,14 @@ def _layout_review_group_words_into_lines(words, y_tol: float = 12.0): top = min(item["bbox"][1] for item in group) right = max(item["bbox"][2] for item in group) bottom = max(item["bbox"][3] for item in group) + line_font_sizes = [float(item.get("font_size_guess") or max(6.0, (item["bbox"][3] - item["bbox"][1]) * 0.75)) for item in group] + line_font_family = next((item.get("font_family_guess") for item in group if item.get("font_family_guess")), "Helvetica") lines.append({ "text": line_text, "bbox": [left, top, right, bottom], "confidence": None, - "font_family_guess": "Helvetica", - "font_size_guess": max(6.0, (bottom - top) * 0.75), + "font_family_guess": line_font_family, + "font_size_guess": round(sum(line_font_sizes) / len(line_font_sizes), 2), "text_color_guess": "#000000", "words": group, }) @@ -1898,6 +1902,7 @@ async def save_layout_review(document_id: str, request: Request, db: Session = D ), None, ) + source_version = reviewed_ocr or raw_ocr or current_text_version if source_version is None: return RedirectResponse( @@ -1933,13 +1938,27 @@ async def save_layout_review(document_id: str, request: Request, db: Session = D except Exception: continue + x_left = min(x1, x2) + x_right = max(x1, x2) + y_top = min(y1, y2) + y_bottom = max(y1, y2) + + if abs(x_right - x_left) < 1.0 or abs(y_bottom - y_top) < 1.0: + continue + + font_size_guess = float(word.get("font_size_guess") or max(6.0, (y_bottom - y_top) * 0.75)) + font_family_guess = (word.get("font_family_guess") or "Helvetica") + words.append({ "id": int(word.get("id") or word_idx), "text": (word.get("text") or "").strip(), - "bbox": [x1, y1, x2, y2], + "bbox": [x_left, y_top, x_right, y_bottom], "confidence": None, + "font_size_guess": font_size_guess, + "font_family_guess": font_family_guess, }) + words.sort(key=lambda w: (w["bbox"][1], w["bbox"][0])) lines = _layout_review_group_words_into_lines(words) rebuilt_text_lines.extend((line.get("text") or "") for line in lines) @@ -2001,6 +2020,8 @@ async def save_layout_review(document_id: str, request: Request, db: Session = D url=f"/documents/{document_id}?tab=layout-review&success=saved_layout_review", status_code=303, ) + + # --- layout review save helpers end --- @router.get("/{document_id}", response_class=HTMLResponse) @@ -2078,6 +2099,8 @@ def document_detail(document_id: str, request: Request, queue: str | None = None "id": idx, "text": (word.get("text") or "").strip(), "bbox": [float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])], + "font_size_guess": float(word.get("font_size_guess") or max(6.0, (float(bbox[3]) - float(bbox[1])) * 0.75)), + "font_family_guess": (word.get("font_family_guess") or "Helvetica"), } words.append(word_row) diff --git a/app/templates/documents/detail.html b/app/templates/documents/detail.html index e62e14e..d84e79d 100644 --- a/app/templates/documents/detail.html +++ b/app/templates/documents/detail.html @@ -385,358 +385,1259 @@ document.addEventListener("DOMContentLoaded", () => { -
No layout review data available yet.
- {% endif %} + return; + } + + pushHistory(); + const bbox = normalizeBBox([x1, y1, x2, y2]); + const newWord = { + id: nextWordId++, + text: "", + bbox, + font_size_guess: defaultFontSizeForBBox(bbox), + font_family_guess: "Helvetica", + }; + words.push(newWord); + selectedId = String(newWord.id); + refreshSelectionUI({ forceText: true }); + syncReviewedTextarea(); + renderCanvas(); + if (textInput) textInput.focus(); + setStatus("Added new box"); + } + + function setTool(nextTool) { + tool = ["select", "pan", "add"].includes(nextTool) ? nextTool : "select"; + document.getElementById("layout-tool-select")?.classList.toggle("active", tool === "select"); + document.getElementById("layout-tool-pan")?.classList.toggle("active", tool === "pan"); + document.getElementById("layout-tool-add")?.classList.toggle("active", tool === "add"); + + if (tool === "pan") { + canvas.style.cursor = "grab"; + setStatus("Pan tool"); + } else if (tool === "add") { + canvas.style.cursor = "crosshair"; + setStatus("Add tool"); + } else { + canvas.style.cursor = "default"; + setStatus("Select tool"); + } + } + + function handlePointerDown(ev) { + ev.preventDefault(); + + if (tool === "pan") { + beginPan(ev); + return; + } + if (tool === "add") { + beginAdd(ev); + return; + } + + const handleName = hitTestSelectedHandles(ev.clientX, ev.clientY); + if (handleName) { + beginResize(ev, handleName); + return; + } + + const hit = pickWord(ev.clientX, ev.clientY); + if (!hit) { + renderCanvas(); + setStatus(selectedId != null ? "Selection kept" : "No selection"); + return; + } + + beginMove(hit, ev); + } + + function handlePointerMove(ev) { + if (!dragState) return; + ev.preventDefault(); + + if (dragState.mode === "pan") { + wrap.scrollLeft = dragState.startScrollLeft - (ev.clientX - dragState.startX); + wrap.scrollTop = dragState.startScrollTop - (ev.clientY - dragState.startY); + return; + } + + if (dragState.mode === "move") { + const w = getSelectedWord(); + if (!w) return; + const rect = canvas.getBoundingClientRect(); + const dx = (ev.clientX - dragState.startX) * (Number(page.page_width || 1) / Math.max(1, rect.width)); + const dy = (ev.clientY - dragState.startY) * (Number(page.page_height || 1) / Math.max(1, rect.height)); + const rawBBox = [ + Number(dragState.startBBox[0]) + dx, + Number(dragState.startBBox[1]) + dy, + Number(dragState.startBBox[2]) + dx, + Number(dragState.startBBox[3]) + dy, + ]; + w.bbox = snapBBox(rawBBox, w.id); + refreshSelectionUI({ geometryOnly: true }); + syncReviewedTextarea(); + renderCanvas(); + return; + } + + if (dragState.mode === "resize") { + const w = getSelectedWord(); + if (!w) return; + const point = clientToPage(ev.clientX, ev.clientY); + let [x1, y1, x2, y2] = dragState.startBBox; + + if (dragState.handle === "nw") { x1 = point.x; y1 = point.y; } + else if (dragState.handle === "ne") { x2 = point.x; y1 = point.y; } + else if (dragState.handle === "sw") { x1 = point.x; y2 = point.y; } + else if (dragState.handle === "se") { x2 = point.x; y2 = point.y; } + + let nextBBox = normalizeBBox([x1, y1, x2, y2]); + const m = bboxMetrics(nextBBox); + if (m.w < 2 || m.h < 2) return; + + nextBBox = snapBBox(nextBBox, w.id); + w.bbox = nextBBox; + refreshSelectionUI({ geometryOnly: true }); + syncReviewedTextarea(); + renderCanvas(); + return; + } + + if (dragState.mode === "add-preview") { + const point = clientToPage(ev.clientX, ev.clientY); + dragState.currentPageX = point.x; + dragState.currentPageY = point.y; + renderCanvas(); + } + } + + function endDrag() { + if (!dragState) return; + if (dragState.mode === "add-preview") { + finalizeAdd(); + } else { + dragState = null; + snapGuides = null; + canvas.style.cursor = tool === "pan" ? "grab" : (tool === "add" ? "crosshair" : "default"); + renderCanvas(); + setStatus("Ready"); + } + } + + function handleKeyDown(ev) { + if (!panel.classList.contains("active")) return; + + const tag = (document.activeElement && document.activeElement.tagName || "").toLowerCase(); + const focusedId = document.activeElement ? document.activeElement.id : ""; + const propsFieldFocused = ["layout-word-text", "layout-word-font-size", "layout-x1", "layout-y1", "layout-x2", "layout-y2", "layout-popover-text", "layout-popover-font-size"].includes(focusedId); + + if ((tag === "input" || tag === "textarea" || tag === "select") && !propsFieldFocused) return; + + if (ev.key === "Delete" || ev.key === "Backspace") { + if (!propsFieldFocused || tag !== "input" || (focusedId !== "layout-word-text" && focusedId !== "layout-popover-text")) { + ev.preventDefault(); + deleteSelectedWord(); + return; + } + } + + const step = ev.shiftKey ? 5 : 1; + if (ev.key === "ArrowLeft") { ev.preventDefault(); nudge(-step, 0); } + else if (ev.key === "ArrowRight") { ev.preventDefault(); nudge(step, 0); } + else if (ev.key === "ArrowUp") { ev.preventDefault(); nudge(0, -step); } + else if (ev.key === "ArrowDown") { ev.preventDefault(); nudge(0, step); } + } + + document.getElementById("layout-apply-word")?.addEventListener("click", () => applyEditorValues(true)); + document.getElementById("layout-nudge-left")?.addEventListener("click", () => nudge(-1, 0)); + document.getElementById("layout-nudge-right")?.addEventListener("click", () => nudge(1, 0)); + document.getElementById("layout-nudge-up")?.addEventListener("click", () => nudge(0, -1)); + document.getElementById("layout-nudge-down")?.addEventListener("click", () => nudge(0, 1)); + + document.getElementById("layout-tool-select")?.addEventListener("click", () => setTool("select")); + document.getElementById("layout-tool-pan")?.addEventListener("click", () => setTool("pan")); + document.getElementById("layout-tool-add")?.addEventListener("click", () => setTool("add")); + + document.getElementById("layout-delete-word")?.addEventListener("click", deleteSelectedWord); + document.getElementById("layout-delete-word-inline")?.addEventListener("click", deleteSelectedWord); + document.getElementById("layout-popover-delete")?.addEventListener("click", deleteSelectedWord); + + document.getElementById("layout-popover-apply")?.addEventListener("click", () => { + if (textInput && popoverTextInput) textInput.value = popoverTextInput.value; + if (fontSizeInput && popoverFontSizeInput) fontSizeInput.value = popoverFontSizeInput.value; + applyEditorValues(true); + }); + + document.getElementById("layout-font-down")?.addEventListener("click", () => changeSelectedFontSize(-0.5)); + document.getElementById("layout-font-up")?.addEventListener("click", () => changeSelectedFontSize(0.5)); + document.getElementById("layout-font-all")?.addEventListener("click", applyFontSizeToAllWords); + document.getElementById("layout-popover-font-down")?.addEventListener("click", () => changeSelectedFontSize(-0.5)); + document.getElementById("layout-popover-font-up")?.addEventListener("click", () => changeSelectedFontSize(0.5)); + document.getElementById("layout-popover-font-all")?.addEventListener("click", applyFontSizeToAllWords); + + document.getElementById("layout-popover-left")?.addEventListener("click", () => nudge(-1, 0)); + document.getElementById("layout-popover-right")?.addEventListener("click", () => nudge(1, 0)); + document.getElementById("layout-popover-up")?.addEventListener("click", () => nudge(0, -1)); + document.getElementById("layout-popover-down")?.addEventListener("click", () => nudge(0, 1)); + + popoverTextInput?.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + if (textInput && popoverTextInput) textInput.value = popoverTextInput.value; + if (fontSizeInput && popoverFontSizeInput) fontSizeInput.value = popoverFontSizeInput.value; + applyEditorValues(true); + } + }); + + document.getElementById("layout-undo")?.addEventListener("click", () => restoreHistory(historyIndex - 1)); + document.getElementById("layout-redo")?.addEventListener("click", () => restoreHistory(historyIndex + 1)); + + document.getElementById("layout-zoom-in")?.addEventListener("click", () => { + zoom = Math.min(MAX_ZOOM, zoom * 1.2); + applyZoom(); + renderCanvas(); + setStatus("Zoom in"); + }); + + document.getElementById("layout-zoom-out")?.addEventListener("click", () => { + zoom = Math.max(MIN_ZOOM, zoom / 1.2); + applyZoom(); + renderCanvas(); + setStatus("Zoom out"); + }); + + document.getElementById("layout-fit-width")?.addEventListener("click", fitWidth); + + canvas.addEventListener("pointerdown", handlePointerDown, { passive: false }); + window.addEventListener("pointermove", handlePointerMove, { passive: false }); + window.addEventListener("pointerup", endDrag, { passive: false }); + window.addEventListener("keydown", handleKeyDown); + + if (saveForm) { + saveForm.addEventListener("submit", function () { + applyEditorValues(false); + syncReviewedTextarea(); + if (saveJsonInput) saveJsonInput.value = buildLayoutReviewPayload(); + }); + } + + pushHistory(); + setTool("select"); + updateZoomLabel(); + + function initialRender() { + fitWidth(); + syncReviewedTextarea(); + refreshSelectionUI({ forceText: true }); + renderCanvas(); + setStatus("Ready"); + } + + if (image.complete) initialRender(); + else image.addEventListener("load", initialRender, { once: true }); + window.addEventListener("resize", renderCanvas); + })(); + + {% else %} +No layout review data available yet.
+ {% endif %}