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 %}