diff --git a/app/routes/documents.py b/app/routes/documents.py index 4ee37fc..cd1a738 100644 --- a/app/routes/documents.py +++ b/app/routes/documents.py @@ -644,6 +644,78 @@ def _get_current_text_versions(document: Document) -> tuple[TextVersion | None, return raw_ocr, reviewed_ocr + +def _default_word_style() -> dict: + return { + "font_family": "Helvetica", + "font_postscript_name": None, + "font_weight": 400, + "font_style": "normal", + "font_stretch": "normal", + "font_size": 10.0, + "line_height": None, + "letter_spacing": 0.0, + "word_spacing": 0.0, + "text_color": "#000000", + "opacity": 1.0, + "render_mode": "fill", + "text_align": "left", + } + + +def _merge_style_layers(inferred_style: dict | None, override_style: dict | None) -> dict: + base = _default_word_style() + if isinstance(inferred_style, dict): + base.update({k: v for k, v in inferred_style.items() if v is not None}) + if isinstance(override_style, dict): + base.update({k: v for k, v in override_style.items() if v is not None}) + return base + + +def _normalize_word_style(word: dict) -> dict: + inferred = word.get("inferred_style") if isinstance(word.get("inferred_style"), dict) else {} + override = word.get("override_style") if isinstance(word.get("override_style"), dict) else {} + resolved = _merge_style_layers(inferred, override) + + word["inferred_style"] = _merge_style_layers({}, inferred) + word["override_style"] = override + word["resolved_style"] = resolved + + manual_flags = word.get("manual_flags") if isinstance(word.get("manual_flags"), dict) else {} + manual_flags.setdefault("text_edited", False) + manual_flags.setdefault("geometry_edited", False) + manual_flags.setdefault("style_edited", False) + word["manual_flags"] = manual_flags + return word + + +def _normalize_layout_review_payload(layout_json: dict | None) -> dict: + layout_json = layout_json if isinstance(layout_json, dict) else {} + layout_json.setdefault("schema_version", 2) + layout_json.setdefault("edit_log", []) + pages = layout_json.get("pages") + if not isinstance(pages, list): + pages = [] + layout_json["pages"] = pages + + for page in pages: + words = page.get("words") + if not isinstance(words, list): + page["words"] = [] + continue + for word in words: + if isinstance(word, dict): + _normalize_word_style(word) + + return layout_json + + +def _append_layout_edit_event(layout_json: dict, event: dict) -> None: + edit_log = layout_json.setdefault("edit_log", []) + if isinstance(edit_log, list): + edit_log.append(event) + + def _extract_line_texts_from_layout(layout_json: dict | None) -> list[str]: if not layout_json: return [] @@ -1988,6 +2060,16 @@ async def save_layout_review(document_id: str, request: Request, db: Session = D new_layout_json["layout_sync_status"] = "synced" new_layout_json["layout_sync_source"] = "layout_review" new_layout_json["layout_needs_review"] = False + new_layout_json = _normalize_layout_review_payload(new_layout_json) + _append_layout_edit_event( + new_layout_json, + { + "event_type": "layout_review_save", + "actor": "user", + "source": "layout_review_editor", + "timestamp": datetime.utcnow().isoformat() + "Z", + }, + ) new_text_content = "\n".join(rebuilt_text_lines).strip() next_version_number = max( diff --git a/app/templates/documents/detail.html b/app/templates/documents/detail.html index d84e79d..cced2c0 100644 --- a/app/templates/documents/detail.html +++ b/app/templates/documents/detail.html @@ -625,6 +625,45 @@ document.addEventListener("DOMContentLoaded", () => { +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + +
+
@@ -682,6 +721,11 @@ document.addEventListener("DOMContentLoaded", () => { const idInput = document.getElementById("layout-word-id"); const textInput = document.getElementById("layout-word-text"); const fontSizeInput = document.getElementById("layout-word-font-size"); + const fontFamilyInput = document.getElementById("layout-word-font-family"); + const fontWeightInput = document.getElementById("layout-word-font-weight"); + const fontStyleInput = document.getElementById("layout-word-font-style"); + const letterSpacingInput = document.getElementById("layout-word-letter-spacing"); + const textColorInput = document.getElementById("layout-word-text-color"); const x1Input = document.getElementById("layout-x1"); const y1Input = document.getElementById("layout-y1"); const x2Input = document.getElementById("layout-x2"); @@ -691,6 +735,11 @@ document.addEventListener("DOMContentLoaded", () => { const popoverTextInput = document.getElementById("layout-popover-text"); const popoverFontSizeInput = document.getElementById("layout-popover-font-size"); + const applyStyleWordBtn = document.getElementById("layout-apply-style-word"); + const applyStyleLineBtn = document.getElementById("layout-apply-style-line"); + const applyStylePageBtn = document.getElementById("layout-apply-style-page"); + const resetStyleWordBtn = document.getElementById("layout-reset-style-word"); + const HANDLE_SIZE_PX = 14; const HANDLE_HIT_PX = 26; const SNAP_THRESHOLD_PX = 10; @@ -707,6 +756,48 @@ document.addEventListener("DOMContentLoaded", () => { if (statusEl) statusEl.textContent = msg; } + function ensureStyle(word) { + word.inferred_style = word.inferred_style || {}; + word.override_style = word.override_style || {}; + word.resolved_style = word.resolved_style || {}; + word.manual_flags = word.manual_flags || {}; + } + + function resolveStyle(word) { + const defaults = { + font_family: "Helvetica", + font_postscript_name: null, + font_weight: 400, + font_style: "normal", + font_stretch: "normal", + font_size: 10, + line_height: null, + letter_spacing: 0, + word_spacing: 0, + text_color: "#000000", + opacity: 1, + render_mode: "fill", + text_align: "left", + }; + word.resolved_style = { + ...defaults, + ...(word.inferred_style || {}), + ...(word.override_style || {}), + }; + } + + function getSelectedWord() { + return words.find(w => String(w.id) === String(selectedId)) || null; + } + + function applyStyleToWord(word, stylePatch) { + if (!word) return; + ensureStyle(word); + Object.assign(word.override_style, stylePatch); + word.manual_flags.style_edited = true; + resolveStyle(word); + } + let pages = []; try { pages = JSON.parse(dataTag.textContent || "[]"); @@ -788,6 +879,13 @@ document.addEventListener("DOMContentLoaded", () => { function syncFontInputs(value) { const v = Number.isFinite(Number(value)) ? Number(value) : ""; if (fontSizeInput) fontSizeInput.value = v; + ensureStyle(word); + resolveStyle(word); + if (fontFamilyInput) fontFamilyInput.value = word.resolved_style?.font_family || "Helvetica"; + if (fontWeightInput) fontWeightInput.value = String(word.resolved_style?.font_weight || 400); + if (fontStyleInput) fontStyleInput.value = word.resolved_style?.font_style || "normal"; + if (letterSpacingInput) letterSpacingInput.value = String(word.resolved_style?.letter_spacing || 0); + if (textColorInput) textColorInput.value = word.resolved_style?.text_color || "#000000"; if (popoverFontSizeInput) popoverFontSizeInput.value = v; } @@ -1632,6 +1730,65 @@ document.addEventListener("DOMContentLoaded", () => { if (image.complete) initialRender(); else image.addEventListener("load", initialRender, { once: true }); window.addEventListener("resize", renderCanvas); + + function collectStylePatchFromInputs() { + return { + font_family: (fontFamilyInput?.value || "Helvetica").trim() || "Helvetica", + font_size: Number(fontSizeInput?.value || 10), + font_weight: Number(fontWeightInput?.value || 400), + font_style: fontStyleInput?.value || "normal", + letter_spacing: Number(letterSpacingInput?.value || 0), + text_color: textColorInput?.value || "#000000", + }; + } + + if (applyStyleWordBtn) { + applyStyleWordBtn.addEventListener("click", function () { + const word = getSelectedWord(); + if (!word) return; + applyStyleToWord(word, collectStylePatchFromInputs()); + pushHistoryState("style-word"); + redraw(); + }); + } + + if (applyStyleLineBtn) { + applyStyleLineBtn.addEventListener("click", function () { + const word = getSelectedWord(); + if (!word) return; + const y = ((word.bbox || [0,0,0,0])[1] + (word.bbox || [0,0,0,0])[3]) / 2; + const patch = collectStylePatchFromInputs(); + for (const w of words) { + const bbox = w.bbox || [0,0,0,0]; + const wy = (bbox[1] + bbox[3]) / 2; + if (Math.abs(wy - y) <= 12) applyStyleToWord(w, patch); + } + pushHistoryState("style-line"); + redraw(); + }); + } + + if (applyStylePageBtn) { + applyStylePageBtn.addEventListener("click", function () { + const patch = collectStylePatchFromInputs(); + for (const w of words) applyStyleToWord(w, patch); + pushHistoryState("style-page"); + redraw(); + }); + } + + if (resetStyleWordBtn) { + resetStyleWordBtn.addEventListener("click", function () { + const word = getSelectedWord(); + if (!word) return; + ensureStyle(word); + word.override_style = {}; + word.manual_flags.style_edited = false; + resolveStyle(word); + pushHistoryState("style-reset"); + redraw(); + }); + } })(); {% else %}