Add layout style editing controls and normalize review style data

This commit is contained in:
Sean McElwain 2026-05-11 21:35:55 -05:00
parent d24e144490
commit fbdef46220
2 changed files with 239 additions and 0 deletions

View File

@ -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(

View File

@ -625,6 +625,45 @@ document.addEventListener("DOMContentLoaded", () => {
</div>
</div>
<div>
<label for="layout-word-font-family">Font family</label>
<input id="layout-word-font-family" type="text" style="width:100%;" placeholder="Helvetica">
</div>
<div class="layout-field-grid">
<div>
<label for="layout-word-font-weight">Weight</label>
<select id="layout-word-font-weight" style="width:100%;">
<option value="300">300</option>
<option value="400" selected>400</option>
<option value="500">500</option>
<option value="700">700</option>
</select>
</div>
<div>
<label for="layout-word-font-style">Style</label>
<select id="layout-word-font-style" style="width:100%;">
<option value="normal" selected>Normal</option>
<option value="italic">Italic</option>
</select>
</div>
<div>
<label for="layout-word-letter-spacing">Letter spacing</label>
<input id="layout-word-letter-spacing" type="number" step="0.1" style="width:100%;">
</div>
<div>
<label for="layout-word-text-color">Text color</label>
<input id="layout-word-text-color" type="color" value="#000000" style="width:100%;">
</div>
</div>
<div id="layout-props-actions">
<button type="button" id="layout-apply-style-word">Apply Style</button>
<button type="button" id="layout-apply-style-line">Style Line</button>
<button type="button" id="layout-apply-style-page">Style Page</button>
<button type="button" id="layout-reset-style-word">Reset Style</button>
</div>
<div class="layout-field-grid">
<div>
<label for="layout-x1">x1</label>
@ -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();
});
}
})();
</script>
{% else %}