document-processor/app/templates/documents/detail.html

4860 lines
186 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
&nbsp; | &nbsp;
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>