4860 lines
186 KiB
HTML
4860 lines
186 KiB
HTML
|
||
<style id="layout-apply-feedback-fix">
|
||
#layout-popover-apply.layout-apply-flash {
|
||
outline: 3px solid #22c55e !important;
|
||
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18) !important;
|
||
font-weight: 800 !important;
|
||
}
|
||
|
||
body.layout-multi-select-active #layout-word-popover {
|
||
display: none !important;
|
||
}
|
||
</style>
|
||
|
||
{% extends "base.html" %}
|
||
{% block title %}Document Detail
|
||
<script>
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const ta = document.getElementById("extra_json");
|
||
const nums = document.getElementById("extra-json-line-numbers");
|
||
if (!ta || !nums) return;
|
||
|
||
const syncNums = () => {
|
||
const count = Math.max(1, ta.value.split("\n").length);
|
||
nums.textContent = Array.from({ length: count }, (_, i) => String(i + 1)).join("\n");
|
||
nums.scrollTop = ta.scrollTop;
|
||
};
|
||
|
||
ta.addEventListener("input", syncNums);
|
||
ta.addEventListener("scroll", () => {
|
||
nums.scrollTop = ta.scrollTop;
|
||
});
|
||
|
||
syncNums();
|
||
});
|
||
|
||
window.toolbarTextColorSyncInstalled = true;
|
||
|
||
(function installToolbarTextColorSync() {
|
||
const toolbarColor = document.getElementById('layout-toolbar-text-color');
|
||
|
||
function findPopupTextColorInput() {
|
||
return (
|
||
document.getElementById('layout-word-text-color') ||
|
||
document.getElementById('layout-markup-text-color') ||
|
||
document.querySelector('#layout-word-popover input[type="color"]')
|
||
);
|
||
}
|
||
|
||
function syncToPopup() {
|
||
const popupColor = findPopupTextColorInput();
|
||
if (toolbarColor && popupColor) {
|
||
popupColor.value = toolbarColor.value || '#111827';
|
||
popupColor.dispatchEvent(new Event('input', { bubbles: true }));
|
||
popupColor.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
}
|
||
|
||
if (toolbarColor) {
|
||
toolbarColor.addEventListener('input', syncToPopup);
|
||
toolbarColor.addEventListener('change', syncToPopup);
|
||
}
|
||
|
||
document.addEventListener('click', () => {
|
||
setTimeout(syncToPopup, 50);
|
||
});
|
||
})();
|
||
|
||
|
||
function forceLayoutRibbonLeft() {
|
||
const ribbon = document.getElementById("layout-review-toolbar");
|
||
if (ribbon) ribbon.scrollLeft = 0;
|
||
}
|
||
|
||
window.addEventListener("load", forceLayoutRibbonLeft);
|
||
setTimeout(forceLayoutRibbonLeft, 250);
|
||
setTimeout(forceLayoutRibbonLeft, 1000);
|
||
|
||
|
||
</script>
|
||
|
||
|
||
<script>
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const panel = document.querySelector('.tab-panel[data-panel="line-items"]');
|
||
if (!panel) return;
|
||
|
||
const moneyCols = [6, 7]; // total, tax
|
||
const qtyCols = [4, 5]; // qty, unit
|
||
|
||
const rows = panel.querySelectorAll("tbody tr");
|
||
rows.forEach((row) => {
|
||
const cells = row.querySelectorAll("td");
|
||
[...moneyCols, ...qtyCols].forEach((col) => {
|
||
const cell = cells[col - 1];
|
||
if (!cell) return;
|
||
const input = cell.querySelector("input");
|
||
if (!input) return;
|
||
|
||
const trimDecimals = () => {
|
||
const v = input.value.trim();
|
||
if (!v) return;
|
||
const n = Number(v.replace(/,/g, ""));
|
||
if (!Number.isFinite(n)) return;
|
||
|
||
if (moneyCols.includes(col)) {
|
||
input.value = n.toFixed(2);
|
||
} else {
|
||
input.value = Number.isInteger(n) ? String(n) : String(n);
|
||
}
|
||
};
|
||
|
||
input.addEventListener("blur", trimDecimals);
|
||
});
|
||
});
|
||
});
|
||
</script>
|
||
|
||
{% endblock %}
|
||
{% block content %}
|
||
{% if error == "line_count_mismatch" %}
|
||
<div class="error-box">
|
||
Could not save reviewed OCR because line count did not match OCR layout.
|
||
Expected {{ error_expected }}, got {{ error_actual }}.
|
||
</div>
|
||
{% elif error == "save_ocr_corrected_failed" %}
|
||
<div class="error-box">
|
||
Could not save OCR-corrected PDF. Check that reviewed OCR line count matches raw OCR line count.
|
||
</div>
|
||
{% elif success == "rerun_ocr" %}
|
||
<div class="success-message">OCR rerun successfully.</div>
|
||
{% elif success == "regenerated_line_items" %}
|
||
<div class="success-message">Line items regenerated successfully.</div>
|
||
{% elif success == "saved_replica_pdf" %}
|
||
<div class="success-message">Replica PDF saved.</div>
|
||
{% elif success == "diagnostic_docx_saved" %}
|
||
<div class="success-message">Diagnostic DOCX saved.</div>
|
||
{% elif success == "saved_reviewed_ocr" %}
|
||
<div class="success-message">Reviewed OCR saved.</div>
|
||
{% elif success == "saved_replica_pdf" %}
|
||
<div class="success-message">Replica PDF saved.</div>
|
||
{% elif success == "saved_reviewed_ocr" %}
|
||
<div class="success-message">Reviewed OCR saved.</div>
|
||
{% elif error == "rerun_ocr_failed" %}
|
||
<div class="error-box">OCR rerun failed.</div>
|
||
{% elif error == "deprecated_pdf_route_disabled" %}
|
||
<div class="error-box">This deprecated PDF save route has been disabled. Use Save Document instead.</div>
|
||
{% elif error == "clean_replica_requires_layout_ocr" %}
|
||
<div class="alert alert-warning">
|
||
Clean replica could not be generated from the current OCR because this document does not yet have usable positional layout data. Run layout-capable OCR next, then save the replica again.
|
||
</div>
|
||
{% elif error == "save_replica_pdf_failed" %}
|
||
<div class="error-box">Could not save replica PDF.</div>
|
||
{% elif error == "save_field_enriched_failed" %}
|
||
<div class="error-box">Could not save field-enriched PDF.</div>
|
||
{% endif %}
|
||
|
||
<div class="detail-sticky-header">
|
||
<div class="topbar">
|
||
<div>
|
||
<h1 class="page-title">{{ document.document_id }}</h1>
|
||
<p class="page-subtitle">{{ document.original_filename or document.canonical_filename or document.document_type }}</p>
|
||
</div>
|
||
<div class="badges">
|
||
<span class="badge {% if document.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">{{ document.review_status }}</span>
|
||
{% if review_state and review_state.reviewed_at %}
|
||
<span class="badge reviewed">doc reviewed</span>
|
||
{% endif %}
|
||
{% if review_state and review_state.is_approved %}
|
||
<span class="badge reviewed">approved</span>
|
||
{% endif %}
|
||
{% if review_state and review_state.is_excluded %}
|
||
<span class="badge">excluded</span>
|
||
{% endif %}
|
||
<span class="badge">{{ document.document_type }}</span>
|
||
<span class="badge">{{ document.mime_type }}</span>
|
||
</div>
|
||
</div> <div class="card" style="margin-bottom: 0;">
|
||
<div style="display:flex; flex-direction:column; gap:0.75rem;">
|
||
|
||
|
||
<div class="detail-doc-actions-row">
|
||
<div class="detail-doc-actions-grid">
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-document-type" class="detail-doc-type-form">
|
||
<div class="detail-type-input-wrap" style="position:relative;">
|
||
<label for="document_type_input">Document type</label>
|
||
<input
|
||
id="document_type_input"
|
||
type="text"
|
||
name="document_type"
|
||
value="{{ document.document_type or '' }}"
|
||
placeholder="receipt"
|
||
autocomplete="off"
|
||
style="min-width:160px; max-width:260px;"
|
||
>
|
||
<div id="document-type-suggestions" style="display:none; position:absolute; top:100%; left:0; right:0; z-index:20; background:#fff; border:1px solid #d7dce5; border-radius:12px; margin-top:0.35rem; max-height:220px; overflow-y:auto; box-shadow:0 10px 24px rgba(15,23,42,0.10);"></div>
|
||
</div>
|
||
<button type="submit" class="top-pill-button detail-update-button" style="height:38px;">Update</button>
|
||
</form>
|
||
|
||
<div class="detail-flags-stack">
|
||
<label class="detail-check-label detail-check-inline">
|
||
<input type="checkbox" form="save-review-flags-form" name="is_approved" value="1" {% if review_state and review_state.is_approved %}checked{% endif %}>
|
||
<span>Approved</span>
|
||
</label>
|
||
<label class="detail-check-label detail-check-inline">
|
||
<input type="checkbox" form="save-review-flags-form" name="is_excluded" value="1" {% if review_state and review_state.is_excluded %}checked{% endif %}>
|
||
<span>Excluded</span>
|
||
</label>
|
||
</div>
|
||
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-review-flags" class="detail-review-flags-form" id="save-review-flags-form">
|
||
<button type="submit" class="top-pill-button detail-saveflags-button" style="height:38px;">Save flags</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-pdf" id="save-pdf-form" class="detail-save-pdf-form" style="display:flex; align-items:flex-end; gap:0.6rem; flex-wrap:wrap; margin:0;">
|
||
<div class="detail-path-row" style="display:flex; align-items:flex-end; gap:0.6rem; width:100%; flex-wrap:nowrap;">
|
||
<div style="flex:1; min-width:260px;">
|
||
<label for="proposed_storage_path_input">Proposed path</label>
|
||
<input
|
||
id="proposed_storage_path_input"
|
||
type="text"
|
||
name="output_path"
|
||
value="{{ proposed_storage_path }}"
|
||
data-default-path="{{ proposed_storage_path }}"
|
||
readonly
|
||
style="width:100%;"
|
||
>
|
||
</div>
|
||
<button type="button" id="toggle-path-edit" class="top-pill-button">Edit path</button>
|
||
</div>
|
||
</form>
|
||
<div class="button-row" style="margin-top:0.6rem;">
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-replica-pdf" style="display:inline;">
|
||
<input type="hidden" name="return_tab" value="ocr-review">
|
||
<input type="hidden" name="return_viewer_source" value="replica">
|
||
<button type="submit">Save Replica PDF</button>
|
||
</form>
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-replica-pdf-debug-overlay" style="display:inline;">
|
||
<input type="hidden" name="return_tab" value="ocr-review">
|
||
<input type="hidden" name="return_viewer_source" value="replica_debug_overlay">
|
||
<button type="submit">Save Replica PDF (Debug Overlay)</button>
|
||
</form>
|
||
|
||
<form method="post" action="/documents/{{ document.document_id }}/export-diagnostic-docx" style="display:inline;">
|
||
<button type="submit">Save Diagnostic DOCX</button>
|
||
</form>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="queue-nav-row">
|
||
<a class="button-link" href="/queue/">Back to Queue</a>
|
||
{% if next_ocr_doc %}
|
||
<a class="button-link" href="/documents/{{ next_ocr_doc.document_id }}">Next in Queue</a>
|
||
{% elif next_fields_doc %}
|
||
<a class="button-link" href="/documents/{{ next_fields_doc.document_id }}">Next in Queue</a>
|
||
{% elif next_doc %}
|
||
<a class="button-link" href="/documents/{{ next_doc.document_id }}">Next in Queue</a>
|
||
{% endif %}
|
||
<form method="post" action="/documents/{{ document.document_id }}/move-to-trash" class="detail-trash-form detail-trash-inline">
|
||
<button class="danger" type="submit">Move to trash</button>
|
||
</form>
|
||
<button type="submit" form="save-pdf-form" class="button-link primary detail-save-document-button">Save Document</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{% if success == "saved_replica_pdf_debug_overlay" %}
|
||
<div style="background:#ecfdf5; border:1px solid #86efac; color:#166534; padding:0.75rem 1rem; border-radius:10px; margin-bottom:1rem;">
|
||
Debug overlay PDF saved.
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if error == "save_replica_pdf_debug_overlay_failed" %}
|
||
<div style="background:#ffe4e6; border:1px solid #fecdd3; color:#7f1d1d; padding:0.75rem 1rem; border-radius:10px; margin-bottom:1rem;">
|
||
Could not save debug overlay PDF.
|
||
</div>
|
||
{% endif %}
|
||
|
||
|
||
{% if error == "storage_unavailable" %}
|
||
<div style="background:#ffe4e6; border:1px solid #fecdd3; color:#7f1d1d; padding:0.75rem 1rem; border-radius:10px; margin-bottom:1rem;">
|
||
Storage mount unavailable. Please retry in a moment.
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if error == "clean_replica_has_no_renderable_lines" %}
|
||
<div style="background:#fff7ed; border:1px solid #fdba74; color:#9a3412; padding:0.75rem 1rem; border-radius:10px; margin-bottom:1rem;">
|
||
Clean replica could not be generated because this document does not yet have usable text layout data. Save the scan-backed replica for now, or re-run OCR/review first.
|
||
</div>
|
||
{% endif %}
|
||
|
||
|
||
|
||
<div class="detail-view-mode-bar">
|
||
<button type="button" class="detail-view-mode-button active" data-detail-mode="split">Split</button>
|
||
<button type="button" class="detail-view-mode-button" data-detail-mode="preview">PDF</button>
|
||
<button type="button" class="detail-view-mode-button" data-detail-mode="review">Review</button>
|
||
</div>
|
||
|
||
|
||
|
||
<div class="workspace-grid">
|
||
<section>
|
||
<div class="card preview-card">
|
||
<div class="preview-card-header">
|
||
<h2 class="card-title">Document preview</h2>
|
||
|
||
<div class="preview-source-toggle">
|
||
<a class="preview-source-link{% if viewer_source == 'scan' %} active{% endif %}" href="/documents/{{ document.document_id }}?tab={{ active_tab }}&viewer_source=scan">Scan</a>
|
||
{% if replica_clean_output %}
|
||
<a class="preview-source-link{% if viewer_source == 'replica' %} active{% endif %}" href="/documents/{{ document.document_id }}?tab={{ active_tab }}&viewer_source=replica">Replica</a>
|
||
{% endif %}
|
||
{% if replica_debug_overlay_output %}
|
||
<a class="preview-source-link{% if viewer_source == 'replica_debug_overlay' %} active{% endif %}" href="/documents/{{ document.document_id }}?tab={{ active_tab }}&viewer_source=replica_debug_overlay">Replica (Debug)</a>
|
||
<a class="preview-source-link{% if viewer_source == 'docx' %} active{% endif %}" href="/documents/{{ document.document_id }}?tab={{ active_tab }}&viewer_source=docx">DOCX</a>
|
||
{% endif %}
|
||
</div>
|
||
{% if overlay_page_data %}
|
||
<div class="preview-overlay-controls" style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.75rem;">
|
||
<label style="display:flex; align-items:center; gap:0.35rem; font-size:0.95rem;">
|
||
<input type="checkbox" id="overlay-toggle-text">
|
||
<span>Show OCR text</span>
|
||
</label>
|
||
<label style="display:flex; align-items:center; gap:0.35rem; font-size:0.95rem;">
|
||
<input type="checkbox" id="overlay-toggle-boxes">
|
||
<span>Show boxes</span>
|
||
</label>
|
||
<label style="display:flex; align-items:center; gap:0.35rem; font-size:0.95rem;">
|
||
<input type="radio" name="overlay-level" id="overlay-level-lines" value="lines" checked>
|
||
<span>Lines</span>
|
||
</label>
|
||
<label style="display:flex; align-items:center; gap:0.35rem; font-size:0.95rem;">
|
||
<input type="radio" name="overlay-level" id="overlay-level-words" value="words">
|
||
<span>Words</span>
|
||
</label>
|
||
</div>
|
||
<div class="preview-overlay-controls diagnostic-candidate-row" style="display:flex; gap:0.4rem; flex-wrap:wrap; margin-top:0.45rem; align-items:center;">
|
||
<div style="display:inline-flex; gap:0.35rem; align-items:center; flex-wrap:nowrap;">
|
||
<span style="font-size:0.82rem; color:#6b7280; white-space:nowrap;">Diagnostics:</span>
|
||
<form method="post" action="/documents/{{ document.document_id }}/run-diagnostic-candidates" style="display:inline;">
|
||
<button type="submit" style="padding:0.18rem 0.45rem; font-size:0.76rem;">Run</button>
|
||
</form>
|
||
|
||
{% set selected_diag = diagnostic_outputs | selectattr("is_selected") | list | first %}
|
||
{% set default_diag = selected_diag or (diagnostic_outputs | selectattr("file_path") | list | first) %}
|
||
|
||
{% if diagnostic_outputs %}
|
||
<form method="post" action="/documents/{{ document.document_id }}/diagnostic-output/select" style="display:inline-flex; gap:0.35rem; align-items:center; flex-wrap:nowrap;">
|
||
<select id="diagnostic-output-select" name="diagnostic_output_id" style="max-width:13rem; font-size:0.78rem; padding:0.18rem 0.35rem;">
|
||
{% for out in diagnostic_outputs %}
|
||
{% if out.file_path %}
|
||
<option
|
||
value="{{ out.id }}"
|
||
data-view="/documents/{{ document.document_id }}/diagnostic-output/{{ out.id }}/view"
|
||
data-download="/documents/{{ document.document_id }}/diagnostic-output/{{ out.id }}/download"
|
||
{% if default_diag and out.id == default_diag.id %}selected{% endif %}
|
||
>
|
||
{% if out.is_selected %}✓ {% endif %}{{ out.engine }} {{ out.output_type }} v{{ out.version_number }}
|
||
</option>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</select>
|
||
<button type="submit" style="padding:0.18rem 0.45rem; font-size:0.76rem;">Select</button>
|
||
</form>
|
||
</div>
|
||
|
||
<a id="diagnostic-view-link" href="{% if default_diag %}/documents/{{ document.document_id }}/diagnostic-output/{{ default_diag.id }}/view{% else %}#{% endif %}" target="_blank" style="font-size:0.78rem;">View</a>
|
||
<a id="diagnostic-download-link" href="{% if default_diag %}/documents/{{ document.document_id }}/diagnostic-output/{{ default_diag.id }}/download{% else %}#{% endif %}" style="font-size:0.78rem;">Download</a>
|
||
|
||
<script>
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const sel = document.getElementById("diagnostic-output-select");
|
||
const view = document.getElementById("diagnostic-view-link");
|
||
const dl = document.getElementById("diagnostic-download-link");
|
||
if (!sel || !view || !dl) return;
|
||
|
||
const syncDiagnosticLinks = () => {
|
||
const opt = sel.options[sel.selectedIndex];
|
||
if (!opt) return;
|
||
view.href = opt.dataset.view || "#";
|
||
dl.href = opt.dataset.download || "#";
|
||
};
|
||
|
||
sel.addEventListener("change", syncDiagnosticLinks);
|
||
syncDiagnosticLinks();
|
||
});
|
||
</script>
|
||
{% else %}
|
||
<span style="font-size:0.78rem; color:#6b7280;">none</span>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% endif %}
|
||
|
||
|
||
</div>
|
||
{% if not storage_available %}
|
||
<p class="empty-state">Storage mount unavailable. Preview is temporarily unavailable.</p>
|
||
{% elif file_url %}
|
||
{% if viewer_source == "docx" %}
|
||
<div class="preview-frame-wrap">
|
||
<iframe
|
||
class="preview-frame"
|
||
id="preview-frame"
|
||
src="/documents/{{ document.document_id }}/diagnostic-docx-html"
|
||
style="width:100%; min-height:78vh; border:0; background:white;"
|
||
loading="lazy">
|
||
</iframe>
|
||
</div>
|
||
{% elif document.mime_type == "application/pdf" %}
|
||
<div class="preview-overlay-stack" style="position:relative;">
|
||
<embed class="preview-frame" id="preview-frame" src="{{ file_url }}" type="application/pdf">
|
||
{% if overlay_page_data %}
|
||
<div id="ocr-overlay-root" style="position:absolute; inset:0; pointer-events:none; overflow:hidden;"></div>
|
||
<script id="ocr-overlay-data" type="application/json">{{ overlay_page_data|tojson }}</script>
|
||
{% endif %}
|
||
</div>
|
||
{% elif document.mime_type in ["image/jpeg", "image/png"] %}
|
||
<img class="preview-image" src="{{ file_url }}" alt="Document image">
|
||
{% else %}
|
||
<p><a href="{{ file_url }}" target="_blank">Open file</a></p>
|
||
{% endif %}
|
||
{% else %}
|
||
<p class="empty-state">No preview available.</p>
|
||
{% endif %}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div class="card">
|
||
<div class="right-pane-tabs">
|
||
<button class="tab-button{% if active_tab in ['ocr-review', 'raw-ocr', 'source-options'] %} active{% endif %}" type="button" data-tab="ocr-review">OCR Review</button>
|
||
<button class="tab-button{% if active_tab == 'layout-review' %} active{% endif %}" type="button" data-tab="layout-review">Layout Review</button>
|
||
<button class="tab-button{% if active_tab == 'extracted-fields' %} active{% endif %}" type="button" data-tab="extracted-fields">Extracted Fields</button>
|
||
<button class="tab-button{% if active_tab == 'additional-fields' %} active{% endif %}" type="button" data-tab="additional-fields">Additional Fields</button>
|
||
<button class="tab-button{% if active_tab == 'line-items' %} active{% endif %}" type="button" data-tab="line-items">Line Items</button>
|
||
<button class="tab-button{% if active_tab == 'versions' %} active{% endif %}" type="button" data-tab="versions">Versions</button>
|
||
<button class="tab-button{% if active_tab == 'raw-ocr' %} active{% endif %}" type="button" data-tab="raw-ocr">Raw OCR</button>
|
||
<button class="tab-button{% if active_tab == 'source-options' %} active{% endif %}" type="button" data-tab="source-options">Source Options</button>
|
||
</div>
|
||
|
||
<div class="tab-panel{% if active_tab in ['ocr-review', 'raw-ocr', 'source-options'] %} active{% endif %}" data-panel="ocr-review">
|
||
<div class="ocr-review-header-row">
|
||
<h2 class="card-title">Reviewed OCR</h2>
|
||
<form method="post" action="/documents/{{ document.document_id }}/rerun-ocr" class="ocr-rerun-inline-form">
|
||
<button type="submit">Rerun OCR</button>
|
||
</form>
|
||
</div>
|
||
{% if current_text_version %}
|
||
<p>Current OCR version: v{{ current_text_version.version_number }} — {{ current_text_version.version_type }} — {{ current_text_version.created_at }}</p>
|
||
{% else %}
|
||
<p class="empty-state">No OCR version available yet.</p>
|
||
{% endif %}
|
||
|
||
<p>
|
||
Expected OCR lines: <span id="expected-lines">{{ expected_line_count }}</span>
|
||
|
|
||
Current editor lines: <span id="actual-lines">{{ actual_line_count }}</span>
|
||
</p>
|
||
|
||
<form method="post" action="/documents/{{ document.document_id }}/review-text">
|
||
<div class="form-field full">
|
||
<label for="reviewed_text">
|
||
Review OCR text (1 line each; mismatch affects PDF)
|
||
</label>
|
||
<div class="editor-wrap">
|
||
<pre class="line-numbers" id="line-numbers">{% for n in line_numbers %}{{ n }}
|
||
{% endfor %}</pre>
|
||
<textarea id="reviewed_text" name="reviewed_text" rows="34" spellcheck="false">{{ review_text_value }}</textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-field full">
|
||
<label>Quality flags</label>
|
||
<div>
|
||
{% for flag in quality_flag_options %}
|
||
<label style="display:block; margin-bottom: 0.25rem;">
|
||
<input type="checkbox" name="quality_flags" value="{{ flag }}" {% if flag in current_quality_flags %}checked{% endif %}>
|
||
{{ flag }}
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-field full">
|
||
<label for="quality_note">Quality note</label>
|
||
<textarea id="quality_note" name="quality_note" rows="4">{{ current_quality_note }}</textarea>
|
||
</div>
|
||
|
||
<div class="button-row">
|
||
<button class="primary" type="submit" id="save-reviewed-btn">Save reviewed OCR</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
|
||
|
||
<div class="tab-panel{% if active_tab == 'layout-review' %} active{% endif %}" data-panel="layout-review">
|
||
{% if layout_review_image_url and layout_review_pages %}
|
||
<style>
|
||
#layout-review-toolbar {
|
||
display:flex;
|
||
flex-wrap:wrap;
|
||
gap:0.5rem;
|
||
align-items:center;
|
||
margin-bottom:0.75rem;
|
||
padding:0.85rem;
|
||
border:1px solid #ddd;
|
||
border-radius:18px;
|
||
background:#fff;
|
||
}
|
||
|
||
#layout-review-shell {
|
||
display:grid;
|
||
grid-template-columns:minmax(0,1fr) 320px;
|
||
gap:1rem;
|
||
align-items:start;
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
#layout-review-shell {
|
||
grid-template-columns:1fr;
|
||
}
|
||
}
|
||
|
||
.layout-tool-btn {
|
||
border:1px solid #cbd5e1;
|
||
background:#fff;
|
||
color:#0f172a;
|
||
border-radius:999px;
|
||
padding:0.5rem 0.8rem;
|
||
font:inherit;
|
||
}
|
||
|
||
.layout-tool-btn.active {
|
||
background:#eff6ff;
|
||
border-color:#60a5fa;
|
||
color:#1d4ed8;
|
||
}
|
||
|
||
.layout-tool-btn.primary {
|
||
background:#2563eb;
|
||
border-color:#2563eb;
|
||
color:#fff;
|
||
}
|
||
|
||
.layout-tool-btn.danger,
|
||
#layout-delete-word-inline,
|
||
#layout-popover-delete {
|
||
background:#fee2e2;
|
||
border-color:#fecaca;
|
||
color:#991b1b;
|
||
}
|
||
|
||
#layout-review-status {
|
||
color:#475569;
|
||
font-size:0.95rem;
|
||
}
|
||
|
||
#layout-review-canvas-wrap {
|
||
position:relative;
|
||
width:100%;
|
||
min-height:900px;
|
||
border:1px solid #ddd;
|
||
border-radius:18px;
|
||
overflow:auto;
|
||
background:#fafafa;
|
||
touch-action:none;
|
||
-webkit-user-select:none;
|
||
user-select:none;
|
||
-webkit-touch-callout:none;
|
||
}
|
||
|
||
#layout-review-stage {
|
||
position:relative;
|
||
width:100%;
|
||
display:block;
|
||
}
|
||
|
||
#layout-review-image {
|
||
display:block;
|
||
width:100%;
|
||
height:auto;
|
||
max-width:none;
|
||
pointer-events:none;
|
||
-webkit-user-drag:none;
|
||
user-drag:none;
|
||
}
|
||
|
||
#layout-review-canvas {
|
||
position:absolute;
|
||
inset:0;
|
||
width:100%;
|
||
height:100%;
|
||
touch-action:none;
|
||
}
|
||
|
||
#layout-review-debug {
|
||
position:sticky;
|
||
left:8px;
|
||
bottom:8px;
|
||
z-index:6;
|
||
width:fit-content;
|
||
margin:8px;
|
||
background:rgba(0,0,0,0.78);
|
||
color:#fff;
|
||
padding:4px 8px;
|
||
border-radius:8px;
|
||
font-size:12px;
|
||
}
|
||
|
||
#layout-props-card {
|
||
border:1px solid #ddd;
|
||
border-radius:18px;
|
||
padding:0.9rem;
|
||
background:#fff;
|
||
}
|
||
|
||
.layout-field-grid {
|
||
display:grid;
|
||
grid-template-columns:1fr 1fr;
|
||
gap:0.5rem;
|
||
}
|
||
|
||
@media (max-width: 560px) {
|
||
.layout-field-grid {
|
||
grid-template-columns:1fr;
|
||
}
|
||
}
|
||
|
||
#layout-props-actions {
|
||
display:flex;
|
||
gap:0.5rem;
|
||
flex-wrap:wrap;
|
||
}
|
||
|
||
#layout-word-popover {
|
||
display:none;
|
||
position:absolute;
|
||
z-index:20;
|
||
min-width:240px;
|
||
max-width:300px;
|
||
background:#ffffff;
|
||
border:1px solid #cbd5e1;
|
||
border-radius:14px;
|
||
box-shadow:0 10px 30px rgba(15,23,42,0.16);
|
||
padding:0.7rem;
|
||
}
|
||
|
||
.layout-mini-grid {
|
||
display:grid;
|
||
grid-template-columns:1fr auto auto auto;
|
||
gap:0.35rem;
|
||
align-items:center;
|
||
}
|
||
|
||
|
||
|
||
/* Word-like floating mini toolbar for selected word */
|
||
#layout-word-popover {
|
||
width: min(92vw, 30rem) !important;
|
||
padding: 0.55rem !important;
|
||
border: 1px solid #b8c6d8 !important;
|
||
border-radius: 0.65rem !important;
|
||
background: linear-gradient(#f8fbff, #eef4fc) !important;
|
||
box-shadow: 0 0.7rem 1.8rem rgba(15,23,42,0.18) !important;
|
||
}
|
||
|
||
#layout-word-popover > div:first-child {
|
||
margin-bottom: 0.3rem !important;
|
||
font-size: 0.72rem !important;
|
||
color: #475569 !important;
|
||
text-transform: uppercase !important;
|
||
letter-spacing: 0.03em !important;
|
||
}
|
||
|
||
#layout-word-popover input[type="text"],
|
||
#layout-word-popover input[type="number"] {
|
||
min-height: 1.75rem !important;
|
||
padding: 0.2rem 0.45rem !important;
|
||
border: 1px solid #cbd5e1 !important;
|
||
border-radius: 0.35rem !important;
|
||
font-size: 0.8rem !important;
|
||
background: #ffffff !important;
|
||
}
|
||
|
||
#layout-word-popover input[type="color"] {
|
||
width: 1.55rem !important;
|
||
height: 1.55rem !important;
|
||
padding: 0.04rem !important;
|
||
border: 1px solid #94a3b8 !important;
|
||
border-radius: 0.25rem !important;
|
||
}
|
||
|
||
#layout-word-popover label {
|
||
font-size: 0.72rem !important;
|
||
color: #475569 !important;
|
||
white-space: nowrap !important;
|
||
}
|
||
|
||
#layout-word-popover button {
|
||
min-height: 1.75rem !important;
|
||
padding: 0.22rem 0.55rem !important;
|
||
border: 1px solid #b8c6d8 !important;
|
||
border-radius: 0.35rem !important;
|
||
background: linear-gradient(#ffffff, #e9eef7) !important;
|
||
color: #1f2937 !important;
|
||
font-size: 0.76rem !important;
|
||
}
|
||
|
||
#layout-popover-apply {
|
||
background: linear-gradient(#dbeafe, #bfdbfe) !important;
|
||
border-color: #60a5fa !important;
|
||
color: #1e3a8a !important;
|
||
}
|
||
|
||
#layout-popover-delete {
|
||
background: linear-gradient(#fff1f2, #ffe4e6) !important;
|
||
border-color: #fda4af !important;
|
||
color: #9f1239 !important;
|
||
}
|
||
|
||
#layout-word-popover .layout-mini-grid {
|
||
grid-template-columns: 4rem auto 4.8rem auto auto auto !important;
|
||
gap: 0.28rem !important;
|
||
align-items: center !important;
|
||
}
|
||
|
||
|
||
/* Floating selected-word mini toolbar: Word-like font group */
|
||
#layout-word-popover {
|
||
width: min(94vw, 34rem) !important;
|
||
padding: 0.55rem 0.65rem !important;
|
||
border: 1px solid #b8c6d8 !important;
|
||
border-radius: 0.65rem !important;
|
||
background: linear-gradient(#f8fbff, #eef4fc) !important;
|
||
box-shadow: 0 0.7rem 1.8rem rgba(15,23,42,0.18) !important;
|
||
}
|
||
|
||
#layout-word-popover > div:first-child {
|
||
font-size: 0.72rem !important;
|
||
font-weight: 700 !important;
|
||
text-transform: uppercase !important;
|
||
letter-spacing: 0.04em !important;
|
||
color: #475569 !important;
|
||
margin-bottom: 0.35rem !important;
|
||
}
|
||
|
||
#layout-word-popover > div:nth-child(2) {
|
||
display: grid !important;
|
||
grid-template-columns: 1.7fr 1fr auto auto auto auto auto !important;
|
||
gap: 0.3rem !important;
|
||
align-items: center !important;
|
||
}
|
||
|
||
#layout-word-popover input[type="text"],
|
||
#layout-word-popover input[type="number"] {
|
||
min-height: 2rem !important;
|
||
padding: 0.25rem 0.45rem !important;
|
||
border: 1px solid #cbd5e1 !important;
|
||
border-radius: 0.32rem !important;
|
||
font-size: 0.86rem !important;
|
||
background: #ffffff !important;
|
||
}
|
||
|
||
#layout-word-popover label {
|
||
font-size: 0.72rem !important;
|
||
color: #475569 !important;
|
||
white-space: nowrap !important;
|
||
}
|
||
|
||
#layout-word-popover input[type="color"] {
|
||
width: 2rem !important;
|
||
height: 2rem !important;
|
||
padding: 0.04rem !important;
|
||
border: 1px solid #94a3b8 !important;
|
||
border-radius: 0.25rem !important;
|
||
}
|
||
|
||
#layout-word-popover button {
|
||
min-height: 2rem !important;
|
||
padding: 0.25rem 0.55rem !important;
|
||
border: 1px solid #b8c6d8 !important;
|
||
border-radius: 0.32rem !important;
|
||
background: linear-gradient(#ffffff, #e9eef7) !important;
|
||
color: #1f2937 !important;
|
||
font-size: 0.82rem !important;
|
||
font-weight: 600 !important;
|
||
}
|
||
|
||
#layout-word-popover .layout-mini-grid {
|
||
display: contents !important;
|
||
}
|
||
|
||
#layout-popover-apply {
|
||
background: linear-gradient(#dbeafe, #bfdbfe) !important;
|
||
border-color: #60a5fa !important;
|
||
color: #1e3a8a !important;
|
||
}
|
||
|
||
#layout-popover-delete {
|
||
background: linear-gradient(#fff1f2, #ffe4e6) !important;
|
||
border-color: #fda4af !important;
|
||
color: #9f1239 !important;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
#layout-review-toolbar {
|
||
grid-template-columns: repeat(4, auto) !important;
|
||
}
|
||
|
||
#layout-word-popover > div:nth-child(2) {
|
||
grid-template-columns: 1fr 4.5rem auto auto !important;
|
||
}
|
||
}
|
||
|
||
|
||
/* Word-like selected-word mini toolbar, compact not huge */
|
||
#layout-word-popover {
|
||
width: min(92vw, 28rem) !important;
|
||
padding: 0.55rem !important;
|
||
border-radius: 0.65rem !important;
|
||
background: #f8fbff !important;
|
||
border: 1px solid #cbd5e1 !important;
|
||
box-shadow: 0 0.65rem 1.4rem rgba(15,23,42,0.18) !important;
|
||
}
|
||
|
||
#layout-word-popover .layout-mini-grid {
|
||
grid-template-columns: 4.5rem auto auto auto auto auto !important;
|
||
gap: 0.25rem !important;
|
||
align-items: center !important;
|
||
}
|
||
|
||
#layout-word-popover input[type="text"],
|
||
#layout-word-popover input[type="number"] {
|
||
min-height: 1.8rem !important;
|
||
font-size: 0.82rem !important;
|
||
padding: 0.22rem 0.4rem !important;
|
||
}
|
||
|
||
#layout-word-popover button {
|
||
min-height: 1.8rem !important;
|
||
padding: 0.22rem 0.48rem !important;
|
||
font-size: 0.78rem !important;
|
||
}
|
||
|
||
#layout-word-popover input[type="color"] {
|
||
width: 1.65rem !important;
|
||
height: 1.65rem !important;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
#layout-review-toolbar {
|
||
gap: 0.22rem !important;
|
||
padding: 0.35rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar .layout-tool-btn,
|
||
#layout-review-toolbar button {
|
||
font-size: 0.76rem !important;
|
||
padding: 0.25rem 0.42rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar label {
|
||
font-size: 0.72rem !important;
|
||
padding: 0.15rem 0.28rem !important;
|
||
}
|
||
}
|
||
|
||
|
||
/* Rebuilt Word-style ribbon toolbar */
|
||
#layout-review-toolbar.word-ribbon-toolbar {
|
||
display: flex !important;
|
||
flex-wrap: wrap !important;
|
||
align-items: stretch !important;
|
||
gap: 0 !important;
|
||
padding: 0.25rem 0.35rem !important;
|
||
border: 1px solid #c7d3e2 !important;
|
||
border-radius: 0.55rem !important;
|
||
background: #f7f9fc !important;
|
||
box-shadow: inset 0 -1px 0 #d8e1ec !important;
|
||
}
|
||
|
||
.word-ribbon-group {
|
||
display: flex !important;
|
||
flex-direction: column !important;
|
||
justify-content: space-between !important;
|
||
gap: 0.18rem !important;
|
||
padding: 0.25rem 0.55rem 0.12rem !important;
|
||
border-right: 1px solid #cbd5e1 !important;
|
||
min-height: 4.1rem !important;
|
||
}
|
||
|
||
.word-ribbon-row {
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
gap: 0.25rem !important;
|
||
min-height: 1.55rem !important;
|
||
}
|
||
|
||
.word-ribbon-label {
|
||
text-align: center !important;
|
||
font-size: 0.64rem !important;
|
||
color: #64748b !important;
|
||
line-height: 1 !important;
|
||
}
|
||
|
||
#layout-review-toolbar .layout-tool-btn,
|
||
#layout-review-toolbar button {
|
||
min-height: 1.55rem !important;
|
||
padding: 0.18rem 0.45rem !important;
|
||
border: 1px solid #b8c6d8 !important;
|
||
border-radius: 0.22rem !important;
|
||
background: linear-gradient(#ffffff, #eef3fa) !important;
|
||
color: #1f2937 !important;
|
||
font-size: 0.76rem !important;
|
||
font-weight: 600 !important;
|
||
line-height: 1 !important;
|
||
}
|
||
|
||
#layout-review-toolbar .layout-tool-btn.active,
|
||
#layout-review-toolbar .layout-tool-btn.primary {
|
||
background: #dbeafe !important;
|
||
border-color: #60a5fa !important;
|
||
color: #1e3a8a !important;
|
||
}
|
||
|
||
#layout-review-toolbar .layout-tool-btn.danger {
|
||
background: #ffe4e6 !important;
|
||
border-color: #fda4af !important;
|
||
color: #9f1239 !important;
|
||
}
|
||
|
||
#layout-review-toolbar label {
|
||
display: inline-flex !important;
|
||
align-items: center !important;
|
||
gap: 0.22rem !important;
|
||
font-size: 0.74rem !important;
|
||
color: #334155 !important;
|
||
white-space: nowrap !important;
|
||
}
|
||
|
||
#layout-review-toolbar input[type="checkbox"] {
|
||
width: 0.95rem !important;
|
||
height: 0.95rem !important;
|
||
margin: 0 !important;
|
||
accent-color: #2563eb !important;
|
||
}
|
||
|
||
#layout-review-toolbar input[type="color"] {
|
||
width: 1.45rem !important;
|
||
height: 1.45rem !important;
|
||
padding: 0 !important;
|
||
border: 1px solid #94a3b8 !important;
|
||
border-radius: 0.2rem !important;
|
||
background: #ffffff !important;
|
||
}
|
||
|
||
.word-ribbon-select,
|
||
.word-ribbon-font-size {
|
||
height: 1.55rem !important;
|
||
display: inline-flex !important;
|
||
align-items: center !important;
|
||
padding: 0 0.4rem !important;
|
||
border: 1px solid #b8c6d8 !important;
|
||
border-radius: 0.2rem !important;
|
||
background: white !important;
|
||
font-size: 0.74rem !important;
|
||
color: #334155 !important;
|
||
}
|
||
|
||
.word-ribbon-select {
|
||
min-width: 5.3rem !important;
|
||
}
|
||
|
||
.word-ribbon-blue-a {
|
||
font-size: 1.05rem !important;
|
||
font-weight: 700 !important;
|
||
color: #2563eb !important;
|
||
}
|
||
|
||
#layout-zoom-label,
|
||
#layout-review-status {
|
||
font-size: 0.8rem !important;
|
||
color: #334155 !important;
|
||
padding: 0 0.25rem !important;
|
||
white-space: nowrap !important;
|
||
}
|
||
|
||
|
||
/* Force Word-like single horizontal ribbon */
|
||
#layout-review-toolbar.word-ribbon-toolbar {
|
||
display: flex !important;
|
||
flex-wrap: nowrap !important;
|
||
align-items: stretch !important;
|
||
gap: 0 !important;
|
||
padding: 0.25rem 0.35rem !important;
|
||
min-height: 4.85rem !important;
|
||
max-height: 5.25rem !important;
|
||
overflow-x: auto !important;
|
||
overflow-y: hidden !important;
|
||
border: 1px solid #c7d3e2 !important;
|
||
border-radius: 0.45rem !important;
|
||
background: #f6f8fb !important;
|
||
}
|
||
|
||
#layout-review-toolbar .word-ribbon-group {
|
||
flex: 0 0 auto !important;
|
||
min-height: 4.3rem !important;
|
||
height: 4.3rem !important;
|
||
padding: 0.25rem 0.55rem 0.1rem !important;
|
||
border-right: 1px solid #cbd5e1 !important;
|
||
display: flex !important;
|
||
flex-direction: column !important;
|
||
justify-content: space-between !important;
|
||
}
|
||
|
||
#layout-review-toolbar .word-ribbon-row {
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
gap: 0.22rem !important;
|
||
min-height: 1.45rem !important;
|
||
white-space: nowrap !important;
|
||
}
|
||
|
||
#layout-review-toolbar .word-ribbon-label {
|
||
height: 0.75rem !important;
|
||
font-size: 0.58rem !important;
|
||
color: #64748b !important;
|
||
text-align: center !important;
|
||
line-height: 0.75rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar button,
|
||
#layout-review-toolbar .layout-tool-btn {
|
||
min-height: 1.45rem !important;
|
||
height: 1.45rem !important;
|
||
padding: 0.12rem 0.38rem !important;
|
||
font-size: 0.68rem !important;
|
||
border-radius: 0.18rem !important;
|
||
line-height: 1 !important;
|
||
}
|
||
|
||
#layout-review-toolbar label {
|
||
min-height: 1.45rem !important;
|
||
font-size: 0.68rem !important;
|
||
gap: 0.18rem !important;
|
||
padding: 0 !important;
|
||
}
|
||
|
||
#layout-review-toolbar input[type="checkbox"] {
|
||
width: 0.82rem !important;
|
||
height: 0.82rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar input[type="color"] {
|
||
width: 1.25rem !important;
|
||
height: 1.25rem !important;
|
||
padding: 0 !important;
|
||
}
|
||
|
||
#layout-review-toolbar .word-ribbon-select {
|
||
min-width: 4.6rem !important;
|
||
height: 1.45rem !important;
|
||
font-size: 0.68rem !important;
|
||
padding: 0 0.3rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar .word-ribbon-font-size {
|
||
height: 1.45rem !important;
|
||
font-size: 0.68rem !important;
|
||
padding: 0 0.32rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar .word-ribbon-blue-a {
|
||
font-size: 0.95rem !important;
|
||
}
|
||
|
||
#layout-zoom-label,
|
||
#layout-review-status {
|
||
font-size: 0.72rem !important;
|
||
height: 1.45rem !important;
|
||
display: inline-flex !important;
|
||
align-items: center !important;
|
||
}
|
||
|
||
|
||
/* Refine Word-like ribbon: denser, usable horizontal scroll */
|
||
#layout-review-toolbar.word-ribbon-toolbar {
|
||
min-height: 4.35rem !important;
|
||
max-height: 4.6rem !important;
|
||
padding: 0.18rem 0.25rem !important;
|
||
scrollbar-width: thin !important;
|
||
background: linear-gradient(#f8fafc, #eef3f8) !important;
|
||
}
|
||
|
||
#layout-review-toolbar.word-ribbon-toolbar::-webkit-scrollbar {
|
||
height: 0.35rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar.word-ribbon-toolbar::-webkit-scrollbar-thumb {
|
||
background: #cbd5e1 !important;
|
||
border-radius: 999px !important;
|
||
}
|
||
|
||
#layout-review-toolbar .word-ribbon-group {
|
||
height: 3.85rem !important;
|
||
min-height: 3.85rem !important;
|
||
padding: 0.2rem 0.42rem 0.08rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar .word-ribbon-row {
|
||
min-height: 1.35rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar button,
|
||
#layout-review-toolbar .layout-tool-btn {
|
||
height: 1.35rem !important;
|
||
min-height: 1.35rem !important;
|
||
padding: 0.08rem 0.34rem !important;
|
||
font-size: 0.66rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar label,
|
||
#layout-review-toolbar .word-ribbon-select,
|
||
#layout-review-toolbar .word-ribbon-font-size,
|
||
#layout-zoom-label,
|
||
#layout-review-status {
|
||
height: 1.35rem !important;
|
||
min-height: 1.35rem !important;
|
||
font-size: 0.66rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar input[type="color"] {
|
||
width: 1.18rem !important;
|
||
height: 1.18rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar input[type="checkbox"] {
|
||
width: 0.78rem !important;
|
||
height: 0.78rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar .word-ribbon-label {
|
||
height: 0.65rem !important;
|
||
line-height: 0.65rem !important;
|
||
font-size: 0.55rem !important;
|
||
}
|
||
|
||
#layout-review-toolbar .word-ribbon-select {
|
||
min-width: 4.25rem !important;
|
||
}
|
||
|
||
|
||
/* arrow group row */
|
||
#layout-word-popover > div:nth-child(2) > div:nth-of-type(2) {
|
||
grid-column: 1 / 2 !important;
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
gap: 0.28rem !important;
|
||
flex-wrap: wrap !important;
|
||
}
|
||
|
||
#layout-word-popover > div:nth-child(2) > div:nth-of-type(2) > div {
|
||
display: contents !important;
|
||
}
|
||
|
||
/* apply/delete row */
|
||
#layout-word-popover > div:nth-child(2) > div:nth-of-type(3) {
|
||
grid-column: 2 / 3 !important;
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
justify-content: flex-end !important;
|
||
gap: 0.28rem !important;
|
||
flex-wrap: wrap !important;
|
||
}
|
||
|
||
#layout-popover-apply {
|
||
background: linear-gradient(#dbeafe, #c9defc) !important;
|
||
border-color: #8cb4f4 !important;
|
||
color: #1e3a8a !important;
|
||
}
|
||
|
||
#layout-popover-delete {
|
||
background: linear-gradient(#fff1f2, #ffe4e6) !important;
|
||
border-color: #f3b0b8 !important;
|
||
color: #9f1239 !important;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
#layout-word-popover {
|
||
width: min(96vw, 30rem) !important;
|
||
}
|
||
|
||
#layout-word-popover .layout-mini-grid {
|
||
grid-template-columns: minmax(7rem, 1fr) 3.2rem auto auto auto 1.8rem !important;
|
||
}
|
||
|
||
#layout-popover-text,
|
||
#layout-popover-font-family,
|
||
#layout-popover-font-size {
|
||
font-size: 0.78rem !important;
|
||
}
|
||
|
||
#layout-popover-font-down,
|
||
#layout-popover-font-up,
|
||
#layout-popover-font-all,
|
||
#layout-popover-up,
|
||
#layout-popover-left,
|
||
#layout-popover-down,
|
||
#layout-popover-right,
|
||
#layout-popover-apply,
|
||
#layout-popover-delete {
|
||
font-size: 0.76rem !important;
|
||
padding: 0.12rem 0.34rem !important;
|
||
}
|
||
}
|
||
|
||
|
||
/* Word-like selected-word popup */
|
||
#layout-word-popover {
|
||
width: min(94vw, 38rem) !important;
|
||
padding: 0.32rem !important;
|
||
border: 1px solid #bfc8d4 !important;
|
||
border-radius: 0.12rem !important;
|
||
background: #ffffff !important;
|
||
box-shadow: 0 0.35rem 1.05rem rgba(15, 23, 42, 0.22) !important;
|
||
}
|
||
|
||
#layout-word-popover > div:first-child {
|
||
display: none !important;
|
||
}
|
||
|
||
#layout-word-popover > div:nth-child(2) {
|
||
display: grid !important;
|
||
grid-template-columns: auto auto auto auto auto auto auto auto auto !important;
|
||
grid-auto-rows: 1.9rem !important;
|
||
gap: 0.18rem !important;
|
||
align-items: center !important;
|
||
}
|
||
|
||
#layout-popover-text {
|
||
grid-column: 1 / 4 !important;
|
||
grid-row: 1 !important;
|
||
width: 11.5rem !important;
|
||
min-height: 1.85rem !important;
|
||
padding: 0.16rem 0.36rem !important;
|
||
border: 1px solid #bfc8d4 !important;
|
||
border-radius: 0 !important;
|
||
font-size: 0.86rem !important;
|
||
background: #ffffff !important;
|
||
}
|
||
|
||
#layout-word-popover .layout-mini-grid {
|
||
display: contents !important;
|
||
}
|
||
|
||
#layout-popover-font-family {
|
||
grid-column: 1 / 3 !important;
|
||
grid-row: 2 !important;
|
||
width: 9.5rem !important;
|
||
}
|
||
|
||
#layout-popover-font-size {
|
||
grid-column: 3 / 4 !important;
|
||
grid-row: 2 !important;
|
||
width: 3.6rem !important;
|
||
text-align: center !important;
|
||
}
|
||
|
||
#layout-popover-font-family,
|
||
#layout-popover-font-size {
|
||
height: 1.85rem !important;
|
||
padding: 0.12rem 0.34rem !important;
|
||
border: 1px solid #bfc8d4 !important;
|
||
border-radius: 0 !important;
|
||
background: #ffffff !important;
|
||
font-size: 0.84rem !important;
|
||
}
|
||
|
||
#layout-word-popover label[for="layout-popover-font-family"],
|
||
#layout-word-popover label[for="layout-popover-text-color"] {
|
||
display: none !important;
|
||
}
|
||
|
||
#layout-popover-font-down,
|
||
#layout-popover-font-up,
|
||
#layout-popover-font-all,
|
||
#layout-popover-up,
|
||
#layout-popover-left,
|
||
#layout-popover-down,
|
||
#layout-popover-right,
|
||
#layout-popover-apply,
|
||
#layout-popover-delete {
|
||
height: 1.85rem !important;
|
||
min-height: 1.85rem !important;
|
||
padding: 0.12rem 0.42rem !important;
|
||
border: 1px solid #bfc8d4 !important;
|
||
border-radius: 0 !important;
|
||
background: linear-gradient(#ffffff, #edf2f8) !important;
|
||
color: #111827 !important;
|
||
font-size: 0.82rem !important;
|
||
font-weight: 600 !important;
|
||
line-height: 1 !important;
|
||
}
|
||
|
||
#layout-popover-font-down {
|
||
grid-column: 4 !important;
|
||
grid-row: 1 !important;
|
||
}
|
||
|
||
#layout-popover-font-up {
|
||
grid-column: 5 !important;
|
||
grid-row: 1 !important;
|
||
}
|
||
|
||
#layout-popover-font-all {
|
||
grid-column: 6 !important;
|
||
grid-row: 1 !important;
|
||
}
|
||
|
||
#layout-popover-text-color {
|
||
grid-column: 7 !important;
|
||
grid-row: 1 !important;
|
||
width: 1.85rem !important;
|
||
height: 1.85rem !important;
|
||
padding: 0 !important;
|
||
border: 1px solid #94a3b8 !important;
|
||
border-radius: 0 !important;
|
||
background: #ffffff !important;
|
||
}
|
||
|
||
#layout-word-popover > div:nth-child(2) > div:nth-of-type(2) {
|
||
display: contents !important;
|
||
}
|
||
|
||
#layout-word-popover > div:nth-child(2) > div:nth-of-type(2) > div {
|
||
display: contents !important;
|
||
}
|
||
|
||
#layout-popover-up {
|
||
grid-column: 8 !important;
|
||
grid-row: 1 !important;
|
||
}
|
||
|
||
#layout-popover-left {
|
||
grid-column: 8 !important;
|
||
grid-row: 2 !important;
|
||
}
|
||
|
||
#layout-popover-down {
|
||
grid-column: 9 !important;
|
||
grid-row: 2 !important;
|
||
}
|
||
|
||
#layout-popover-right {
|
||
grid-column: 10 !important;
|
||
grid-row: 2 !important;
|
||
}
|
||
|
||
#layout-word-popover > div:nth-child(2) > div:nth-of-type(3) {
|
||
display: contents !important;
|
||
}
|
||
|
||
#layout-popover-apply {
|
||
grid-column: 4 / 6 !important;
|
||
grid-row: 2 !important;
|
||
background: linear-gradient(#ffffff, #edf2f8) !important;
|
||
}
|
||
|
||
#layout-popover-delete {
|
||
grid-column: 6 / 8 !important;
|
||
grid-row: 2 !important;
|
||
background: linear-gradient(#fff7f8, #ffe4e6) !important;
|
||
color: #9f1239 !important;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
#layout-word-popover {
|
||
width: min(96vw, 34rem) !important;
|
||
overflow-x: auto !important;
|
||
}
|
||
|
||
#layout-word-popover > div:nth-child(2) {
|
||
min-width: 32rem !important;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
/* Lock-size control in selected-word popup */
|
||
#layout-word-popover .layout-popover-lock-size-label {
|
||
height: 1.85rem !important;
|
||
padding: 0 0.35rem !important;
|
||
border: 1px solid #bfc8d4 !important;
|
||
background: #ffffff !important;
|
||
font-size: 0.78rem !important;
|
||
color: #334155 !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
|
||
#layout-popover-lock-size {
|
||
width: 0.9rem !important;
|
||
height: 0.9rem !important;
|
||
margin: 0 !important;
|
||
}
|
||
|
||
|
||
/* Move selected-word controls into ribbon */
|
||
#layout-ribbon-selection-group {
|
||
min-width: 42rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .word-ribbon-row {
|
||
gap: 0.25rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-inline-field {
|
||
display: inline-flex !important;
|
||
align-items: center !important;
|
||
gap: 0.2rem !important;
|
||
margin: 0 !important;
|
||
padding: 0 !important;
|
||
border: none !important;
|
||
min-height: 1.35rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-inline-field span {
|
||
font-size: 0.62rem !important;
|
||
color: #64748b !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group input,
|
||
#layout-ribbon-selection-group select {
|
||
height: 1.35rem !important;
|
||
min-height: 1.35rem !important;
|
||
padding: 0.08rem 0.3rem !important;
|
||
font-size: 0.66rem !important;
|
||
border: 1px solid #b8c6d8 !important;
|
||
border-radius: 0.18rem !important;
|
||
background: #fff !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-text-field input {
|
||
width: 7.5rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-font-field input {
|
||
width: 6.5rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-size-field input {
|
||
width: 3.2rem !important;
|
||
text-align: center !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-color-field input[type="color"] {
|
||
width: 1.35rem !important;
|
||
padding: 0 !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-small-select select {
|
||
width: 4.8rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-small-field input,
|
||
#layout-ribbon-selection-group .ribbon-xy-field input {
|
||
width: 3.6rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group button {
|
||
height: 1.35rem !important;
|
||
min-height: 1.35rem !important;
|
||
padding: 0.08rem 0.32rem !important;
|
||
font-size: 0.64rem !important;
|
||
}
|
||
|
||
#layout-props-card {
|
||
display: none !important;
|
||
}
|
||
|
||
|
||
/* Better fit selected-word ribbon controls */
|
||
#layout-review-toolbar.word-ribbon-toolbar {
|
||
overflow-x: auto !important;
|
||
overflow-y: hidden !important;
|
||
max-width: 100% !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group {
|
||
min-width: 36rem !important;
|
||
max-width: none !important;
|
||
flex: 0 0 auto !important;
|
||
padding-left: 0.4rem !important;
|
||
padding-right: 0.4rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .word-ribbon-row {
|
||
display: flex !important;
|
||
flex-wrap: nowrap !important;
|
||
align-items: center !important;
|
||
gap: 0.18rem !important;
|
||
overflow: hidden !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-inline-field {
|
||
display: inline-flex !important;
|
||
align-items: center !important;
|
||
gap: 0.12rem !important;
|
||
min-width: 0 !important;
|
||
white-space: nowrap !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-inline-field span {
|
||
font-size: 0.58rem !important;
|
||
max-width: 3.4rem !important;
|
||
overflow: hidden !important;
|
||
text-overflow: ellipsis !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group input,
|
||
#layout-ribbon-selection-group select,
|
||
#layout-ribbon-selection-group button {
|
||
height: 1.25rem !important;
|
||
min-height: 1.25rem !important;
|
||
font-size: 0.6rem !important;
|
||
padding: 0.05rem 0.22rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-text-field input {
|
||
width: 7rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-font-field input {
|
||
width: 5.8rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-size-field input {
|
||
width: 2.6rem !important;
|
||
text-align: center !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-color-field input[type="color"] {
|
||
width: 1.25rem !important;
|
||
min-width: 1.25rem !important;
|
||
padding: 0 !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-small-select select {
|
||
width: 4.2rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-small-field input {
|
||
width: 3rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-xy-field {
|
||
display: none !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group #layout-apply-style-line,
|
||
#layout-ribbon-selection-group #layout-apply-style-page,
|
||
#layout-ribbon-selection-group #layout-reset-style-word {
|
||
display: none !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group #layout-delete-word-inline {
|
||
color: #9f1239 !important;
|
||
background: #ffe4e6 !important;
|
||
border-color: #fda4af !important;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
#layout-ribbon-selection-group {
|
||
min-width: 31rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-text-field input {
|
||
width: 6rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-font-field input {
|
||
width: 5rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-inline-field span {
|
||
display: none !important;
|
||
}
|
||
}
|
||
|
||
|
||
/* Selection ribbon polish with compact position controls */
|
||
#layout-ribbon-selection-group {
|
||
min-width: 38rem !important;
|
||
padding-left: 0.25rem !important;
|
||
padding-right: 0.25rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .word-ribbon-row {
|
||
gap: 0.12rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-inline-field {
|
||
gap: 0.08rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-inline-field span {
|
||
display: inline-flex !important;
|
||
font-size: 0.54rem !important;
|
||
max-width: 2.4rem !important;
|
||
color: #64748b !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group input,
|
||
#layout-ribbon-selection-group select,
|
||
#layout-ribbon-selection-group button {
|
||
height: 1.18rem !important;
|
||
min-height: 1.18rem !important;
|
||
font-size: 0.58rem !important;
|
||
padding: 0.03rem 0.18rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-text-field input {
|
||
width: 6.2rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-font-field input {
|
||
width: 4.8rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-size-field input {
|
||
width: 2.25rem !important;
|
||
text-align: center !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-color-field input[type="color"] {
|
||
width: 1.12rem !important;
|
||
min-width: 1.12rem !important;
|
||
padding: 0 !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-small-select select {
|
||
width: 3.55rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-small-field input {
|
||
width: 2.55rem !important;
|
||
}
|
||
|
||
/* Restore compact x/y position controls */
|
||
#layout-ribbon-selection-group .ribbon-xy-field {
|
||
display: inline-flex !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-xy-field span {
|
||
display: inline-flex !important;
|
||
max-width: 1rem !important;
|
||
font-size: 0.52rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-xy-field input {
|
||
width: 2.45rem !important;
|
||
text-align: right !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group #layout-apply-style-word {
|
||
min-width: 3.2rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group #layout-apply-word {
|
||
min-width: 2.5rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group #layout-delete-word-inline {
|
||
min-width: 4.3rem !important;
|
||
color: #9f1239 !important;
|
||
background: #ffe4e6 !important;
|
||
border-color: #fda4af !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .word-ribbon-label {
|
||
margin-top: 0.02rem !important;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
#layout-ribbon-selection-group {
|
||
min-width: 37rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-inline-field span {
|
||
display: inline-flex !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-text-field input {
|
||
width: 5.6rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-font-field input {
|
||
width: 4.4rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-xy-field input {
|
||
width: 2.25rem !important;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
/* Real paired font select */
|
||
.layout-font-family-select {
|
||
height: 1.18rem !important;
|
||
min-height: 1.18rem !important;
|
||
width: 1.35rem !important;
|
||
padding: 0 !important;
|
||
margin-left: 0.08rem !important;
|
||
font-size: 0.58rem !important;
|
||
border: 1px solid #b8c6d8 !important;
|
||
border-radius: 0.18rem !important;
|
||
background: #ffffff !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
|
||
#layout-popover-font-family-select {
|
||
width: 1.8rem !important;
|
||
height: 1.9rem !important;
|
||
min-height: 1.9rem !important;
|
||
grid-column: 1 / 2 !important;
|
||
grid-row: 2 !important;
|
||
justify-self: end !important;
|
||
z-index: 2 !important;
|
||
}
|
||
|
||
#layout-popover-font-family {
|
||
padding-right: 2rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-font-field {
|
||
min-width: 6.5rem !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group .ribbon-font-field input {
|
||
width: 4.6rem !important;
|
||
}
|
||
|
||
|
||
|
||
/* Popup: anchored normally on desktop, bottom bar only on mobile */
|
||
#layout-word-popover {
|
||
box-sizing: border-box !important;
|
||
z-index: 9998 !important;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
#layout-word-popover {
|
||
position: fixed !important;
|
||
left: 4.35rem !important;
|
||
right: 0.35rem !important;
|
||
bottom: 0.45rem !important;
|
||
top: auto !important;
|
||
width: auto !important;
|
||
max-width: none !important;
|
||
transform: none !important;
|
||
overflow: visible !important;
|
||
}
|
||
}
|
||
|
||
/* Keep ribbon from loading half-scrolled / visually clipped */
|
||
#layout-review-toolbar.word-ribbon-toolbar {
|
||
scroll-behavior: auto !important;
|
||
}
|
||
|
||
#layout-ribbon-selection-group {
|
||
max-width: none !important;
|
||
}
|
||
|
||
|
||
/* Mobile popup final cleanup */
|
||
#layout-review-debug {
|
||
display: none !important;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
#layout-word-popover {
|
||
position: fixed !important;
|
||
left: 4.25rem !important;
|
||
right: 0.35rem !important;
|
||
bottom: calc(env(safe-area-inset-bottom, 0px) + 0.6rem) !important;
|
||
top: auto !important;
|
||
width: auto !important;
|
||
max-width: none !important;
|
||
max-height: 9.5rem !important;
|
||
overflow: hidden !important;
|
||
padding: 0.35rem !important;
|
||
box-sizing: border-box !important;
|
||
z-index: 9998 !important;
|
||
}
|
||
|
||
#layout-word-popover > div:nth-child(2) {
|
||
display: grid !important;
|
||
grid-template-columns: minmax(0, 1fr) 3.4rem 3.4rem 3.4rem !important;
|
||
grid-auto-rows: 1.9rem !important;
|
||
gap: 0.25rem !important;
|
||
min-width: 0 !important;
|
||
max-width: 100% !important;
|
||
align-items: center !important;
|
||
}
|
||
|
||
#layout-popover-text {
|
||
grid-column: 1 / 5 !important;
|
||
grid-row: 1 !important;
|
||
width: 100% !important;
|
||
}
|
||
|
||
#layout-popover-font-family {
|
||
grid-column: 1 / 2 !important;
|
||
grid-row: 2 !important;
|
||
width: 100% !important;
|
||
min-width: 0 !important;
|
||
padding-right: 0.35rem !important;
|
||
}
|
||
|
||
#layout-popover-font-family-select {
|
||
grid-column: 2 / 3 !important;
|
||
grid-row: 2 !important;
|
||
width: 100% !important;
|
||
height: 1.9rem !important;
|
||
min-height: 1.9rem !important;
|
||
position: static !important;
|
||
justify-self: stretch !important;
|
||
}
|
||
|
||
#layout-popover-font-size {
|
||
grid-column: 3 / 4 !important;
|
||
grid-row: 2 !important;
|
||
width: 100% !important;
|
||
text-align: center !important;
|
||
}
|
||
|
||
#layout-popover-text-color {
|
||
grid-column: 4 / 5 !important;
|
||
grid-row: 2 !important;
|
||
width: 100% !important;
|
||
height: 1.9rem !important;
|
||
position: static !important;
|
||
justify-self: stretch !important;
|
||
}
|
||
|
||
#layout-popover-font-down {
|
||
grid-column: 1 / 2 !important;
|
||
grid-row: 3 !important;
|
||
}
|
||
|
||
#layout-popover-font-up {
|
||
grid-column: 2 / 3 !important;
|
||
grid-row: 3 !important;
|
||
}
|
||
|
||
#layout-popover-font-all {
|
||
grid-column: 3 / 4 !important;
|
||
grid-row: 3 !important;
|
||
}
|
||
|
||
#layout-popover-apply {
|
||
grid-column: 1 / 3 !important;
|
||
grid-row: 4 !important;
|
||
}
|
||
|
||
#layout-popover-delete {
|
||
grid-column: 3 / 5 !important;
|
||
grid-row: 4 !important;
|
||
}
|
||
|
||
.layout-popover-lock-size-label {
|
||
grid-column: 1 / 5 !important;
|
||
grid-row: 5 !important;
|
||
height: 1.5rem !important;
|
||
min-height: 1.5rem !important;
|
||
padding: 0.15rem 0.3rem !important;
|
||
}
|
||
|
||
#layout-popover-up,
|
||
#layout-popover-left,
|
||
#layout-popover-down,
|
||
#layout-popover-right {
|
||
display: none !important;
|
||
}
|
||
|
||
#layout-word-popover input,
|
||
#layout-word-popover select,
|
||
#layout-word-popover button {
|
||
min-width: 0 !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
}
|
||
|
||
</style>
|
||
|
||
<div id="layout-review-toolbar" class="word-ribbon-toolbar">
|
||
<div class="word-ribbon-group">
|
||
<div class="word-ribbon-row layout-tool-row">
|
||
<button type="button" class="layout-tool-btn active" id="layout-tool-select">Select</button>
|
||
<button type="button" class="layout-tool-btn" id="layout-select-all">Select All</button>
|
||
<button type="button" class="layout-tool-btn" id="layout-multi-select">Multi</button>
|
||
<button type="button" class="layout-tool-btn" id="layout-clear-selection">Clear</button>
|
||
<button type="button" class="layout-tool-btn" id="layout-invert-selection">Invert</button>
|
||
</div>
|
||
<div class="word-ribbon-row layout-tool-row">
|
||
<button type="button" class="layout-tool-btn" id="layout-select-line">Line</button>
|
||
<button type="button" class="layout-tool-btn" id="layout-tool-pan">Pan</button>
|
||
<button type="button" class="layout-tool-btn" id="layout-tool-add">Add</button>
|
||
<button type="button" class="layout-tool-btn danger" id="layout-delete-word">Delete</button>
|
||
</div>
|
||
<div class="word-ribbon-label">Tools</div>
|
||
</div>
|
||
|
||
<div class="word-ribbon-group">
|
||
<div class="word-ribbon-row">
|
||
<button type="button" class="layout-tool-btn" id="layout-undo">Undo</button>
|
||
<button type="button" class="layout-tool-btn" id="layout-redo">Redo</button>
|
||
<form method="post" action="/documents/{{ document.document_id }}/reset-layout-review" style="display:inline;" onsubmit="return confirm('Reset Layout Review from raw OCR? This will discard current layout-review edits.');">
|
||
<button type="submit" class="layout-tool-btn danger">Reset Layout</button>
|
||
</form>
|
||
</div>
|
||
<div class="word-ribbon-label">Edit</div>
|
||
</div>
|
||
|
||
<div class="word-ribbon-group">
|
||
<div class="word-ribbon-row">
|
||
<button type="button" class="layout-tool-btn" id="layout-zoom-out">−</button>
|
||
<button type="button" class="layout-tool-btn" id="layout-fit-width">Fit</button>
|
||
<button type="button" class="layout-tool-btn" id="layout-zoom-in">+</button>
|
||
<span id="layout-zoom-label">100%</span>
|
||
</div>
|
||
<div class="word-ribbon-label">Zoom</div>
|
||
</div>
|
||
|
||
<div class="word-ribbon-group word-ribbon-font-group">
|
||
<div class="word-ribbon-row">
|
||
<span class="word-ribbon-select">OCR Text</span>
|
||
<span class="word-ribbon-font-size">11</span>
|
||
<label class="word-ribbon-color-control">
|
||
<span class="word-ribbon-blue-a">A</span>
|
||
<input type="color" id="layout-toolbar-text-color" value="#111827">
|
||
</label>
|
||
</div>
|
||
<div class="word-ribbon-row">
|
||
<label><input type="checkbox" id="layout-show-text"> Text</label>
|
||
<label><input type="checkbox" id="layout-show-baselines"> Baselines</label>
|
||
</div>
|
||
<div class="word-ribbon-label">Font</div>
|
||
</div>
|
||
|
||
<div class="word-ribbon-group">
|
||
<div class="word-ribbon-row">
|
||
<label><input type="checkbox" id="layout-show-scan" checked> Scan</label>
|
||
<label><input type="checkbox" id="layout-show-boxes" checked> Boxes</label>
|
||
<label><input type="checkbox" id="layout-show-guides" checked> Guides</label>
|
||
<label><input type="checkbox" id="layout-show-snap" checked> Snap</label>
|
||
</div>
|
||
<div class="word-ribbon-row">
|
||
<label class="word-ribbon-color-control">Box <input type="color" id="layout-markup-color" value="#dc2626"></label>
|
||
<label class="word-ribbon-color-control">Selected <input type="color" id="layout-selected-color" value="#2563eb"></label>
|
||
<label class="word-ribbon-color-control">Baseline <input type="color" id="layout-baseline-color" value="#10b981"></label>
|
||
</div>
|
||
<div class="word-ribbon-label">Display</div>
|
||
</div>
|
||
|
||
|
||
<div class="word-ribbon-group word-ribbon-selection-group" id="layout-ribbon-selection-group">
|
||
<div class="word-ribbon-row" id="layout-ribbon-selection-row-1"></div>
|
||
<div class="word-ribbon-row" id="layout-ribbon-selection-row-2"></div>
|
||
<div class="word-ribbon-label">Selection</div>
|
||
</div>
|
||
<div class="word-ribbon-group">
|
||
<div class="word-ribbon-row">
|
||
<button type="submit" form="layout-review-save-form" class="layout-tool-btn primary" onclick="window.prepareLayoutReviewSubmit && window.prepareLayoutReviewSubmit()">Save</button>
|
||
<span id="layout-review-status">Ready</span>
|
||
</div>
|
||
<div class="word-ribbon-label">Document</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{% set vision_fields = vision_candidate_fields or [] %}
|
||
{% set vision_suggestions = vision_field_suggestions or [] %}
|
||
|
||
{% if vision_suggestions %}
|
||
<div class="detail-card" style="margin:0.75rem 0; border-left:4px solid #16a34a;">
|
||
<div style="display:flex; justify-content:space-between; gap:1rem; align-items:center;">
|
||
<h3 style="margin:0;">Vision Suggested Additions</h3>
|
||
<span style="font-size:0.85rem; color:#64748b;">{{ vision_suggestions|length }} suggestions</span>
|
||
</div>
|
||
|
||
<div style="display:grid; gap:0.5rem; margin-top:0.75rem;">
|
||
{% for suggestion in vision_suggestions[:8] %}
|
||
<div style="padding:0.65rem 0.75rem; border:1px solid #dbe3ea; border-radius:0.75rem; background:#f8fafc;">
|
||
<div style="font-size:0.78rem; color:#64748b; font-weight:700; text-transform:uppercase;">
|
||
{{ suggestion.get("target_field", "") }} · {{ suggestion.get("action", "") }}
|
||
</div>
|
||
<div style="font-size:1rem; font-weight:700; margin-top:0.2rem; color:#1e293b;">
|
||
{{ suggestion.get("value", "") }}
|
||
</div>
|
||
<div style="font-size:0.78rem; color:#64748b; margin-top:0.2rem;">
|
||
confidence {{ suggestion.get("confidence", "") }}
|
||
{% if suggestion.get("ocr_confidence") is not none %}
|
||
· OCR {{ suggestion.get("ocr_confidence", "") }}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if vision_fields %}
|
||
<details class="detail-card" style="margin:0.75rem 0; border-left:4px solid #7c3aed;">
|
||
<summary style="cursor:pointer; font-weight:700; font-size:1.05rem;">
|
||
Raw Vision Candidate Fields · {{ vision_fields|length }} candidates
|
||
</summary>
|
||
<div style="margin-top:0.5rem; overflow-x:auto;">
|
||
<table class="data-table" style="width:100%;">
|
||
<thead>
|
||
<tr>
|
||
<th>Type</th>
|
||
<th>Value</th>
|
||
<th>Confidence</th>
|
||
<th>OCR Conf.</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for field in vision_fields[:20] %}
|
||
<tr>
|
||
<td>{{ field.get("candidate_type", "") }}</td>
|
||
<td><code>{{ field.get("value", "") }}</code></td>
|
||
<td>{{ field.get("confidence", "") }}</td>
|
||
<td>{{ field.get("ocr_confidence", "") }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
{% endif %}
|
||
|
||
<div id="layout-review-shell">
|
||
<div>
|
||
<div id="layout-review-canvas-wrap" oncontextmenu="return false;">
|
||
<div id="layout-review-stage">
|
||
<img
|
||
id="layout-review-image"
|
||
src="{{ layout_review_image_url }}"
|
||
alt="Layout review page"
|
||
draggable="false"
|
||
>
|
||
<canvas id="layout-review-canvas"></canvas>
|
||
<div id="layout-review-debug">boot</div>
|
||
|
||
<div id="layout-word-popover">
|
||
<div style="font-size:0.78rem; font-weight:700; color:#475569; margin-bottom:0.45rem;">Selected word</div>
|
||
<div style="display:grid; gap:0.45rem;">
|
||
<input id="layout-popover-text" type="text" style="width:100%;" placeholder="Word text">
|
||
<div class="layout-mini-grid">
|
||
<input id="layout-popover-font-size" type="number" step="0.1" min="6" style="width:100%;" placeholder="Font size">
|
||
|
||
<label for="layout-popover-font-family">Font</label>
|
||
<input id="layout-popover-font-family" type="text" placeholder="Helvetica"><select id="layout-popover-font-family-select" class="layout-font-family-select"><option value="">▼</option>
|
||
<option value="Helvetica">Helvetica</option>
|
||
<option value="Arial">Arial</option>
|
||
<option value="Calibri">Calibri</option>
|
||
<option value="Cambria">Cambria</option>
|
||
<option value="Times New Roman">Times New Roman</option>
|
||
<option value="Courier New">Courier New</option>
|
||
<option value="Georgia">Georgia</option>
|
||
<option value="Verdana">Verdana</option>
|
||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||
<option value="DejaVu Sans">DejaVu Sans</option>
|
||
<option value="DejaVu Serif">DejaVu Serif</option>
|
||
<option value="Liberation Sans">Liberation Sans</option>
|
||
<option value="Liberation Serif">Liberation Serif</option></select>
|
||
|
||
<label for="layout-popover-text-color">Text color</label>
|
||
<input id="layout-popover-text-color" type="color" value="#000000">
|
||
<label class="layout-popover-lock-size-label" style="display:inline-flex; align-items:center; gap:0.25rem; white-space:nowrap;">
|
||
<input id="layout-popover-lock-size" type="checkbox">
|
||
Lock size
|
||
</label>
|
||
<button type="button" id="layout-popover-font-down">A-</button>
|
||
<button type="button" id="layout-popover-font-up">A+</button>
|
||
<button type="button" id="layout-popover-font-all">All</button>
|
||
</div>
|
||
<div style="display:grid; gap:0.35rem;">
|
||
<div style="display:flex; justify-content:center;">
|
||
<button type="button" id="layout-popover-up">↑</button>
|
||
</div>
|
||
<div style="display:flex; justify-content:center; gap:0.45rem;">
|
||
<button type="button" id="layout-popover-left">←</button>
|
||
<button type="button" id="layout-popover-down">↓</button>
|
||
<button type="button" id="layout-popover-right">→</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:0.45rem; flex-wrap:wrap;">
|
||
<button type="button" id="layout-popover-apply">Apply</button>
|
||
<button type="button" id="layout-popover-delete">Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="layout-props-card">
|
||
<h3 style="margin-top:0;">Selected word</h3>
|
||
<div style="display:grid; gap:0.65rem;">
|
||
<div>
|
||
<label for="layout-word-id">Word ID</label>
|
||
<input id="layout-word-id" type="text" readonly style="width:100%;">
|
||
</div>
|
||
<div>
|
||
<label for="layout-word-text">Text</label>
|
||
<input id="layout-word-text" type="text" style="width:100%;">
|
||
</div>
|
||
<div>
|
||
<label for="layout-word-font-size">Font size</label>
|
||
<div class="layout-mini-grid">
|
||
<input id="layout-word-font-size" type="number" step="0.1" min="6" style="width:100%;">
|
||
<button type="button" id="layout-font-down">A-</button>
|
||
<button type="button" id="layout-font-up">A+</button>
|
||
<button type="button" id="layout-font-all">All</button>
|
||
</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"><select id="layout-word-font-family-select" class="layout-font-family-select"><option value="">▼</option>
|
||
<option value="Helvetica">Helvetica</option>
|
||
<option value="Arial">Arial</option>
|
||
<option value="Calibri">Calibri</option>
|
||
<option value="Cambria">Cambria</option>
|
||
<option value="Times New Roman">Times New Roman</option>
|
||
<option value="Courier New">Courier New</option>
|
||
<option value="Georgia">Georgia</option>
|
||
<option value="Verdana">Verdana</option>
|
||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||
<option value="DejaVu Sans">DejaVu Sans</option>
|
||
<option value="DejaVu Serif">DejaVu Serif</option>
|
||
<option value="Liberation Sans">Liberation Sans</option>
|
||
<option value="Liberation Serif">Liberation Serif</option></select>
|
||
</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>
|
||
<input id="layout-x1" type="number" step="0.1" style="width:100%;">
|
||
</div>
|
||
<div>
|
||
<label for="layout-y1">y1</label>
|
||
<input id="layout-y1" type="number" step="0.1" style="width:100%;">
|
||
</div>
|
||
<div>
|
||
<label for="layout-x2">x2</label>
|
||
<input id="layout-x2" type="number" step="0.1" style="width:100%;">
|
||
</div>
|
||
<div>
|
||
<label for="layout-y2">y2</label>
|
||
<input id="layout-y2" type="number" step="0.1" style="width:100%;">
|
||
</div>
|
||
</div>
|
||
|
||
<div id="layout-props-actions">
|
||
<button type="button" id="layout-apply-word">Apply</button>
|
||
<button type="button" id="layout-nudge-left">←</button>
|
||
<button type="button" id="layout-nudge-right">→</button>
|
||
<button type="button" id="layout-nudge-up">↑</button>
|
||
<button type="button" id="layout-nudge-down">↓</button>
|
||
<button type="button" id="layout-delete-word-inline">Delete Selected</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-layout-review" id="layout-review-save-form" style="margin-top:0.75rem;">
|
||
<input type="hidden" name="layout_review_json" id="layout-review-json">
|
||
</form>
|
||
|
||
|
||
<style id="layout-mobile-popover-fit-fix">
|
||
@media (max-width: 760px) {
|
||
#layout-word-popover {
|
||
position: fixed !important;
|
||
left: 4.4rem !important;
|
||
right: 0.45rem !important;
|
||
bottom: 0.45rem !important;
|
||
top: auto !important;
|
||
width: auto !important;
|
||
max-width: none !important;
|
||
max-height: 42vh !important;
|
||
overflow-y: auto !important;
|
||
overflow-x: hidden !important;
|
||
z-index: 99999 !important;
|
||
box-sizing: border-box !important;
|
||
padding: 0.55rem !important;
|
||
pointer-events: auto !important;
|
||
touch-action: manipulation !important;
|
||
}
|
||
|
||
#layout-word-popover * {
|
||
pointer-events: auto !important;
|
||
}
|
||
|
||
#layout-word-popover input,
|
||
#layout-word-popover button,
|
||
#layout-word-popover select,
|
||
#layout-word-popover label {
|
||
touch-action: manipulation !important;
|
||
}
|
||
|
||
#layout-word-popover > div:nth-child(2) {
|
||
display: grid !important;
|
||
grid-template-columns: minmax(0, 1fr) 5.5rem 4rem 4rem !important;
|
||
gap: 0.45rem !important;
|
||
min-width: 0 !important;
|
||
max-width: 100% !important;
|
||
}
|
||
|
||
#layout-popover-text {
|
||
grid-column: 1 / -1 !important;
|
||
}
|
||
|
||
#layout-popover-font-family {
|
||
grid-column: 1 / 2 !important;
|
||
min-width: 0 !important;
|
||
}
|
||
|
||
#layout-popover-font-size {
|
||
grid-column: 2 / 3 !important;
|
||
}
|
||
|
||
#layout-popover-text-color {
|
||
grid-column: 3 / 4 !important;
|
||
width: 100% !important;
|
||
height: 2.65rem !important;
|
||
}
|
||
|
||
#layout-popover-apply {
|
||
grid-column: 1 / 3 !important;
|
||
}
|
||
|
||
#layout-popover-delete {
|
||
grid-column: 3 / -1 !important;
|
||
}
|
||
|
||
#layout-popover-font-down,
|
||
#layout-popover-font-up,
|
||
#layout-popover-font-all {
|
||
min-height: 2.65rem !important;
|
||
}
|
||
|
||
#layout-lock-size-row,
|
||
#layout-popover-lock-size-row,
|
||
#layout-popover-lock-size,
|
||
label[for="layout-popover-lock-size"] {
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
gap: 0.45rem !important;
|
||
grid-column: 1 / -1 !important;
|
||
min-height: 2.4rem !important;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
|
||
<script id="layout-review-data" type="application/json">{{ layout_review_pages|tojson }}</script>
|
||
<script>
|
||
(function () {
|
||
const panel = document.querySelector('.tab-panel[data-panel="layout-review"]');
|
||
const dataTag = document.getElementById("layout-review-data");
|
||
const wrap = document.getElementById("layout-review-canvas-wrap");
|
||
const stage = document.getElementById("layout-review-stage");
|
||
const canvas = document.getElementById("layout-review-canvas");
|
||
const image = document.getElementById("layout-review-image");
|
||
const debugEl = document.getElementById("layout-review-debug");
|
||
const reviewedTextarea = document.getElementById("reviewed_text");
|
||
const actualLinesEl = document.getElementById("actual-lines");
|
||
const saveForm = document.getElementById("layout-review-save-form");
|
||
const saveJsonInput = document.getElementById("layout-review-json");
|
||
const statusEl = document.getElementById("layout-review-status");
|
||
const zoomLabel = document.getElementById("layout-zoom-label");
|
||
const showScanInput = document.getElementById("layout-show-scan");
|
||
const showBoxesInput = document.getElementById("layout-show-boxes");
|
||
const showTextInput = document.getElementById("layout-show-text");
|
||
const showBaselinesInput = document.getElementById("layout-show-baselines");
|
||
const showGuidesInput = document.getElementById("layout-show-guides");
|
||
const showSnapInput = document.getElementById("layout-show-snap");
|
||
const markupColorInput = document.getElementById("layout-markup-color");
|
||
const selectedColorInput = document.getElementById("layout-selected-color");
|
||
const baselineColorInput = document.getElementById("layout-baseline-color");
|
||
|
||
if (!panel || !dataTag || !wrap || !stage || !canvas || !image) return;
|
||
|
||
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 toolbarTextColorInput = document.getElementById("layout-toolbar-text-color");
|
||
const x1Input = document.getElementById("layout-x1");
|
||
const y1Input = document.getElementById("layout-y1");
|
||
const x2Input = document.getElementById("layout-x2");
|
||
const y2Input = document.getElementById("layout-y2");
|
||
|
||
const popoverEl = document.getElementById("layout-word-popover");
|
||
const popoverTextInput = document.getElementById("layout-popover-text");
|
||
const popoverFontSizeInput = document.getElementById("layout-popover-font-size");
|
||
const popoverFontFamilyInput = document.getElementById("layout-popover-font-family");
|
||
const popoverTextColorInput = document.getElementById("layout-popover-text-color");
|
||
const popoverLockSizeInput = document.getElementById("layout-popover-lock-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");
|
||
|
||
function moveSelectionControlsToRibbon() {
|
||
const row1 = document.getElementById("layout-ribbon-selection-row-1");
|
||
const row2 = document.getElementById("layout-ribbon-selection-row-2");
|
||
const propsCard = document.getElementById("layout-props-card");
|
||
if (!row1 || !row2 || !propsCard) return;
|
||
|
||
function moveInputWithLabel(inputId, targetRow, compactClass = "") {
|
||
const input = document.getElementById(inputId);
|
||
if (!input) return;
|
||
|
||
const label = propsCard.querySelector(`label[for="${inputId}"]`);
|
||
const wrap = document.createElement("label");
|
||
wrap.className = "ribbon-inline-field " + compactClass;
|
||
|
||
if (label) {
|
||
const labelText = document.createElement("span");
|
||
labelText.textContent = label.textContent || "";
|
||
wrap.appendChild(labelText);
|
||
label.style.display = "none";
|
||
}
|
||
|
||
wrap.appendChild(input);
|
||
|
||
const companionSelect = document.getElementById(inputId + "-select");
|
||
if (companionSelect) {
|
||
wrap.appendChild(companionSelect);
|
||
}
|
||
targetRow.appendChild(wrap);
|
||
}
|
||
|
||
function moveEl(id, targetRow) {
|
||
const el = document.getElementById(id);
|
||
if (el) targetRow.appendChild(el);
|
||
}
|
||
|
||
moveInputWithLabel("layout-word-text", row1, "ribbon-text-field");
|
||
moveInputWithLabel("layout-word-font-family", row1, "ribbon-font-field");
|
||
moveInputWithLabel("layout-word-font-size", row1, "ribbon-size-field");
|
||
moveInputWithLabel("layout-word-text-color", row1, "ribbon-color-field");
|
||
|
||
moveEl("layout-font-down", row1);
|
||
moveEl("layout-font-up", row1);
|
||
moveEl("layout-font-all", row1);
|
||
|
||
moveInputWithLabel("layout-word-font-weight", row2, "ribbon-small-select");
|
||
moveInputWithLabel("layout-word-font-style", row2, "ribbon-small-select");
|
||
moveInputWithLabel("layout-word-letter-spacing", row2, "ribbon-small-field");
|
||
|
||
moveEl("layout-apply-style-word", row2);
|
||
moveEl("layout-apply-style-line", row2);
|
||
moveEl("layout-apply-style-page", row2);
|
||
moveEl("layout-reset-style-word", row2);
|
||
|
||
moveInputWithLabel("layout-x1", row2, "ribbon-xy-field");
|
||
moveInputWithLabel("layout-y1", row2, "ribbon-xy-field");
|
||
moveInputWithLabel("layout-x2", row2, "ribbon-xy-field");
|
||
moveInputWithLabel("layout-y2", row2, "ribbon-xy-field");
|
||
|
||
moveEl("layout-apply-word", row2);
|
||
moveEl("layout-delete-word-inline", row2);
|
||
|
||
propsCard.style.display = "none";
|
||
}
|
||
|
||
moveSelectionControlsToRibbon();
|
||
|
||
function installRealFontFamilySelects() {
|
||
const pairs = [
|
||
["layout-word-font-family", "layout-word-font-family-select"],
|
||
["layout-popover-font-family", "layout-popover-font-family-select"],
|
||
];
|
||
|
||
function setFont(font) {
|
||
if (!font) return;
|
||
|
||
for (const [inputId, selectId] of pairs) {
|
||
const input = document.getElementById(inputId);
|
||
const select = document.getElementById(selectId);
|
||
|
||
if (input) {
|
||
input.value = font;
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||
}
|
||
|
||
if (select) select.value = font;
|
||
}
|
||
|
||
const w = getSelectedWord();
|
||
if (w) {
|
||
applyStyleToWord(w, { font_family: font });
|
||
refreshSelectionUI({ geometryOnly: true });
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
}
|
||
}
|
||
|
||
for (const [inputId, selectId] of pairs) {
|
||
const input = document.getElementById(inputId);
|
||
const select = document.getElementById(selectId);
|
||
if (!input || !select) continue;
|
||
|
||
select.addEventListener("change", () => {
|
||
if (select.value) setFont(select.value);
|
||
});
|
||
|
||
input.addEventListener("change", () => {
|
||
const typed = input.value || "";
|
||
const matched = Array.from(select.options).some(opt => opt.value === typed);
|
||
select.value = matched ? typed : "";
|
||
});
|
||
}
|
||
}
|
||
|
||
installRealFontFamilySelects();
|
||
|
||
|
||
const HANDLE_SIZE_PX = 14;
|
||
const HANDLE_HIT_PX = 12;
|
||
const SNAP_THRESHOLD_PX = 10;
|
||
const LINE_GROUP_TOLERANCE = 12;
|
||
const MIN_ZOOM = 0.2;
|
||
const MAX_ZOOM = 4;
|
||
|
||
function debug(msg) {
|
||
if (debugEl) debugEl.textContent = msg;
|
||
try { console.log("[layout-review]", msg); } catch (e) {}
|
||
}
|
||
|
||
function setStatus(msg) {
|
||
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 || "[]");
|
||
} catch (e) {
|
||
debug("json-error");
|
||
return;
|
||
}
|
||
if (!Array.isArray(pages) || !pages.length) {
|
||
debug("no-pages");
|
||
return;
|
||
}
|
||
|
||
const page = pages[0];
|
||
let words = JSON.parse(JSON.stringify(page.words || []));
|
||
let selectedId = null;
|
||
let selectedIds = new Set();
|
||
let multiSelectMode = false;
|
||
|
||
function getSelectedWords() {
|
||
if (selectedIds && selectedIds.size) return words.filter(w => selectedIds.has(String(w.id)));
|
||
const w = getSelectedWord();
|
||
return w ? [w] : [];
|
||
}
|
||
|
||
let touchMultiAnchor = false;
|
||
let touchMultiTimer = null;
|
||
const activePointers = new Set();
|
||
let tool = "select";
|
||
let zoom = 1;
|
||
let dragState = null;
|
||
let nextWordId = Math.max(0, ...words.map(w => Number(w.id || 0))) + 1;
|
||
let history = [];
|
||
let historyIndex = -1;
|
||
let snapGuides = null;
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) {
|
||
debug("no-ctx");
|
||
return;
|
||
}
|
||
|
||
function normalizeBBox(bbox) {
|
||
const x1 = Number((bbox || [0,0,0,0])[0] || 0);
|
||
const y1 = Number((bbox || [0,0,0,0])[1] || 0);
|
||
const x2 = Number((bbox || [0,0,0,0])[2] || 0);
|
||
const y2 = Number((bbox || [0,0,0,0])[3] || 0);
|
||
return [Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2)];
|
||
}
|
||
|
||
function bboxMetrics(bbox) {
|
||
const [x1, y1, x2, y2] = normalizeBBox(bbox);
|
||
return { x1, y1, x2, y2, cx:(x1+x2)/2, cy:(y1+y2)/2, w:(x2-x1), h:(y2-y1) };
|
||
}
|
||
|
||
function defaultFontSizeForBBox(bbox) {
|
||
const m = bboxMetrics(bbox || [0,0,0,0]);
|
||
return Math.max(6, Number((m.h * 0.75).toFixed(2)));
|
||
}
|
||
|
||
function getWordFontSize(word) {
|
||
const raw = Number(word && word.font_size_guess);
|
||
if (Number.isFinite(raw) && raw > 0) return raw;
|
||
return defaultFontSizeForBBox(word && word.bbox ? word.bbox : [0,0,0,0]);
|
||
}
|
||
|
||
function getWordFontFamily(word) {
|
||
return (word && word.font_family_guess) ? String(word.font_family_guess) : "Helvetica";
|
||
}
|
||
|
||
words = words.map((word, idx) => {
|
||
const bbox = normalizeBBox(word.bbox || [0,0,0,0]);
|
||
return {
|
||
...word,
|
||
id: Number(word.id || (idx + 1)),
|
||
text: word.text || "",
|
||
bbox,
|
||
font_size_guess: Number.isFinite(Number(word.font_size_guess)) ? Number(word.font_size_guess) : defaultFontSizeForBBox(bbox),
|
||
font_family_guess: word.font_family_guess || "Helvetica",
|
||
};
|
||
});
|
||
nextWordId = Math.max(0, ...words.map(w => Number(w.id || 0))) + 1;
|
||
|
||
function cloneWords(src) {
|
||
return JSON.parse(JSON.stringify(src || []));
|
||
}
|
||
|
||
function getSelectedWord() {
|
||
return words.find(w => String(w.id) === String(selectedId)) || null;
|
||
}
|
||
|
||
function syncFontInputs(value, word = null) {
|
||
const v = Number.isFinite(Number(value)) ? Number(value) : "";
|
||
|
||
if (fontSizeInput) fontSizeInput.value = v;
|
||
if (popoverFontSizeInput) popoverFontSizeInput.value = v;
|
||
|
||
if (!word) return;
|
||
|
||
ensureStyle(word);
|
||
resolveStyle(word);
|
||
|
||
const style = word.resolved_style || {};
|
||
const color = (word.resolved_style && word.resolved_style.text_color) || word.text_color_guess || "#000000";
|
||
const family = (word.resolved_style && word.resolved_style.font_family) || word.font_family_guess || "Helvetica";
|
||
|
||
if (fontFamilyInput) fontFamilyInput.value = family;
|
||
if (popoverFontFamilyInput) popoverFontFamilyInput.value = family;
|
||
if (fontWeightInput) fontWeightInput.value = String(style.font_weight || 400);
|
||
if (fontStyleInput) fontStyleInput.value = style.font_style || "normal";
|
||
if (letterSpacingInput) letterSpacingInput.value = String(style.letter_spacing || 0);
|
||
|
||
if (textColorInput) textColorInput.value = color;
|
||
if (popoverTextColorInput) popoverTextColorInput.value = color;
|
||
if (toolbarTextColorInput) toolbarTextColorInput.value = color;
|
||
}
|
||
|
||
|
||
function setSelectedWordTextColorFromInput(color) {
|
||
const word = getSelectedWord();
|
||
if (!word || !color) return;
|
||
|
||
ensureStyle(word);
|
||
word.style = word.style || {};
|
||
word.resolved_style = word.resolved_style || {};
|
||
|
||
ensureStyle(word);
|
||
word.override_style.text_color = color;
|
||
resolveStyle(word);
|
||
word.text_color_guess = color;
|
||
|
||
if (textColorInput) textColorInput.value = color;
|
||
if (popoverTextColorInput) popoverTextColorInput.value = color;
|
||
if (toolbarTextColorInput) toolbarTextColorInput.value = color;
|
||
|
||
refreshSelectionUI({ geometryOnly: true });
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
}
|
||
|
||
function pageToCanvasPoint(px, py, displayWidth, displayHeight) {
|
||
return {
|
||
x: px * (displayWidth / Number(page.page_width || 1)),
|
||
y: py * (displayHeight / Number(page.page_height || 1)),
|
||
};
|
||
}
|
||
|
||
function clientToPage(clientX, clientY) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
return {
|
||
x: (clientX - rect.left) * (Number(page.page_width || 1) / Math.max(1, rect.width)),
|
||
y: (clientY - rect.top) * (Number(page.page_height || 1) / Math.max(1, rect.height)),
|
||
};
|
||
}
|
||
|
||
function updateZoomLabel() {
|
||
if (zoomLabel) zoomLabel.textContent = Math.round(zoom * 100) + "%";
|
||
}
|
||
|
||
function getBaseWidth() {
|
||
return image.naturalWidth || page.page_width || image.clientWidth || wrap.clientWidth || 1;
|
||
}
|
||
|
||
function getBaseHeight() {
|
||
return image.naturalHeight || page.page_height || image.clientHeight || wrap.clientHeight || 1;
|
||
}
|
||
|
||
function applyZoom() {
|
||
const baseWidth = getBaseWidth();
|
||
const baseHeight = getBaseHeight();
|
||
stage.style.width = Math.max(1, Math.round(baseWidth * zoom)) + "px";
|
||
image.style.width = "100%";
|
||
image.style.height = "auto";
|
||
image.style.maxWidth = "none";
|
||
canvas.style.width = "100%";
|
||
canvas.style.height = Math.max(1, Math.round(baseHeight * zoom)) + "px";
|
||
updateZoomLabel();
|
||
}
|
||
|
||
function fitWidth() {
|
||
const available = wrap.clientWidth - 2;
|
||
const baseWidth = getBaseWidth();
|
||
zoom = baseWidth > 0 && available > 0 ? Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, available / baseWidth)) : 1;
|
||
applyZoom();
|
||
renderCanvas();
|
||
setStatus("Fit width");
|
||
}
|
||
|
||
function sizeCanvasToStage() {
|
||
const rect = image.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) return null;
|
||
const ratio = window.devicePixelRatio || 1;
|
||
canvas.width = Math.round(rect.width * ratio);
|
||
canvas.height = Math.round(rect.height * ratio);
|
||
canvas.style.width = rect.width + "px";
|
||
canvas.style.height = rect.height + "px";
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.scale(ratio, ratio);
|
||
return { width: rect.width, height: rect.height };
|
||
}
|
||
|
||
function pushHistory() {
|
||
history = history.slice(0, historyIndex + 1);
|
||
history.push(cloneWords(words));
|
||
historyIndex = history.length - 1;
|
||
updateUndoRedoButtons();
|
||
}
|
||
|
||
function updateUndoRedoButtons() {
|
||
const undoBtn = document.getElementById("layout-undo");
|
||
const redoBtn = document.getElementById("layout-redo");
|
||
if (undoBtn) undoBtn.disabled = historyIndex <= 0;
|
||
if (redoBtn) redoBtn.disabled = historyIndex >= history.length - 1;
|
||
}
|
||
|
||
function restoreHistory(index) {
|
||
if (index < 0 || index >= history.length) return;
|
||
historyIndex = index;
|
||
words = cloneWords(history[index]);
|
||
nextWordId = Math.max(0, ...words.map(w => Number(w.id || 0))) + 1;
|
||
if (selectedId != null && !getSelectedWord()) selectedId = null;
|
||
syncReviewedTextarea();
|
||
refreshSelectionUI({ forceText: true });
|
||
renderCanvas();
|
||
updateUndoRedoButtons();
|
||
}
|
||
|
||
function rebuildReviewedTextFromWords() {
|
||
const sortedWords = [...words].sort((a, b) => {
|
||
const ab = normalizeBBox(a.bbox || [0,0,0,0]);
|
||
const bb = normalizeBBox(b.bbox || [0,0,0,0]);
|
||
if (Math.abs(ab[1] - bb[1]) > LINE_GROUP_TOLERANCE) return ab[1] - bb[1];
|
||
return ab[0] - bb[0];
|
||
});
|
||
|
||
const groups = [];
|
||
for (const word of sortedWords) {
|
||
const bbox = normalizeBBox(word.bbox || [0,0,0,0]);
|
||
const cy = (bbox[1] + bbox[3]) / 2;
|
||
let placed = false;
|
||
for (const group of groups) {
|
||
if (Math.abs(cy - group.centerY) <= LINE_GROUP_TOLERANCE) {
|
||
group.words.push(word);
|
||
group.centerY = group.words
|
||
.map(w => {
|
||
const gb = normalizeBBox(w.bbox || [0,0,0,0]);
|
||
return (gb[1] + gb[3]) / 2;
|
||
})
|
||
.reduce((a, b) => a + b, 0) / group.words.length;
|
||
placed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!placed) groups.push({ centerY: cy, words: [word] });
|
||
}
|
||
|
||
groups.sort((a, b) => a.centerY - b.centerY);
|
||
return groups.map(group => {
|
||
const ordered = [...group.words].sort((a, b) => normalizeBBox(a.bbox)[0] - normalizeBBox(b.bbox)[0]);
|
||
return ordered.map(w => (w.text || "").trim()).join(" ").trim();
|
||
}).join("\n");
|
||
}
|
||
|
||
function syncReviewedTextarea() {
|
||
if (!reviewedTextarea) return;
|
||
reviewedTextarea.value = rebuildReviewedTextFromWords();
|
||
if (actualLinesEl) {
|
||
const count = reviewedTextarea.value ? reviewedTextarea.value.split("\n").length : 0;
|
||
actualLinesEl.textContent = String(count);
|
||
}
|
||
}
|
||
|
||
function hidePopover() {
|
||
if (popoverEl) popoverEl.style.display = "none";
|
||
}
|
||
|
||
function positionPopover() {
|
||
const w = getSelectedWord();
|
||
if (!w || !popoverEl || !canvas) {
|
||
hidePopover();
|
||
return;
|
||
}
|
||
|
||
const rect = canvas.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) {
|
||
hidePopover();
|
||
return;
|
||
}
|
||
|
||
const scaleX = rect.width / Number(page.page_width || 1);
|
||
const scaleY = rect.height / Number(page.page_height || 1);
|
||
const bbox = normalizeBBox(w.bbox || [0,0,0,0]);
|
||
const x1 = bbox[0] * scaleX;
|
||
const y1 = bbox[1] * scaleY;
|
||
const y2 = bbox[3] * scaleY;
|
||
|
||
popoverEl.style.display = "block";
|
||
const gap = 8;
|
||
const desiredLeft = Math.max(8, Math.min(x1, rect.width - 240));
|
||
let desiredTop = y1 - popoverEl.offsetHeight - gap;
|
||
if (desiredTop < 8) desiredTop = Math.min(rect.height - popoverEl.offsetHeight - 8, y2 + gap);
|
||
|
||
popoverEl.style.left = desiredLeft + "px";
|
||
popoverEl.style.top = desiredTop + "px";
|
||
}
|
||
|
||
|
||
function positionPopoverFixedSize(word = null) {
|
||
if (!popoverEl) return;
|
||
|
||
const w = word || getSelectedWord();
|
||
const wrapRect = wrap.getBoundingClientRect();
|
||
|
||
let left = wrapRect.left + 24;
|
||
let top = wrapRect.top + 24;
|
||
|
||
if (w && Array.isArray(w.bbox)) {
|
||
const bbox = normalizeBBox(w.bbox);
|
||
const displayWidth = image.clientWidth || image.naturalWidth || 1;
|
||
const displayHeight = image.clientHeight || image.naturalHeight || 1;
|
||
|
||
const p = pageToCanvasPoint(bbox[0], bbox[3], displayWidth, displayHeight);
|
||
left = wrapRect.left + p.x + 12;
|
||
top = wrapRect.top + p.y + 12;
|
||
}
|
||
|
||
popoverEl.style.setProperty("--layout-popover-left", `${Math.round(left)}px`);
|
||
popoverEl.style.setProperty("--layout-popover-top", `${Math.round(top)}px`);
|
||
}
|
||
|
||
function refreshSelectionUI(opts = {}) {
|
||
if (!getSelectedWord() && popoverEl) popoverEl.style.display = "none";
|
||
const { forceText = false, geometryOnly = false } = opts;
|
||
const w = getSelectedWord();
|
||
|
||
if (!w) {
|
||
if (idInput) idInput.value = "";
|
||
if (x1Input) x1Input.value = "";
|
||
if (y1Input) y1Input.value = "";
|
||
if (x2Input) x2Input.value = "";
|
||
if (y2Input) y2Input.value = "";
|
||
syncFontInputs("");
|
||
if (forceText || !geometryOnly) {
|
||
if (textInput) textInput.value = "";
|
||
if (popoverTextInput) popoverTextInput.value = "";
|
||
}
|
||
hidePopover();
|
||
return;
|
||
}
|
||
|
||
const bbox = normalizeBBox(w.bbox || [0,0,0,0]);
|
||
if (idInput) idInput.value = w.id;
|
||
if (x1Input) x1Input.value = bbox[0];
|
||
if (y1Input) y1Input.value = bbox[1];
|
||
if (x2Input) x2Input.value = bbox[2];
|
||
if (y2Input) y2Input.value = bbox[3];
|
||
syncFontInputs(getWordFontSize(w), w);
|
||
|
||
const activeId = document.activeElement ? document.activeElement.id : "";
|
||
const editingText = activeId === "layout-word-text" || activeId === "layout-popover-text";
|
||
if (forceText || (!geometryOnly && !editingText)) {
|
||
if (textInput) textInput.value = w.text || "";
|
||
if (popoverTextInput) popoverTextInput.value = w.text || "";
|
||
}
|
||
|
||
positionPopover();
|
||
}
|
||
|
||
function baselineYForWord(word, bboxOverride = null) {
|
||
const bbox = normalizeBBox(bboxOverride || word?.bbox || [0,0,0,0]);
|
||
const m = bboxMetrics(bbox);
|
||
const fs = Number(word?.resolved_style?.font_size || word?.font_size_guess || defaultFontSizeForBBox(bbox));
|
||
return m.y2 - Math.max(0.5, fs * 0.18);
|
||
}
|
||
|
||
function snapBBox(rawBBox, movingWordId) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const tx = SNAP_THRESHOLD_PX * (Number(page.page_width || 1) / Math.max(1, rect.width));
|
||
const ty = SNAP_THRESHOLD_PX * (Number(page.page_height || 1) / Math.max(1, rect.height));
|
||
|
||
const box = bboxMetrics(rawBBox);
|
||
let out = [box.x1, box.y1, box.x2, box.y2];
|
||
snapGuides = { x: null, y: null };
|
||
|
||
if (!layoutToggleChecked(showSnapInput, true)) {
|
||
return normalizeBBox(out);
|
||
}
|
||
|
||
const movingWord = words.find(w => String(w.id) === String(movingWordId)) || null;
|
||
|
||
const candidates = [];
|
||
for (const word of words) {
|
||
if (String(word.id) === String(movingWordId)) continue;
|
||
if (!Array.isArray(word.bbox)) continue;
|
||
|
||
ensureStyle(word);
|
||
resolveStyle(word);
|
||
|
||
const m = bboxMetrics(word.bbox || [0,0,0,0]);
|
||
candidates.push({
|
||
x: [m.x1, m.cx, m.x2],
|
||
y: [m.y1, m.cy, m.y2, baselineYForWord(word)],
|
||
});
|
||
}
|
||
|
||
function applyDeltaX(delta) {
|
||
out = [out[0] + delta, out[1], out[2] + delta, out[3]];
|
||
}
|
||
|
||
function applyDeltaY(delta) {
|
||
out = [out[0], out[1] + delta, out[2], out[3] + delta];
|
||
}
|
||
|
||
let bestX = null;
|
||
let bestY = null;
|
||
|
||
const current = bboxMetrics(out);
|
||
const xPoints = [current.x1, current.cx, current.x2];
|
||
const yPoints = [
|
||
current.y1,
|
||
current.cy,
|
||
current.y2,
|
||
baselineYForWord(movingWord, out),
|
||
];
|
||
|
||
for (const c of candidates) {
|
||
for (const xp of xPoints) {
|
||
for (const gv of c.x) {
|
||
const delta = gv - xp;
|
||
if (Math.abs(delta) <= tx && (!bestX || Math.abs(delta) < Math.abs(bestX.delta))) {
|
||
bestX = { delta, guide: gv };
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const yp of yPoints) {
|
||
for (const gv of c.y) {
|
||
const delta = gv - yp;
|
||
if (Math.abs(delta) <= ty && (!bestY || Math.abs(delta) < Math.abs(bestY.delta))) {
|
||
bestY = { delta, guide: gv };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (bestX) {
|
||
applyDeltaX(bestX.delta);
|
||
snapGuides.x = bestX.guide;
|
||
}
|
||
|
||
if (bestY) {
|
||
applyDeltaY(bestY.delta);
|
||
snapGuides.y = bestY.guide;
|
||
}
|
||
|
||
return normalizeBBox(out);
|
||
}
|
||
|
||
function hexToRgba(hex, alpha) {
|
||
const clean = String(hex || "#000000").replace("#", "");
|
||
if (clean.length !== 6) return `rgba(0,0,0,${alpha})`;
|
||
const r = parseInt(clean.slice(0, 2), 16);
|
||
const g = parseInt(clean.slice(2, 4), 16);
|
||
const b = parseInt(clean.slice(4, 6), 16);
|
||
return `rgba(${r},${g},${b},${alpha})`;
|
||
}
|
||
|
||
function layoutToggleChecked(input, fallback) {
|
||
return input ? !!input.checked : fallback;
|
||
}
|
||
|
||
function drawReplicaText(word, bbox, x1, y1, x2, y2, w, h, scaleX, scaleY) {
|
||
const text = String(word.text || "").trim();
|
||
if (!text) return;
|
||
|
||
ensureStyle(word);
|
||
resolveStyle(word);
|
||
const style = word.resolved_style || {};
|
||
|
||
const fontFamily = (word.resolved_style && word.resolved_style.font_family) || word.font_family_guess || "Helvetica";
|
||
const fontWeight = style.font_weight || 400;
|
||
const fontStyle = style.font_style || "normal";
|
||
|
||
const sourceFontSize = Number(style.font_size || word.font_size_guess || 10);
|
||
const fontPx = Math.max(1, Math.min(h * 0.92, sourceFontSize * scaleY));
|
||
|
||
ctx.save();
|
||
ctx.globalAlpha = Number(style.opacity || 1);
|
||
ctx.fillStyle = toolbarTextColorInput ? toolbarTextColorInput.value : "#000000";
|
||
ctx.font = `${fontStyle} ${fontWeight} ${fontPx}px ${fontFamily}, Arial, sans-serif`;
|
||
ctx.textBaseline = "alphabetic";
|
||
|
||
// Approximate PDF baseline from box bottom. This is for live visual alignment.
|
||
const baselineY = y2 - Math.max(0.5, fontPx * 0.18);
|
||
const measured = ctx.measureText(text).width;
|
||
const fitScale = measured > 0 && measured > w ? Math.max(0.35, w / measured) : 1;
|
||
|
||
ctx.translate(x1, baselineY);
|
||
ctx.scale(fitScale, 1);
|
||
ctx.fillText(text, 0, 0);
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawBaselineGuide(x1, x2, y2, h) {
|
||
const fontGuideY = y2 - Math.max(0.5, h * 0.18);
|
||
ctx.save();
|
||
ctx.strokeStyle = hexToRgba(baselineColorInput ? baselineColorInput.value : "#10b981", 0.85);
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([3, 3]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, fontGuideY);
|
||
ctx.lineTo(x2, fontGuideY);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
|
||
function drawPageGuideLine(axis, pageValue, color) {
|
||
const displayWidth = image.clientWidth || image.naturalWidth || 1;
|
||
const displayHeight = image.clientHeight || image.naturalHeight || 1;
|
||
|
||
ctx.save();
|
||
ctx.setLineDash([6, 6]);
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeStyle = color;
|
||
ctx.beginPath();
|
||
|
||
if (axis === "x") {
|
||
const p = pageToCanvasPoint(pageValue, 0, displayWidth, displayHeight);
|
||
ctx.moveTo(p.x, 0);
|
||
ctx.lineTo(p.x, canvas.height);
|
||
} else {
|
||
const p = pageToCanvasPoint(0, pageValue, displayWidth, displayHeight);
|
||
ctx.moveTo(0, p.y);
|
||
ctx.lineTo(canvas.width, p.y);
|
||
}
|
||
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawSelectedAlignmentGuides() {
|
||
if (!layoutToggleChecked(showGuidesInput, true)) return;
|
||
|
||
const w = getSelectedWord();
|
||
if (!w || !Array.isArray(w.bbox)) return;
|
||
|
||
ensureStyle(w);
|
||
resolveStyle(w);
|
||
|
||
const m = bboxMetrics(w.bbox || [0,0,0,0]);
|
||
const baseline = baselineYForWord(w);
|
||
|
||
// Selected box edges / centers / baseline
|
||
[m.x1, m.cx, m.x2].forEach(x => drawPageGuideLine("x", x, "rgba(37,99,235,0.22)"));
|
||
[m.y1, m.cy, m.y2].forEach(y => drawPageGuideLine("y", y, "rgba(37,99,235,0.22)"));
|
||
drawPageGuideLine("y", baseline, "rgba(16,185,129,0.55)");
|
||
|
||
// Active snap guides
|
||
if (snapGuides?.x != null) drawPageGuideLine("x", snapGuides.x, "rgba(220,38,38,0.9)");
|
||
if (snapGuides?.y != null) drawPageGuideLine("y", snapGuides.y, "rgba(220,38,38,0.9)");
|
||
}
|
||
|
||
function renderCanvas() {
|
||
const sized = sizeCanvasToStage();
|
||
if (!sized) {
|
||
debug("size-failed");
|
||
return;
|
||
}
|
||
|
||
const displayWidth = sized.width;
|
||
const displayHeight = sized.height;
|
||
ctx.clearRect(0, 0, displayWidth, displayHeight);
|
||
|
||
const showScan = layoutToggleChecked(showScanInput, true);
|
||
const showBoxes = layoutToggleChecked(showBoxesInput, true);
|
||
const showText = layoutToggleChecked(showTextInput, false);
|
||
const showGuides = layoutToggleChecked(showGuidesInput, true);
|
||
const showBaselines = layoutToggleChecked(showBaselinesInput, false);
|
||
|
||
image.style.opacity = showScan ? "1" : "0";
|
||
image.style.visibility = showScan ? "visible" : "hidden";
|
||
|
||
const scaleX = displayWidth / Number(page.page_width || 1);
|
||
const scaleY = displayHeight / Number(page.page_height || 1);
|
||
|
||
for (const word of words) {
|
||
const bbox = normalizeBBox(word.bbox || [0,0,0,0]);
|
||
const x1 = bbox[0] * scaleX;
|
||
const y1 = bbox[1] * scaleY;
|
||
const x2 = bbox[2] * scaleX;
|
||
const y2 = bbox[3] * scaleY;
|
||
const w = Math.max(1, x2 - x1);
|
||
const h = Math.max(1, y2 - y1);
|
||
const selected = selectedIds.has(String(word.id)) || String(word.id) === String(selectedId);
|
||
|
||
if (showText) {
|
||
drawReplicaText(word, bbox, x1, y1, x2, y2, w, h, scaleX, scaleY);
|
||
}
|
||
|
||
if (showBaselines) {
|
||
drawBaselineGuide(x1, x2, y2, h);
|
||
}
|
||
|
||
if (showBoxes) {
|
||
ctx.save();
|
||
const boxColor = markupColorInput ? markupColorInput.value : "#dc2626";
|
||
const selColor = selectedColorInput ? selectedColorInput.value : "#2563eb";
|
||
ctx.strokeStyle = selected ? hexToRgba(selColor, 0.98) : hexToRgba(boxColor, 0.85);
|
||
ctx.lineWidth = selected ? 2 : 1;
|
||
ctx.fillStyle = selected ? hexToRgba(selColor, 0.12) : hexToRgba(boxColor, 0.03);
|
||
ctx.fillRect(x1, y1, w, h);
|
||
ctx.strokeRect(x1, y1, w, h);
|
||
|
||
if (selected) {
|
||
const handle = HANDLE_SIZE_PX;
|
||
ctx.fillStyle = hexToRgba(selColor, 0.98);
|
||
ctx.fillRect(x1 - handle / 2, y1 - handle / 2, handle, handle);
|
||
ctx.fillRect(x2 - handle / 2, y1 - handle / 2, handle, handle);
|
||
ctx.fillRect(x1 - handle / 2, y2 - handle / 2, handle, handle);
|
||
ctx.fillRect(x2 - handle / 2, y2 - handle / 2, handle, handle);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
if (dragState && dragState.mode === "add-preview") {
|
||
const x1 = Math.min(dragState.startPageX, dragState.currentPageX);
|
||
const y1 = Math.min(dragState.startPageY, dragState.currentPageY);
|
||
const x2 = Math.max(dragState.startPageX, dragState.currentPageX);
|
||
const y2 = Math.max(dragState.startPageY, dragState.currentPageY);
|
||
const p1 = pageToCanvasPoint(x1, y1, displayWidth, displayHeight);
|
||
const p2 = pageToCanvasPoint(x2, y2, displayWidth, displayHeight);
|
||
ctx.save();
|
||
if (showGuides) {
|
||
ctx.strokeStyle = "rgba(37,99,235,0.98)";
|
||
ctx.lineWidth = 2;
|
||
ctx.setLineDash([6, 4]);
|
||
ctx.strokeRect(p1.x, p1.y, Math.max(1, p2.x - p1.x), Math.max(1, p2.y - p1.y));
|
||
ctx.restore();
|
||
}
|
||
|
||
if (snapGuides) {
|
||
ctx.save();
|
||
ctx.strokeStyle = "rgba(37,99,235,0.75)";
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([4, 4]);
|
||
if (snapGuides.x != null) {
|
||
const x = snapGuides.x * scaleX;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, 0);
|
||
ctx.lineTo(x, displayHeight);
|
||
ctx.stroke();
|
||
|
||
}}
|
||
if (snapGuides.y != null) {
|
||
const y = snapGuides.y * scaleY;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, y);
|
||
ctx.lineTo(displayWidth, y);
|
||
ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
positionPopover();
|
||
debug("render words=" + words.length);
|
||
}
|
||
|
||
function isBoxSizeLocked() {
|
||
return !!(popoverLockSizeInput && popoverLockSizeInput.checked);
|
||
}
|
||
|
||
function hitTestSelectedHandles(clientX, clientY) {
|
||
if (isBoxSizeLocked()) return null;
|
||
const w = getSelectedWord();
|
||
if (!w) return null;
|
||
|
||
const rect = canvas.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) return null;
|
||
|
||
const bbox = normalizeBBox(w.bbox || [0,0,0,0]);
|
||
const scaleX = rect.width / Number(page.page_width || 1);
|
||
const scaleY = rect.height / Number(page.page_height || 1);
|
||
|
||
const corners = {
|
||
nw: { x: bbox[0] * scaleX, y: bbox[1] * scaleY },
|
||
ne: { x: bbox[2] * scaleX, y: bbox[1] * scaleY },
|
||
sw: { x: bbox[0] * scaleX, y: bbox[3] * scaleY },
|
||
se: { x: bbox[2] * scaleX, y: bbox[3] * scaleY },
|
||
};
|
||
|
||
const localX = clientX - rect.left;
|
||
const localY = clientY - rect.top;
|
||
|
||
for (const [name, pt] of Object.entries(corners)) {
|
||
if (Math.abs(localX - pt.x) <= HANDLE_HIT_PX && Math.abs(localY - pt.y) <= HANDLE_HIT_PX) {
|
||
return name;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function pickWord(clientX, clientY) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) return null;
|
||
|
||
const px = (clientX - rect.left) * (Number(page.page_width || 1) / rect.width);
|
||
const py = (clientY - rect.top) * (Number(page.page_height || 1) / rect.height);
|
||
|
||
for (let i = words.length - 1; i >= 0; i--) {
|
||
const bbox = normalizeBBox(words[i].bbox || [0,0,0,0]);
|
||
if (px >= bbox[0] && px <= bbox[2] && py >= bbox[1] && py <= bbox[3]) {
|
||
return { word: words[i], px, py };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function applyEditorValues(push = true) {
|
||
const selectedWords = getSelectedWords();
|
||
const w = getSelectedWord();
|
||
if (!selectedWords.length || !w) return null;
|
||
if (push) pushHistory();
|
||
|
||
const readValue = (el, fallback) => {
|
||
if (!el) return fallback;
|
||
const value = String(el.value ?? "");
|
||
return value.length ? value : fallback;
|
||
};
|
||
|
||
const nextText = readValue(popoverTextInput, textInput ? textInput.value : (w.text || ""));
|
||
const nextFamily = readValue(
|
||
document.getElementById("layout-popover-font-family"),
|
||
fontFamilyInput ? fontFamilyInput.value : (w.font_family_guess || "Helvetica")
|
||
).trim() || "Helvetica";
|
||
|
||
const sizeSource = document.getElementById("layout-popover-font-size") || fontSizeInput;
|
||
const nextSizeRaw = Number(sizeSource ? sizeSource.value : w.font_size_guess);
|
||
const nextSize = Number.isFinite(nextSizeRaw) && nextSizeRaw > 0
|
||
? nextSizeRaw
|
||
: Number(w.font_size_guess || 10);
|
||
|
||
const colorSource = document.getElementById("layout-popover-text-color") || textColorInput;
|
||
const nextColor = readValue(colorSource, w.text_color_guess || "#000000");
|
||
|
||
w.text = nextText;
|
||
for (const item of selectedWords) {
|
||
item.font_family_guess = nextFamily;
|
||
item.font_size_guess = nextSize;
|
||
item.text_color_guess = nextColor;
|
||
|
||
item.override_style = item.override_style && typeof item.override_style === "object" ? item.override_style : {};
|
||
item.override_style.font_family = nextFamily;
|
||
item.override_style.font_size = nextSize;
|
||
item.override_style.text_color = nextColor;
|
||
|
||
item.resolved_style = item.resolved_style && typeof item.resolved_style === "object" ? item.resolved_style : {};
|
||
item.resolved_style.font_family = nextFamily;
|
||
item.resolved_style.font_size = nextSize;
|
||
item.resolved_style.text_color = nextColor;
|
||
|
||
item.manual_flags = item.manual_flags && typeof item.manual_flags === "object" ? item.manual_flags : {};
|
||
item.manual_flags.style_edited = true;
|
||
if (String(item.id) === String(w.id)) item.manual_flags.text_edited = true;
|
||
}
|
||
|
||
if (textInput) textInput.value = nextText;
|
||
if (popoverTextInput) popoverTextInput.value = nextText;
|
||
if (fontFamilyInput) fontFamilyInput.value = nextFamily;
|
||
if (document.getElementById("layout-popover-font-family")) document.getElementById("layout-popover-font-family").value = nextFamily;
|
||
if (fontSizeInput) fontSizeInput.value = String(nextSize);
|
||
if (popoverFontSizeInput) popoverFontSizeInput.value = String(nextSize);
|
||
if (textColorInput) textColorInput.value = nextColor;
|
||
if (document.getElementById("layout-popover-text-color")) document.getElementById("layout-popover-text-color").value = nextColor;
|
||
|
||
const b = normalizeBBox([
|
||
Number(x1Input ? x1Input.value : (w.bbox || [0,0,0,0])[0]),
|
||
Number(y1Input ? y1Input.value : (w.bbox || [0,0,0,0])[1]),
|
||
Number(x2Input ? x2Input.value : (w.bbox || [0,0,0,0])[2]),
|
||
Number(y2Input ? y2Input.value : (w.bbox || [0,0,0,0])[3]),
|
||
]);
|
||
w.bbox = b;
|
||
|
||
snapGuides = null;
|
||
refreshSelectionUI({ forceText: true });
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
setStatus("Applied style to " + selectedWords.length + " selected");
|
||
return w;
|
||
}
|
||
|
||
function changeSelectedFontSize(delta) {
|
||
const w = getSelectedWord();
|
||
if (!w) return;
|
||
const next = Math.max(6, Number((getWordFontSize(w) + delta).toFixed(2)));
|
||
if (fontSizeInput) fontSizeInput.value = next;
|
||
if (popoverFontSizeInput) popoverFontSizeInput.value = next;
|
||
applyEditorValues(true);
|
||
}
|
||
|
||
function applyFontSizeInputToSelected(push = true) {
|
||
const w = getSelectedWord();
|
||
if (!w || !fontSizeInput) return;
|
||
const next = Number(fontSizeInput.value);
|
||
if (!Number.isFinite(next) || next <= 0) return;
|
||
if (push) pushHistory();
|
||
w.font_size_guess = next;
|
||
syncFontInputs(next, w);
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
setStatus("Font size applied: " + next);
|
||
}
|
||
|
||
function applyFontSizeToAllWords() {
|
||
const w = getSelectedWord();
|
||
if (!w) return;
|
||
const chosen = popoverFontSizeInput && document.activeElement === popoverFontSizeInput
|
||
? Number(popoverFontSizeInput.value)
|
||
: Number(fontSizeInput ? fontSizeInput.value : getWordFontSize(w));
|
||
if (!Number.isFinite(chosen) || chosen <= 0) return;
|
||
|
||
pushHistory();
|
||
for (const item of words) {
|
||
item.font_size_guess = chosen;
|
||
if (!item.font_family_guess) item.font_family_guess = getWordFontFamily(w);
|
||
}
|
||
syncFontInputs(chosen, w);
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
setStatus("Applied font size to all");
|
||
}
|
||
|
||
function nudge(dx, dy) {
|
||
const selectedWords = getSelectedWords();
|
||
if (!selectedWords.length) return;
|
||
pushHistory();
|
||
for (const item of selectedWords) {
|
||
item.bbox = normalizeBBox([
|
||
Number(item.bbox[0]) + dx,
|
||
Number(item.bbox[1]) + dy,
|
||
Number(item.bbox[2]) + dx,
|
||
Number(item.bbox[3]) + dy,
|
||
]);
|
||
item.bbox = snapBBox(item.bbox, item.id);
|
||
item.manual_flags = item.manual_flags && typeof item.manual_flags === "object" ? item.manual_flags : {};
|
||
item.manual_flags.geometry_edited = true;
|
||
}
|
||
refreshSelectionUI({ geometryOnly: true });
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
setStatus("Nudged " + selectedWords.length + " selected");
|
||
}
|
||
|
||
function deleteSelectedWord() {
|
||
const selectedWords = getSelectedWords();
|
||
if (!selectedWords.length) return;
|
||
pushHistory();
|
||
const deleteIds = new Set(selectedWords.map(w => String(w.id)));
|
||
words = words.filter(item => !deleteIds.has(String(item.id)));
|
||
selectedIds.clear();
|
||
selectedId = null;
|
||
nextWordId = Math.max(0, ...words.map(item => Number(item.id || 0))) + 1;
|
||
snapGuides = null;
|
||
refreshSelectionUI({ forceText: true });
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
setStatus("Deleted " + deleteIds.size + " selected");
|
||
}
|
||
|
||
function buildLayoutReviewPayload() {
|
||
return JSON.stringify({
|
||
pages: [{
|
||
page: page.page || 1,
|
||
page_width: page.page_width || 1,
|
||
page_height: page.page_height || 1,
|
||
words: words.map((w, idx) => {
|
||
const bbox = normalizeBBox([
|
||
Number((w.bbox || [0,0,0,0])[0] || 0),
|
||
Number((w.bbox || [0,0,0,0])[1] || 0),
|
||
Number((w.bbox || [0,0,0,0])[2] || 0),
|
||
Number((w.bbox || [0,0,0,0])[3] || 0),
|
||
]);
|
||
|
||
const manualFlags = Object.assign({}, w.manual_flags || {});
|
||
const isStyleEdited = manualFlags.style_edited === true;
|
||
|
||
const family = String(w.font_family_guess || "Helvetica");
|
||
const size = Number(w.font_size_guess || defaultFontSizeForBBox(bbox));
|
||
const color = String(w.text_color_guess || "#000000");
|
||
|
||
const out = {
|
||
id: Number(w.id || (idx + 1)),
|
||
text: String(w.text || ""),
|
||
bbox,
|
||
font_size_guess: size,
|
||
font_family_guess: family,
|
||
font_weight_guess: Number(w.font_weight_guess || 400),
|
||
font_style_guess: String(w.font_style_guess || "normal"),
|
||
letter_spacing_guess: Number(w.letter_spacing_guess || 0),
|
||
text_color_guess: color,
|
||
manual_flags: manualFlags,
|
||
};
|
||
|
||
if (isStyleEdited) {
|
||
out.override_style = Object.assign({}, w.override_style || {}, {
|
||
font_family: family,
|
||
font_size: size,
|
||
text_color: color,
|
||
});
|
||
out.resolved_style = Object.assign({}, w.resolved_style || {}, out.override_style);
|
||
} else {
|
||
out.override_style = {};
|
||
out.resolved_style = {};
|
||
out.manual_flags.style_edited = false;
|
||
}
|
||
|
||
return out;
|
||
}),
|
||
}],
|
||
});
|
||
}
|
||
|
||
function prepareLayoutReviewSubmit() {
|
||
try {
|
||
// Intentionally do not call applyEditorValues(false) here.
|
||
// Apply button is the mutation point. Save should only serialize current editor state.
|
||
syncReviewedTextarea();
|
||
if (saveJsonInput) {
|
||
saveJsonInput.value = buildLayoutReviewPayload();
|
||
console.log("[layout-review] prepared payload length", saveJsonInput.value.length);
|
||
}
|
||
} catch (e) {
|
||
console.error("[layout-review] prepare payload failed", e);
|
||
}
|
||
}
|
||
|
||
window.buildLayoutReviewPayload = buildLayoutReviewPayload;
|
||
window.prepareLayoutReviewSubmit = prepareLayoutReviewSubmit;
|
||
|
||
if (saveForm && saveForm.dataset.cleanPayloadSubmit !== "1") {
|
||
saveForm.dataset.cleanPayloadSubmit = "1";
|
||
saveForm.addEventListener("submit", () => {
|
||
prepareLayoutReviewSubmit();
|
||
}, true);
|
||
}
|
||
|
||
|
||
|
||
|
||
function beginPan(ev) {
|
||
dragState = {
|
||
mode: "pan",
|
||
startX: ev.clientX,
|
||
startY: ev.clientY,
|
||
startScrollLeft: wrap.scrollLeft,
|
||
startScrollTop: wrap.scrollTop,
|
||
};
|
||
canvas.style.cursor = "grabbing";
|
||
setStatus("Panning");
|
||
}
|
||
|
||
function beginMove(hit, ev) {
|
||
selectedId = String(hit.word.id);
|
||
refreshSelectionUI({ forceText: true });
|
||
renderCanvas();
|
||
|
||
dragState = {
|
||
mode: "move",
|
||
wordId: hit.word.id,
|
||
startX: ev.clientX,
|
||
startY: ev.clientY,
|
||
startBBox: normalizeBBox(hit.word.bbox || [0,0,0,0]),
|
||
};
|
||
canvas.style.cursor = "move";
|
||
setStatus("Dragging selection");
|
||
}
|
||
|
||
function beginResize(ev, handleName) {
|
||
if (isBoxSizeLocked()) {
|
||
dragState = null;
|
||
canvas.style.cursor = "move";
|
||
setStatus("Box size locked");
|
||
return;
|
||
}
|
||
const w = getSelectedWord();
|
||
if (!w) return;
|
||
pushHistory();
|
||
dragState = {
|
||
mode: "resize",
|
||
handle: handleName,
|
||
wordId: w.id,
|
||
startX: ev.clientX,
|
||
startY: ev.clientY,
|
||
startBBox: normalizeBBox(w.bbox || [0,0,0,0]),
|
||
};
|
||
canvas.style.cursor = "nwse-resize";
|
||
setStatus("Resizing selection");
|
||
}
|
||
|
||
function beginAdd(ev) {
|
||
const point = clientToPage(ev.clientX, ev.clientY);
|
||
dragState = {
|
||
mode: "add-preview",
|
||
startPageX: point.x,
|
||
startPageY: point.y,
|
||
currentPageX: point.x,
|
||
currentPageY: point.y,
|
||
};
|
||
setStatus("Drawing new box");
|
||
renderCanvas();
|
||
}
|
||
|
||
function finalizeAdd() {
|
||
if (!dragState || dragState.mode !== "add-preview") return;
|
||
|
||
const x1 = Math.min(dragState.startPageX, dragState.currentPageX);
|
||
const y1 = Math.min(dragState.startPageY, dragState.currentPageY);
|
||
const x2 = Math.max(dragState.startPageX, dragState.currentPageX);
|
||
const y2 = Math.max(dragState.startPageY, dragState.currentPageY);
|
||
dragState = null;
|
||
|
||
if ((x2 - x1) < 3 || (y2 - y1) < 3) {
|
||
renderCanvas();
|
||
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) {
|
||
activePointers.add(ev.pointerId);
|
||
if (ev.pointerType === "touch" && activePointers.size >= 2) {
|
||
multiSelectMode = true;
|
||
document.body.classList.add("layout-multi-select-active");
|
||
document.getElementById("layout-multi-select")?.classList.add("active");
|
||
hidePopover();
|
||
setStatus("Touch multi-select on");
|
||
}
|
||
if (tool === "pan") {
|
||
ev.preventDefault();
|
||
beginPan(ev);
|
||
return;
|
||
}
|
||
|
||
if (tool === "add") {
|
||
ev.preventDefault();
|
||
beginAdd(ev);
|
||
return;
|
||
}
|
||
|
||
const handleName = hitTestSelectedHandles(ev.clientX, ev.clientY);
|
||
if (handleName) {
|
||
ev.preventDefault();
|
||
beginResize(ev, handleName);
|
||
return;
|
||
}
|
||
|
||
const hit = pickWord(ev.clientX, ev.clientY);
|
||
if (!hit) {
|
||
selectedId = null;
|
||
dragState = null;
|
||
snapGuides = null;
|
||
if (popoverEl) popoverEl.style.display = "none";
|
||
canvas.style.cursor = "default";
|
||
refreshSelectionUI({ forceText: true });
|
||
if (multiSelectMode) hidePopover();
|
||
renderCanvas();
|
||
setStatus("No selection");
|
||
return;
|
||
}
|
||
|
||
// Click selects immediately. Drag starts only if pointer moves enough.
|
||
ev.preventDefault();
|
||
|
||
const id = String(hit.word.id);
|
||
const additiveSelect = multiSelectMode || ev.ctrlKey || ev.metaKey || ev.shiftKey || activePointers.size >= 2 || touchMultiAnchor;
|
||
|
||
if (additiveSelect) {
|
||
if (selectedIds.has(id)) selectedIds.delete(id);
|
||
else selectedIds.add(id);
|
||
selectedId = Array.from(selectedIds).at(-1) || null;
|
||
hidePopover();
|
||
refreshSelectionUI({ forceText: true });
|
||
renderCanvas();
|
||
setStatus("Selected: " + selectedIds.size);
|
||
return;
|
||
}
|
||
|
||
selectedIds = new Set([id]);
|
||
selectedId = id;
|
||
refreshSelectionUI({ forceText: true });
|
||
renderCanvas();
|
||
|
||
dragState = {
|
||
mode: "pending-move",
|
||
wordId: hit.word.id,
|
||
startX: ev.clientX,
|
||
startY: ev.clientY,
|
||
startBBox: normalizeBBox(hit.word.bbox || [0,0,0,0]),
|
||
historyPushed: false,
|
||
};
|
||
|
||
canvas.style.cursor = "move";
|
||
setStatus("Selected");
|
||
}
|
||
|
||
function handlePointerMove(ev) {
|
||
if (!dragState) return;
|
||
if (ev && ev.pointerType === "touch") markLayoutReviewDragging();
|
||
ev.preventDefault();
|
||
|
||
|
||
if (dragState.mode === "pending-move") {
|
||
const moved = Math.hypot(ev.clientX - dragState.startX, ev.clientY - dragState.startY);
|
||
|
||
if (moved < 4) {
|
||
return;
|
||
}
|
||
|
||
dragState.mode = "move";
|
||
if (!dragState.historyPushed) {
|
||
pushHistory();
|
||
dragState.historyPushed = true;
|
||
}
|
||
setStatus("Dragging selection");
|
||
}
|
||
|
||
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") {
|
||
if (isBoxSizeLocked()) {
|
||
dragState = null;
|
||
canvas.style.cursor = "move";
|
||
setStatus("Box size locked");
|
||
renderCanvas();
|
||
return;
|
||
}
|
||
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 markLayoutReviewDragging() {
|
||
document.body.classList.add("layout-review-dragging");
|
||
}
|
||
|
||
function clearLayoutReviewDragging() {
|
||
document.body.classList.remove("layout-review-dragging");
|
||
}
|
||
|
||
function endDrag(ev) {
|
||
if (ev && ev.pointerId != null) activePointers.delete(ev.pointerId);
|
||
if (dragState && dragState.mode === "pending-move") {
|
||
dragState = null;
|
||
snapGuides = null;
|
||
canvas.style.cursor = tool === "pan" ? "grab" : (tool === "add" ? "crosshair" : "default");
|
||
renderCanvas();
|
||
return;
|
||
}
|
||
clearLayoutReviewDragging();
|
||
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();
|
||
drawSelectedAlignmentGuides();
|
||
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-select-all")?.addEventListener("click", () => {
|
||
selectedIds = new Set(words.map(w => String(w.id)));
|
||
selectedId = Array.from(selectedIds).at(-1) || null;
|
||
refreshSelectionUI({ forceText: true });
|
||
if (multiSelectMode) hidePopover();
|
||
renderCanvas();
|
||
setStatus("Selected all: " + selectedIds.size);
|
||
});
|
||
document.getElementById("layout-clear-selection")?.addEventListener("click", () => {
|
||
selectedIds.clear();
|
||
selectedId = null;
|
||
refreshSelectionUI({ forceText: true });
|
||
renderCanvas();
|
||
setStatus("Selection cleared");
|
||
});
|
||
|
||
document.getElementById("layout-invert-selection")?.addEventListener("click", () => {
|
||
const next = new Set();
|
||
for (const w of words) {
|
||
const id = String(w.id);
|
||
if (!selectedIds.has(id)) next.add(id);
|
||
}
|
||
selectedIds = next;
|
||
selectedId = Array.from(selectedIds).at(-1) || null;
|
||
refreshSelectionUI({ forceText: true });
|
||
renderCanvas();
|
||
setStatus("Inverted selection: " + selectedIds.size);
|
||
});
|
||
|
||
document.getElementById("layout-select-line")?.addEventListener("click", () => {
|
||
const w = getSelectedWord();
|
||
if (!w) return;
|
||
const b = normalizeBBox(w.bbox || [0,0,0,0]);
|
||
const cy = (b[1] + b[3]) / 2;
|
||
selectedIds = new Set(words.filter(item => {
|
||
const ib = normalizeBBox(item.bbox || [0,0,0,0]);
|
||
const icy = (ib[1] + ib[3]) / 2;
|
||
return Math.abs(icy - cy) <= LINE_GROUP_TOLERANCE;
|
||
}).map(item => String(item.id)));
|
||
selectedId = Array.from(selectedIds).at(-1) || null;
|
||
refreshSelectionUI({ forceText: true });
|
||
renderCanvas();
|
||
setStatus("Selected line: " + selectedIds.size);
|
||
});
|
||
document.getElementById("layout-multi-select")?.addEventListener("click", () => {
|
||
multiSelectMode = !multiSelectMode;
|
||
document.getElementById("layout-multi-select")?.classList.toggle("active", multiSelectMode);
|
||
document.body.classList.toggle("layout-multi-select-active", multiSelectMode);
|
||
if (multiSelectMode) hidePopover();
|
||
setStatus(multiSelectMode ? "Multi-select on" : "Multi-select off");
|
||
});
|
||
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", (ev) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
if (ev.stopImmediatePropagation) ev.stopImmediatePropagation();
|
||
|
||
const word = getSelectedWord();
|
||
if (!word) {
|
||
setStatus("No word selected");
|
||
return;
|
||
}
|
||
|
||
pushHistory();
|
||
|
||
const textEl = document.getElementById("layout-popover-text");
|
||
const familyTextEl = document.getElementById("layout-popover-font-family");
|
||
const familySelectEl = document.getElementById("layout-popover-font-family-select");
|
||
const sizeEl = document.getElementById("layout-popover-font-size");
|
||
const colorEl = document.getElementById("layout-popover-text-color");
|
||
|
||
const nextText = textEl ? String(textEl.value || "") : String(word.text || "");
|
||
const nextFamily = (
|
||
familySelectEl && familySelectEl.value
|
||
? String(familySelectEl.value).trim()
|
||
: familyTextEl
|
||
? String(familyTextEl.value || "Helvetica").trim()
|
||
: String(word.font_family_guess || "Helvetica").trim()
|
||
) || "Helvetica";
|
||
const nextSizeRaw = sizeEl ? Number(sizeEl.value) : Number(word.font_size_guess);
|
||
const nextSize = Number.isFinite(nextSizeRaw) && nextSizeRaw > 0 ? nextSizeRaw : Number(word.font_size_guess || 10);
|
||
const nextColor = colorEl ? String(colorEl.value || "#000000") : String(word.text_color_guess || "#000000");
|
||
|
||
word.text = nextText;
|
||
word.font_family_guess = nextFamily;
|
||
word.font_size_guess = nextSize;
|
||
word.text_color_guess = nextColor;
|
||
word.override_style = word.override_style && typeof word.override_style === "object" ? word.override_style : {};
|
||
word.override_style.font_family = nextFamily;
|
||
word.override_style.font_size = nextSize;
|
||
word.override_style.text_color = nextColor;
|
||
word.resolved_style = word.resolved_style && typeof word.resolved_style === "object" ? word.resolved_style : {};
|
||
word.resolved_style.font_family = nextFamily;
|
||
word.resolved_style.font_size = nextSize;
|
||
word.resolved_style.text_color = nextColor;
|
||
word.manual_flags = word.manual_flags && typeof word.manual_flags === "object" ? word.manual_flags : {};
|
||
word.manual_flags.text_edited = true;
|
||
word.manual_flags.style_edited = true;
|
||
|
||
if (textInput) textInput.value = nextText;
|
||
if (fontFamilyInput) fontFamilyInput.value = nextFamily;
|
||
if (popoverFontFamilyInput) popoverFontFamilyInput.value = nextFamily;
|
||
if (familyTextEl) familyTextEl.value = nextFamily;
|
||
if (familySelectEl) familySelectEl.value = nextFamily;
|
||
if (fontSizeInput) fontSizeInput.value = String(nextSize);
|
||
if (popoverFontSizeInput) popoverFontSizeInput.value = String(nextSize);
|
||
if (textColorInput) textColorInput.value = nextColor;
|
||
if (popoverTextColorInput) popoverTextColorInput.value = nextColor;
|
||
|
||
syncReviewedTextarea();
|
||
refreshSelectionUI({ forceText: true });
|
||
renderCanvas();
|
||
|
||
const oldText = ev.currentTarget.textContent;
|
||
ev.currentTarget.textContent = "Applied ✓";
|
||
ev.currentTarget.classList.add("layout-apply-flash");
|
||
setStatus("Applied selected word only: " + nextText + " / " + nextFamily + " / " + nextSize + "pt");
|
||
window.setTimeout(() => {
|
||
ev.currentTarget.textContent = oldText || "Apply";
|
||
ev.currentTarget.classList.remove("layout-apply-flash");
|
||
}, 900);
|
||
});
|
||
|
||
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);
|
||
fontSizeInput?.addEventListener("change", () => applyFontSizeInputToSelected(true));
|
||
fontSizeInput?.addEventListener("blur", () => applyFontSizeInputToSelected(true));
|
||
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);
|
||
|
||
|
||
function syncPopoverStyleControlsToWord() {
|
||
const w = getSelectedWord();
|
||
if (!w) return;
|
||
|
||
const patch = {};
|
||
|
||
if (popoverFontSizeInput) {
|
||
const nextFontSize = Number(popoverFontSizeInput.value);
|
||
if (Number.isFinite(nextFontSize) && nextFontSize > 0) {
|
||
patch.font_size = nextFontSize;
|
||
}
|
||
}
|
||
|
||
if (popoverFontFamilyInput && popoverFontFamilyInput.value.trim()) {
|
||
patch.font_family = popoverFontFamilyInput.value.trim();
|
||
}
|
||
|
||
if (popoverTextColorInput && popoverTextColorInput.value) {
|
||
patch.text_color = popoverTextColorInput.value;
|
||
}
|
||
|
||
if (Object.keys(patch).length) {
|
||
applyStyleToWord(w, patch);
|
||
refreshSelectionUI({ geometryOnly: true });
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
}
|
||
}
|
||
|
||
[popoverFontSizeInput, popoverFontFamilyInput, popoverTextColorInput].forEach((el) => {
|
||
if (el) el.addEventListener("input", syncPopoverStyleControlsToWord);
|
||
if (el) el.addEventListener("change", syncPopoverStyleControlsToWord);
|
||
});
|
||
|
||
|
||
function installWordPopoverClickAway() {
|
||
document.addEventListener("pointerdown", function (ev) {
|
||
if (!popoverEl) return;
|
||
if (popoverEl.style.display === "none") return;
|
||
|
||
const target = ev.target;
|
||
const clickedPopover = popoverEl.contains(target);
|
||
const clickedCanvas = canvas && canvas.contains(target);
|
||
const clickedToolbar = document.getElementById("layout-review-toolbar")?.contains(target);
|
||
|
||
if (!clickedPopover && !clickedCanvas && !clickedToolbar) {
|
||
popoverEl.style.display = "none";
|
||
}
|
||
}, true);
|
||
}
|
||
|
||
installWordPopoverClickAway();
|
||
|
||
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 (ev) {
|
||
try {
|
||
applyEditorValues(false);
|
||
syncReviewedTextarea();
|
||
const payload = buildLayoutReviewPayload();
|
||
if (saveJsonInput) saveJsonInput.value = payload;
|
||
console.log("[layout-review] payload bytes", payload ? payload.length : -1);
|
||
if (statusEl) statusEl.textContent = "[layout-review] payload bytes: " + (payload ? payload.length : -1);
|
||
} catch (err) {
|
||
ev.preventDefault();
|
||
console.error("[layout-review] submit error", err);
|
||
if (statusEl) statusEl.textContent = "[layout-review] submit error: " + (err && err.message ? err.message : err);
|
||
}
|
||
});
|
||
}
|
||
|
||
pushHistory();
|
||
setTool("select");
|
||
updateZoomLabel();
|
||
|
||
|
||
function resetLayoutRibbonScroll() {
|
||
const ribbon = document.getElementById("layout-review-toolbar");
|
||
if (ribbon) ribbon.scrollLeft = 0;
|
||
}
|
||
|
||
function initialRender() {
|
||
resetLayoutRibbonScroll();
|
||
fitWidth();
|
||
syncReviewedTextarea();
|
||
refreshSelectionUI({ forceText: true });
|
||
renderCanvas();
|
||
setStatus("Ready");
|
||
}
|
||
|
||
if (image.complete) initialRender();
|
||
else image.addEventListener("load", initialRender, { once: true });
|
||
[showScanInput, showBoxesInput, showTextInput, showBaselinesInput, showGuidesInput, showSnapInput].forEach((el) => {
|
||
if (el) el.addEventListener("change", renderCanvas);
|
||
});
|
||
|
||
toolbarTextColorInput?.addEventListener("input", () => {
|
||
const color = toolbarTextColorInput.value || "#000000";
|
||
|
||
if (textColorInput) textColorInput.value = color;
|
||
if (popoverTextColorInput) popoverTextColorInput.value = color;
|
||
|
||
const word = getSelectedWord();
|
||
if (word) {
|
||
applyStyleToWord(word, { text_color: color });
|
||
refreshSelectionUI({ geometryOnly: true });
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
}
|
||
});
|
||
|
||
|
||
function installTextColorOverlayHandlers() {
|
||
[toolbarTextColorInput, textColorInput, popoverTextColorInput].forEach((el) => {
|
||
if (!el) return;
|
||
el.addEventListener("input", () => setSelectedWordTextColorFromInput(el.value || "#000000"));
|
||
el.addEventListener("change", () => setSelectedWordTextColorFromInput(el.value || "#000000"));
|
||
});
|
||
}
|
||
|
||
installTextColorOverlayHandlers();
|
||
|
||
[markupColorInput, selectedColorInput, baselineColorInput, toolbarTextColorInput].forEach((el) => {
|
||
if (el) el.addEventListener("input", renderCanvas);
|
||
});
|
||
|
||
|
||
|
||
|
||
window.addEventListener("resize", renderCanvas);
|
||
window.addEventListener("scroll", () => positionPopoverFixedSize(), true);
|
||
window.addEventListener("resize", () => positionPopoverFixedSize());
|
||
|
||
|
||
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: toolbarTextColorInput?.value || 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 %}
|
||
<p class="empty-state">No layout review data available yet.</p>
|
||
{% endif %}
|
||
</div>
|
||
<div class="tab-panel{% if active_tab == 'extracted-fields' %} active{% endif %}" data-panel="extracted-fields">
|
||
<div class="extracted-fields-header-row">
|
||
<h2 class="card-title">Extracted fields</h2>
|
||
<form method="get" action="/documents/{{ document.document_id }}" class="extracted-autofill-inline-form">
|
||
<input type="hidden" name="autofill_extracted" value="1">
|
||
<input type="hidden" name="tab" value="extracted-fields">
|
||
<button type="submit">Auto-extract fields</button>
|
||
</form>
|
||
</div>
|
||
{% if current_extracted_version_number %}
|
||
{% set current_extracted_meta = (
|
||
extracted_version_options
|
||
| selectattr(0, "equalto", current_extracted_version_number)
|
||
| list
|
||
| first
|
||
) %}
|
||
<p>
|
||
Current extracted version: v{{ current_extracted_version_number }}
|
||
{% if current_extracted_meta %}— {{ current_extracted_meta[1] }}{% endif %}
|
||
</p>
|
||
{% endif %}
|
||
|
||
{% if current_extracted %}
|
||
|
||
{% else %}
|
||
<p class="empty-state">No extracted fields saved yet.</p>
|
||
{% endif %}
|
||
|
||
|
||
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-extracted-fields" style="margin-top: 1rem;">
|
||
<div class="form-grid">
|
||
<div class="form-field"><label>Merchant raw</label><input type="text" name="merchant_raw" value="{{ extracted_form.merchant_raw }}"></div>
|
||
<div class="form-field"><label>Merchant normalized</label><input type="text" name="merchant_normalized" value="{{ extracted_form.merchant_normalized }}"></div>
|
||
<div class="form-field"><label>Transaction date</label><input type="date" name="transaction_date" value="{{ extracted_form.transaction_date }}"></div>
|
||
<div class="form-field"><label>Transaction time</label><input type="text" name="transaction_time" value="{{ extracted_form.transaction_time }}"></div>
|
||
<div class="form-field"><label>Subtotal</label><input type="text" name="subtotal" value="{{ extracted_form.subtotal }}"></div>
|
||
<div class="form-field"><label>Tax</label><input type="text" name="tax" value="{{ extracted_form.tax }}"></div>
|
||
<div class="form-field"><label>Total</label><input type="text" name="total" value="{{ extracted_form.total }}"></div>
|
||
<div class="form-field"><label>Currency</label><input type="text" name="currency" value="{{ extracted_form.currency }}"></div>
|
||
<div class="form-field"><label>Payment method</label><input type="text" name="payment_method" value="{{ extracted_form.payment_method }}"></div>
|
||
<div class="form-field"><label>Reference number</label><input type="text" name="receipt_number" value="{{ extracted_form.receipt_number }}"></div>
|
||
<div class="form-field full"><label>Location</label><input type="text" name="location" value="{{ extracted_form.location }}"></div>
|
||
<div class="form-field full"><label>Counterparty</label><input type="text" name="counterparty" value="{{ extracted_form.counterparty }}"></div>
|
||
<div class="form-field full extra-json-editor-field">
|
||
<label for="extra_json">Extra JSON</label>
|
||
<div class="editor-wrap extra-json-editor-wrap">
|
||
<pre class="line-numbers" id="extra-json-line-numbers">1</pre>
|
||
<textarea id="extra_json" name="extra_json" rows="8" spellcheck="false">{{ extracted_form.extra_json }}</textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="button-row" style="margin-top: 1rem;">
|
||
<button class="primary" type="submit">Save extracted fields</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="tab-panel{% if active_tab == 'additional-fields' %} active{% endif %}" data-panel="additional-fields">
|
||
<h2 class="card-title">Additional fields</h2>
|
||
{% if current_additional_version_number %}{% set current_additional_meta = (
|
||
additional_version_options
|
||
| selectattr(0, "equalto", current_additional_version_number)
|
||
| list
|
||
| first
|
||
) %}
|
||
<p>
|
||
Current additional version: v{{ current_additional_version_number }}
|
||
{% if current_additional_meta %}— {{ current_additional_meta[1] }}{% endif %}
|
||
</p>{% endif %}
|
||
|
||
{% if current_additional %}
|
||
|
||
{% else %}
|
||
<p class="empty-state">No additional fields saved yet.</p>
|
||
{% endif %}
|
||
|
||
{% if presets %}
|
||
<form method="get" action="/documents/{{ document.document_id }}" style="margin-bottom: 1rem;">
|
||
<input type="hidden" name="tab" value="additional-fields">
|
||
<div class="button-row">
|
||
<select name="preset_id">
|
||
<option value="">Select preset</option>
|
||
{% for preset in presets %}
|
||
<option value="{{ preset.id }}" {% if selected_preset_id == preset.id %}selected{% endif %}>{{ preset.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<button type="submit">Apply preset</button>
|
||
<a class="button-link" href="/presets/">Manage presets</a>
|
||
</div>
|
||
</form>
|
||
{% else %}
|
||
<div class="button-row" style="margin-bottom: 1rem;">
|
||
<a class="button-link" href="/presets/">Create first preset</a>
|
||
</div>
|
||
{% endif %}
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-additional-fields">
|
||
<div class="form-grid">
|
||
<div class="form-field">
|
||
<label>Primary owner</label>
|
||
<input type="text" name="owner_primary" value="{{ additional_form.owner_primary }}">
|
||
</div>
|
||
<div class="form-field">
|
||
<label>Secondary owner</label>
|
||
<input type="text" name="owner_secondary" value="{{ additional_form.owner_secondary }}">
|
||
</div>
|
||
<div class="form-field">
|
||
<label>Paid by person</label>
|
||
<input type="text" name="paid_by_person" value="{{ additional_form.paid_by_person }}">
|
||
</div>
|
||
<div class="form-field full">
|
||
<label>Covered people</label>
|
||
<input type="text" name="covered_people" value="{{ additional_form.covered_people }}" placeholder="Full Name, Full Name">
|
||
</div>
|
||
<div class="form-field full">
|
||
<label>Attendees</label>
|
||
<input type="text" name="attendees" value="{{ additional_form.attendees }}" placeholder="Full Name, Full Name">
|
||
</div>
|
||
<div class="form-field full">
|
||
<label>Occasion note</label>
|
||
<input type="text" name="occasion_note" value="{{ additional_form.occasion_note }}" placeholder="Dinner with Camie and friends">
|
||
</div>
|
||
<div class="form-field">
|
||
<label><input type="checkbox" name="is_shared_expense" value="1" {% if additional_form.is_shared_expense %}checked{% endif %}> Shared expense</label>
|
||
</div>
|
||
<div class="form-field full">
|
||
<label>Reimbursement expected from</label>
|
||
<input type="text" name="reimbursement_expected_from" value="{{ additional_form.reimbursement_expected_from }}" placeholder="Full Name, Full Name">
|
||
</div>
|
||
<div class="form-field">
|
||
<label>Reimbursement paid by</label>
|
||
<input type="text" name="reimbursement_paid_by" value="{{ additional_form.reimbursement_paid_by }}">
|
||
</div>
|
||
<div class="form-field">
|
||
<label>Reimbursement paid to</label>
|
||
<input type="text" name="reimbursement_paid_to" value="{{ additional_form.reimbursement_paid_to }}">
|
||
</div>
|
||
<div class="form-field">
|
||
<label>Reimbursement paid amount</label>
|
||
<input type="text" name="reimbursement_paid_amount" value="{{ additional_form.reimbursement_paid_amount }}">
|
||
</div>
|
||
<div class="form-field">
|
||
<label>Reimbursement paid date</label>
|
||
<input type="date" name="reimbursement_paid_date" value="{{ additional_form.reimbursement_paid_date }}">
|
||
</div>
|
||
<div class="form-field full">
|
||
<label>Reimbursement note</label>
|
||
<textarea name="reimbursement_note" rows="4">{{ additional_form.reimbursement_note }}</textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="button-row" style="margin-top: 1rem;">
|
||
<button class="primary" type="submit">Save additional fields</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
|
||
<div class="tab-panel{% if active_tab == 'line-items' %} active{% endif %}" data-panel="line-items">
|
||
<div class="line-items-header-row">
|
||
<h2 class="card-title">Line Items</h2>
|
||
<form method="post" action="/documents/{{ document.document_id }}/regenerate-line-items" class="line-items-regenerate-inline-form">
|
||
<button type="submit">Regenerate Line Items</button>
|
||
</form>
|
||
</div>
|
||
{% if current_line_item_version %}
|
||
<p>Current line item version: v{{ current_line_item_version.version_number }} — {{ current_line_item_version.created_at }}</p>
|
||
{% else %}
|
||
<p class="empty-state">No line items saved yet.</p>
|
||
{% endif %}
|
||
|
||
|
||
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-line-items">
|
||
{% set base_count = line_items|length %}
|
||
{% set row_count = base_count if base_count > 0 else 0 %}
|
||
<input type="hidden" name="row_count" value="{{ row_count }}">
|
||
|
||
<div style="margin-bottom: 0.5rem;">
|
||
<button type="button" onclick="addRow()">+ Add Row</button>
|
||
|
||
</div>
|
||
|
||
<div style="overflow-x:auto;">
|
||
<table style="width:100%; border-collapse:collapse;">
|
||
<thead>
|
||
<tr>
|
||
<th style="text-align:left; padding:0.5rem;">#</th>
|
||
<th style="text-align:left; padding:0.5rem;">Date</th>
|
||
<th style="text-align:left; padding:0.5rem;">Description</th>
|
||
<th style="text-align:left; padding:0.5rem;">Qty</th>
|
||
<th style="text-align:left; padding:0.5rem;">Unit</th>
|
||
<th style="text-align:left; padding:0.5rem;">Total</th>
|
||
<th style="text-align:left; padding:0.5rem;">Tax</th>
|
||
<th style="text-align:left; padding:0.5rem;">Category</th>
|
||
<th style="text-align:left; padding:0.5rem;">Notes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for i in range(row_count) %}
|
||
{% set item = line_items[i] if i < line_items|length else none %}
|
||
<tr class="line-item-row">
|
||
<td style="padding:0.35rem; white-space:nowrap;">
|
||
<span class="line-item-row-number">{{ i + 1 }}</span>
|
||
<button type="button" onclick="removeLineItemRow(this)" aria-label="Remove line item" title="Remove line item" style="margin-left:0.45rem; padding:0; border:none; background:transparent; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; width:2.1rem; height:2.1rem; vertical-align:middle;">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" style="display:block; width:1.9rem; height:1.9rem;">
|
||
<rect x="1.5" y="1.5" width="21" height="21" rx="5" fill="#ffffff" stroke="#d7dce5" stroke-width="1.2"></rect>
|
||
<path d="M8 7h8" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
<path d="M10 5h4" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
<path d="M9 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
<path d="M12 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
<path d="M15 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
<path d="M7.5 7.5l.7 10.2a1 1 0 0 0 1 .9h5.6a1 1 0 0 0 1-.9l.7-10.2" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
</svg>
|
||
</button>
|
||
</td>
|
||
<td style="padding:0.35rem;"><input type="date" name="entry_date_{{ i }}" value="{{ item.entry_date.isoformat() if item and item.entry_date else '' }}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="description_{{ i }}" value="{{ item.description if item else '' }}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="quantity_{{ i }}" value="{{ (item.quantity | string | replace('.0000', '') | replace('.00', '')) if item and item.quantity is not none else '' }}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="unit_price_{{ i }}" value="{{ (item.unit_price | string | replace('.0000', '') | replace('.00', '')) if item and item.unit_price is not none else '' }}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="line_total_{{ i }}" value="{{ '%.2f'|format(item.line_total|float) if item and item.line_total is not none else '' }}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="tax_amount_{{ i }}" value="{{ '%.2f'|format(item.tax_amount|float) if item and item.tax_amount is not none else '' }}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="category_{{ i }}" value="{{ item.category if item else '' }}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="notes_{{ i }}" value="{{ item.notes if item else '' }}" style="width:100%;"></td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="button-row" style="margin-top: 1rem;">
|
||
<button class="primary" type="submit">Save line items</button>
|
||
</div>
|
||
</form>
|
||
|
||
<script>
|
||
function renumberLineItemRows(panel) {
|
||
const tbody = panel.querySelector("tbody");
|
||
const rowCountInput = panel.querySelector('input[name="row_count"]');
|
||
const rows = Array.from(tbody.querySelectorAll("tr.line-item-row, tr"));
|
||
|
||
rows.forEach((row, i) => {
|
||
const num = row.querySelector(".line-item-row-number");
|
||
if (num) {
|
||
num.textContent = String(i + 1);
|
||
} else if (row.cells && row.cells[0]) {
|
||
row.cells[0].textContent = String(i + 1);
|
||
}
|
||
|
||
row.querySelectorAll("input").forEach((input) => {
|
||
const oldName = input.getAttribute("name");
|
||
if (!oldName) return;
|
||
input.setAttribute("name", oldName.replace(/_\d+$/, `_${i}`));
|
||
});
|
||
});
|
||
|
||
rowCountInput.value = rows.length;
|
||
}
|
||
|
||
function removeLineItemRow(button) {
|
||
const panel = document.querySelector('[data-panel="line-items"]');
|
||
if (!panel) return;
|
||
|
||
const row = button.closest("tr");
|
||
if (!row) return;
|
||
|
||
row.remove();
|
||
renumberLineItemRows(panel);
|
||
}
|
||
|
||
function addRow() {
|
||
const panel = document.querySelector('[data-panel="line-items"]');
|
||
if (!panel) return;
|
||
|
||
const tbody = panel.querySelector("tbody");
|
||
const i = tbody.querySelectorAll("tr").length;
|
||
|
||
const row = document.createElement("tr");
|
||
row.className = "line-item-row";
|
||
row.innerHTML = `
|
||
<td style="padding:0.35rem; white-space:nowrap;">
|
||
<span class="line-item-row-number">${i + 1}</span>
|
||
<button type="button" onclick="removeLineItemRow(this)" aria-label="Remove line item" title="Remove line item" style="margin-left:0.45rem; padding:0; border:none; background:transparent; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; width:2.1rem; height:2.1rem; vertical-align:middle;">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" style="display:block; width:1.9rem; height:1.9rem;">
|
||
<rect x="1.5" y="1.5" width="21" height="21" rx="5" fill="#ffffff" stroke="#d7dce5" stroke-width="1.2"></rect>
|
||
<path d="M8 7h8" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
<path d="M10 5h4" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
<path d="M9 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
<path d="M12 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
<path d="M15 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
<path d="M7.5 7.5l.7 10.2a1 1 0 0 0 1 .9h5.6a1 1 0 0 0 1-.9l.7-10.2" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||
</svg>
|
||
</button>
|
||
</td>
|
||
<td style="padding:0.35rem;"><input type="date" name="entry_date_${i}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="description_${i}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="quantity_${i}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="unit_price_${i}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="line_total_${i}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="tax_amount_${i}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="category_${i}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem;"><input type="text" name="notes_${i}" style="width:100%;"></td>
|
||
<td style="padding:0.35rem; width:3.25rem; text-align:center;"><button type="button" class="line-item-delete-btn" onclick="deleteRow(this)" aria-label="Delete line item" title="Delete line item">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<rect class="trash-tile" x="1.5" y="1.5" width="21" height="21" rx="5"></rect>
|
||
<path class="trash-can" d="M8 7h8"></path>
|
||
<path class="trash-can" d="M10 5h4"></path>
|
||
<path class="trash-can" d="M9 9.25v6.25"></path>
|
||
<path class="trash-can" d="M12 9.25v6.25"></path>
|
||
<path class="trash-can" d="M15 9.25v6.25"></path>
|
||
<path class="trash-can" d="M7.5 7.5l.7 10.2a1 1 0 0 0 1 .9h5.6a1 1 0 0 0 1-.9l.7-10.2"></path>
|
||
</svg>
|
||
</button></td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
|
||
renumberLineItemRows(panel);
|
||
}
|
||
</script>
|
||
</div>
|
||
|
||
<div class="tab-panel{% if active_tab == 'versions' %} active{% endif %}" data-panel="versions">
|
||
<h2 class="card-title">Document versions</h2>
|
||
{% if version_rows %}
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Version</th>
|
||
<th>Type</th>
|
||
<th>Path</th>
|
||
<th>Size</th>
|
||
<th>Created</th>
|
||
<th>Notes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for version, file_exists in version_rows %}
|
||
<tr>
|
||
<td>v{{ version.version_number }}</td>
|
||
<td>{{ version.version_type }}</td>
|
||
<td>
|
||
{{ version.file_path }}
|
||
<div style="margin-top:0.25rem;">
|
||
{% if file_exists %}
|
||
<span class="badge">Available</span>
|
||
{% endif %}
|
||
</div>
|
||
</td>
|
||
<td>{{ human_size(version.file_size_bytes) }}</td>
|
||
<td>{{ version.created_at }}</td>
|
||
<td>{{ version.notes or "" }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<p class="empty-state">No versions found.</p>
|
||
{% endif %}
|
||
</div>
|
||
|
||
|
||
<div class="tab-panel{% if active_tab == 'source-options' %} active{% endif %}" data-panel="source-options">
|
||
<h2 class="card-title">Source Options</h2>
|
||
|
||
<form method="post" action="/documents/{{ document.document_id }}/source-options" style="display:flex; flex-direction:column; gap:1rem;">
|
||
<div class="card" style="padding:1rem;">
|
||
<div class="source-options-select-row">
|
||
<label for="file_action_select" class="source-options-inline-label">File source</label>
|
||
<select id="file_action_select" name="file_action">
|
||
<option value="none" selected>No file change</option>
|
||
<option value="revert_original">Revert to original file</option>
|
||
<option value="revert_current_version">Revert to latest saved PDF version</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="padding:1rem;">
|
||
<h3 style="margin-top:0;">Restore from version history</h3>
|
||
<div class="data-reset-grid">
|
||
<div class="data-reset-row">
|
||
<label for="ocr_restore_choice">OCR</label>
|
||
<select id="ocr_restore_choice" name="ocr_restore_choice">
|
||
<option value="none">No change</option>
|
||
<option value="original">Reset to original</option>
|
||
{% for tv in (document.text_versions | sort(attribute='version_number', reverse=True)) %}
|
||
<option value="version:{{ tv.version_number }}">
|
||
v{{ tv.version_number }} — {{ tv.version_type }} — {{ tv.created_at }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="data-reset-row">
|
||
<label for="extracted_restore_choice">Extracted fields</label>
|
||
<select id="extracted_restore_choice" name="extracted_restore_choice">
|
||
<option value="none">No change</option>
|
||
<option value="original">Reset to original</option>
|
||
{% for v in (document.extracted_field_versions | sort(attribute='version_number', reverse=True)) %}
|
||
<option value="version:{{ v.version_number }}">
|
||
v{{ v.version_number }} — {{ v.created_at }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="data-reset-row">
|
||
<label for="additional_restore_choice">Additional fields</label>
|
||
<select id="additional_restore_choice" name="additional_restore_choice">
|
||
<option value="none">No change</option>
|
||
<option value="original">Reset to original</option>
|
||
{% for v in (document.additional_field_versions | sort(attribute='version_number', reverse=True)) %}
|
||
<option value="version:{{ v.version_number }}">
|
||
v{{ v.version_number }} — {{ v.created_at }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="data-reset-row">
|
||
<label for="line_item_restore_choice">Line items</label>
|
||
<select id="line_item_restore_choice" name="line_item_restore_choice">
|
||
<option value="none">No change</option>
|
||
<option value="clear">Clear current line items</option>
|
||
{% if document.line_item_set_versions is defined %}
|
||
{% for v in (document.line_item_set_versions | sort(attribute='version_number', reverse=True)) %}
|
||
<option value="version:{{ v.version_number }}">
|
||
v{{ v.version_number }} — {{ v.created_at }}
|
||
</option>
|
||
{% endfor %}
|
||
{% endif %}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<button class="btn btn-primary" type="submit">Apply Source Options</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="tab-panel{% if active_tab == 'raw-ocr' %} active{% endif %}" data-panel="raw-ocr">
|
||
<h2 class="card-title">Raw OCR</h2>
|
||
{% if raw_ocr %}
|
||
<div class="meta-grid">
|
||
<div class="meta-item"><span class="meta-label">Text version</span>v{{ raw_ocr.version_number }}</div>
|
||
<div class="meta-item"><span class="meta-label">OCR engine</span>{{ raw_ocr.ocr_engine or "unknown" }}</div>
|
||
<div class="meta-item"><span class="meta-label">Engine version</span>{{ raw_ocr.ocr_engine_version or "unknown" }}</div>
|
||
<div class="meta-item"><span class="meta-label">Rerun source</span>{{ raw_ocr.rerun_source or "unknown" }}</div>
|
||
<div class="meta-item"><span class="meta-label">Quality score</span>{{ raw_ocr.quality_score if raw_ocr.quality_score is not none else "not scored yet" }}</div>
|
||
<div class="meta-item"><span class="meta-label">Quality note</span>{{ raw_ocr.quality_note or "" }}</div>
|
||
</div>
|
||
<p><strong>Quality flags:</strong> {{ raw_ocr.quality_flags if raw_ocr and raw_ocr.quality_flags else [] }}</p>
|
||
<pre class="codeblock">{{ raw_ocr.text_content }}</pre>
|
||
{% else %}
|
||
<p class="empty-state">No raw OCR text found.</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2 class="card-title">Metadata</h2>
|
||
<div class="meta-grid">
|
||
<div class="meta-item"><span class="meta-label">Type</span>{{ document.document_type }}</div>
|
||
<div class="meta-item"><span class="meta-label">Review status</span>{{ document.review_status }}</div>
|
||
<div class="meta-item"><span class="meta-label">Source path</span>{{ document.source_path }}</div>
|
||
<div class="meta-item"><span class="meta-label">Current path</span>{{ document.current_path }}</div>
|
||
<div class="meta-item"><span class="meta-label">Original filename</span>{{ document.original_filename }}</div>
|
||
<div class="meta-item"><span class="meta-label">Canonical filename</span>{{ document.canonical_filename }}</div>
|
||
<div class="meta-item"><span class="meta-label">MIME type</span>{{ document.mime_type }}</div>
|
||
<div class="meta-item"><span class="meta-label">File size</span>{{ human_size(document.file_size) }}</div>
|
||
<div class="meta-item"><span class="meta-label">Page count</span>{{ document.page_count }}</div>
|
||
<div class="meta-item"><span class="meta-label">Share path</span>{{ document.share_path or "" }}</div>
|
||
<div class="meta-item"><span class="meta-label">Created at</span>{{ document.created_at }}</div>
|
||
<div class="meta-item"><span class="meta-label">Updated at</span>{{ document.updated_at }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const ta = document.getElementById("extra_json");
|
||
const nums = document.getElementById("extra-json-line-numbers");
|
||
if (!ta || !nums) return;
|
||
|
||
const syncNums = () => {
|
||
const count = Math.max(1, ta.value.split("\n").length);
|
||
nums.textContent = Array.from({ length: count }, (_, i) => String(i + 1)).join("\n");
|
||
nums.scrollTop = ta.scrollTop;
|
||
};
|
||
|
||
ta.addEventListener("input", syncNums);
|
||
ta.addEventListener("scroll", () => {
|
||
nums.scrollTop = ta.scrollTop;
|
||
});
|
||
|
||
syncNums();
|
||
});
|
||
</script>
|
||
|
||
|
||
<script>
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const panel = document.querySelector('.tab-panel[data-panel="line-items"]');
|
||
if (!panel) return;
|
||
|
||
const moneyCols = [6, 7]; // total, tax
|
||
const qtyCols = [4, 5]; // qty, unit
|
||
|
||
const rows = panel.querySelectorAll("tbody tr");
|
||
rows.forEach((row) => {
|
||
const cells = row.querySelectorAll("td");
|
||
[...moneyCols, ...qtyCols].forEach((col) => {
|
||
const cell = cells[col - 1];
|
||
if (!cell) return;
|
||
const input = cell.querySelector("input");
|
||
if (!input) return;
|
||
|
||
const trimDecimals = () => {
|
||
const v = input.value.trim();
|
||
if (!v) return;
|
||
const n = Number(v.replace(/,/g, ""));
|
||
if (!Number.isFinite(n)) return;
|
||
|
||
if (moneyCols.includes(col)) {
|
||
input.value = n.toFixed(2);
|
||
} else {
|
||
input.value = Number.isInteger(n) ? String(n) : String(n);
|
||
}
|
||
};
|
||
|
||
input.addEventListener("blur", trimDecimals);
|
||
});
|
||
});
|
||
});
|
||
</script>
|
||
|
||
{% endblock %}
|
||
|
||
|
||
<script>
|
||
(function () {
|
||
const dataTag = document.getElementById("ocr-overlay-data");
|
||
const overlayRoot = document.getElementById("ocr-overlay-root");
|
||
if (!dataTag || !overlayRoot) return;
|
||
|
||
let overlayData = [];
|
||
try {
|
||
overlayData = JSON.parse(dataTag.textContent || "[]");
|
||
} catch (e) {
|
||
overlayData = [];
|
||
}
|
||
if (!Array.isArray(overlayData) || !overlayData.length) return;
|
||
|
||
const textToggle = document.getElementById("overlay-toggle-text");
|
||
const boxesToggle = document.getElementById("overlay-toggle-boxes");
|
||
const levelLines = document.getElementById("overlay-level-lines");
|
||
const levelWords = document.getElementById("overlay-level-words");
|
||
|
||
function currentLevel() {
|
||
return levelWords && levelWords.checked ? "words" : "lines";
|
||
}
|
||
|
||
function clearOverlay() {
|
||
overlayRoot.innerHTML = "";
|
||
}
|
||
|
||
function renderOcrOverlay() {
|
||
clearOverlay();
|
||
|
||
const showText = !!(textToggle && textToggle.checked);
|
||
const showBoxes = !!(boxesToggle && boxesToggle.checked);
|
||
if (!showText && !showBoxes) return;
|
||
|
||
const page = overlayData[0];
|
||
if (!page) return;
|
||
|
||
const pageWidth = Number(page.page_width || 1);
|
||
const pageHeight = Number(page.page_height || 1);
|
||
const rootRect = overlayRoot.getBoundingClientRect();
|
||
if (!rootRect.width || !rootRect.height) return;
|
||
|
||
const xScale = rootRect.width / pageWidth;
|
||
const yScale = rootRect.height / pageHeight;
|
||
const items = currentLevel() === "words" ? (page.words || []) : (page.lines || []);
|
||
|
||
for (const item of items) {
|
||
const bbox = item.bbox || [0, 0, 0, 0];
|
||
const x1 = Number(bbox[0] || 0) * xScale;
|
||
const y1 = Number(bbox[1] || 0) * yScale;
|
||
const x2 = Number(bbox[2] || 0) * xScale;
|
||
const y2 = Number(bbox[3] || 0) * yScale;
|
||
const w = Math.max(1, x2 - x1);
|
||
const h = Math.max(1, y2 - y1);
|
||
|
||
const el = document.createElement("div");
|
||
el.style.position = "absolute";
|
||
el.style.left = x1 + "px";
|
||
el.style.top = y1 + "px";
|
||
el.style.width = w + "px";
|
||
el.style.height = h + "px";
|
||
el.style.boxSizing = "border-box";
|
||
el.style.pointerEvents = "none";
|
||
|
||
if (showBoxes) {
|
||
el.style.border = "1px solid rgba(220,38,38,0.55)";
|
||
el.style.background = "rgba(220,38,38,0.04)";
|
||
}
|
||
|
||
if (showText) {
|
||
el.textContent = item.text || "";
|
||
el.style.color = "rgba(220,38,38,0.92)";
|
||
el.style.fontSize = Math.max(9, Math.min(24, h * 0.9)) + "px";
|
||
el.style.lineHeight = h + "px";
|
||
el.style.whiteSpace = "nowrap";
|
||
el.style.overflow = "hidden";
|
||
}
|
||
|
||
overlayRoot.appendChild(el);
|
||
}
|
||
}
|
||
|
||
[textToggle, boxesToggle, levelLines, levelWords].forEach((el) => {
|
||
if (el) el.addEventListener("change", renderOcrOverlay);
|
||
});
|
||
|
||
window.addEventListener("resize", renderOcrOverlay);
|
||
setTimeout(renderOcrOverlay, 250);
|
||
setTimeout(renderOcrOverlay, 900);
|
||
})();
|
||
</script>
|
||
|
||
<script id="disable-layout-font-all-buttons">
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
for (const id of ["layout-font-all", "layout-popover-font-all"]) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.disabled = true;
|
||
el.title = "Disabled while selected-word styling is being stabilized";
|
||
el.style.opacity = "0.45";
|
||
}
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<script id="layout-disable-all-font-buttons-final">
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
for (const id of ["layout-font-all", "layout-popover-font-all"]) {
|
||
const btn = document.getElementById(id);
|
||
if (!btn) continue;
|
||
btn.disabled = true;
|
||
btn.title = "Disabled to prevent accidental whole-document font changes";
|
||
btn.style.opacity = "0.45";
|
||
}
|
||
});
|
||
</script>
|