Add layout style editing controls and normalize review style data
This commit is contained in:
parent
d24e144490
commit
fbdef46220
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue