2535 lines
111 KiB
HTML
2535 lines
111 KiB
HTML
{% 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();
|
||
});
|
||
</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_replica_pdf_scan_backed" %}
|
||
<div class="success-message">Scan-backed replica PDF 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_replica_pdf_scan_backed" %}
|
||
<div class="success-message">Scan-backed 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_replica_pdf_scan_backed_failed" %}
|
||
<div class="error-box">Could not save scan-backed 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;">
|
||
<button type="submit">Save Replica PDF</button>
|
||
</form>
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-replica-pdf-scan-backed" style="display:inline;">
|
||
<button type="submit">Save Replica PDF (Scan-backed)</button>
|
||
</form>
|
||
<form method="post" action="/documents/{{ document.document_id }}/save-replica-pdf-debug-overlay" style="display:inline;">
|
||
<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 success == "saved_replica_pdf_scan_backed_fallback" %}
|
||
<div style="background:#ecfdf5; border:1px solid #86efac; color:#166534; padding:0.75rem 1rem; border-radius:10px; margin-bottom:1rem;">
|
||
Clean replica could not be generated for this document, so a scan-backed replica was created instead.
|
||
</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 %}
|
||
|
||
{% if success == "saved_replica_pdf_scan_backed_fallback" %}
|
||
<div style="background:#ecfdf5; border:1px solid #86efac; color:#166534; padding:0.75rem 1rem; border-radius:10px; margin-bottom:1rem;">
|
||
Clean replica was unavailable because usable OCR layout boxes were missing. A scan-backed replica was generated instead.
|
||
</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_scan_backed_output %}
|
||
<a class="preview-source-link{% if viewer_source == 'replica_scan_backed' %} active{% endif %}" href="/documents/{{ document.document_id }}?tab={{ active_tab }}&viewer_source=replica_scan_backed">Replica (Scan-backed)</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>
|
||
{% endif %}
|
||
|
||
|
||
</div>
|
||
{% if not storage_available %}
|
||
<p class="empty-state">Storage mount unavailable. Preview is temporarily unavailable.</p>
|
||
{% elif file_url %}
|
||
{% if viewer_source == "docx" %}
|
||
<div class="preview-frame-wrap">
|
||
<iframe
|
||
class="preview-frame"
|
||
id="preview-frame"
|
||
src="/documents/{{ document.document_id }}/diagnostic-docx-html"
|
||
style="width:100%; min-height:78vh; border:0; background:white;"
|
||
loading="lazy">
|
||
</iframe>
|
||
</div>
|
||
{% elif document.mime_type == "application/pdf" %}
|
||
<div class="preview-overlay-stack" style="position:relative;">
|
||
<embed class="preview-frame" id="preview-frame" src="{{ file_url }}" type="application/pdf">
|
||
{% if overlay_page_data %}
|
||
<div id="ocr-overlay-root" style="position:absolute; inset:0; pointer-events:none; overflow:hidden;"></div>
|
||
<script id="ocr-overlay-data" type="application/json">{{ overlay_page_data|tojson }}</script>
|
||
{% endif %}
|
||
</div>
|
||
{% elif document.mime_type in ["image/jpeg", "image/png"] %}
|
||
<img class="preview-image" src="{{ file_url }}" alt="Document image">
|
||
{% else %}
|
||
<p><a href="{{ file_url }}" target="_blank">Open file</a></p>
|
||
{% endif %}
|
||
{% else %}
|
||
<p class="empty-state">No preview available.</p>
|
||
{% endif %}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div class="card">
|
||
<div class="right-pane-tabs">
|
||
<button class="tab-button{% if active_tab in ['ocr-review', 'raw-ocr', 'source-options'] %} active{% endif %}" type="button" data-tab="ocr-review">OCR Review</button>
|
||
<button class="tab-button{% if active_tab == 'layout-review' %} active{% endif %}" type="button" data-tab="layout-review">Layout Review</button>
|
||
<button class="tab-button{% if active_tab == 'extracted-fields' %} active{% endif %}" type="button" data-tab="extracted-fields">Extracted Fields</button>
|
||
<button class="tab-button{% if active_tab == 'additional-fields' %} active{% endif %}" type="button" data-tab="additional-fields">Additional Fields</button>
|
||
<button class="tab-button{% if active_tab == 'line-items' %} active{% endif %}" type="button" data-tab="line-items">Line Items</button>
|
||
<button class="tab-button{% if active_tab == 'versions' %} active{% endif %}" type="button" data-tab="versions">Versions</button>
|
||
<button class="tab-button{% if active_tab == 'raw-ocr' %} active{% endif %}" type="button" data-tab="raw-ocr">Raw OCR</button>
|
||
<button class="tab-button{% if active_tab == 'source-options' %} active{% endif %}" type="button" data-tab="source-options">Source Options</button>
|
||
</div>
|
||
|
||
<div class="tab-panel{% if active_tab in ['ocr-review', 'raw-ocr', 'source-options'] %} active{% endif %}" data-panel="ocr-review">
|
||
<div class="ocr-review-header-row">
|
||
<h2 class="card-title">Reviewed OCR</h2>
|
||
<form method="post" action="/documents/{{ document.document_id }}/rerun-ocr" class="ocr-rerun-inline-form">
|
||
<button type="submit">Rerun OCR</button>
|
||
</form>
|
||
</div>
|
||
{% if current_text_version %}
|
||
<p>Current OCR version: v{{ current_text_version.version_number }} — {{ current_text_version.version_type }} — {{ current_text_version.created_at }}</p>
|
||
{% else %}
|
||
<p class="empty-state">No OCR version available yet.</p>
|
||
{% endif %}
|
||
|
||
<p>
|
||
Expected OCR lines: <span id="expected-lines">{{ expected_line_count }}</span>
|
||
|
|
||
Current editor lines: <span id="actual-lines">{{ actual_line_count }}</span>
|
||
</p>
|
||
|
||
<form method="post" action="/documents/{{ document.document_id }}/review-text">
|
||
<div class="form-field full">
|
||
<label for="reviewed_text">
|
||
Review OCR text (1 line each; mismatch affects PDF)
|
||
</label>
|
||
<div class="editor-wrap">
|
||
<pre class="line-numbers" id="line-numbers">{% for n in line_numbers %}{{ n }}
|
||
{% endfor %}</pre>
|
||
<textarea id="reviewed_text" name="reviewed_text" rows="34" spellcheck="false">{{ review_text_value }}</textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-field full">
|
||
<label>Quality flags</label>
|
||
<div>
|
||
{% for flag in quality_flag_options %}
|
||
<label style="display:block; margin-bottom: 0.25rem;">
|
||
<input type="checkbox" name="quality_flags" value="{{ flag }}" {% if flag in current_quality_flags %}checked{% endif %}>
|
||
{{ flag }}
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-field full">
|
||
<label for="quality_note">Quality note</label>
|
||
<textarea id="quality_note" name="quality_note" rows="4">{{ current_quality_note }}</textarea>
|
||
</div>
|
||
|
||
<div class="button-row">
|
||
<button class="primary" type="submit" id="save-reviewed-btn">Save reviewed OCR</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
|
||
|
||
<div class="tab-panel{% if active_tab == 'layout-review' %} active{% endif %}" data-panel="layout-review">
|
||
{% if layout_review_image_url and layout_review_pages %}
|
||
<style>
|
||
#layout-review-toolbar {
|
||
display:flex;
|
||
flex-wrap:wrap;
|
||
gap:0.5rem;
|
||
align-items:center;
|
||
margin-bottom:0.75rem;
|
||
padding:0.85rem;
|
||
border:1px solid #ddd;
|
||
border-radius:18px;
|
||
background:#fff;
|
||
}
|
||
|
||
#layout-review-shell {
|
||
display:grid;
|
||
grid-template-columns:minmax(0,1fr) 320px;
|
||
gap:1rem;
|
||
align-items:start;
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
#layout-review-shell {
|
||
grid-template-columns:1fr;
|
||
}
|
||
}
|
||
|
||
.layout-tool-btn {
|
||
border:1px solid #cbd5e1;
|
||
background:#fff;
|
||
color:#0f172a;
|
||
border-radius:999px;
|
||
padding:0.5rem 0.8rem;
|
||
font:inherit;
|
||
}
|
||
|
||
.layout-tool-btn.active {
|
||
background:#eff6ff;
|
||
border-color:#60a5fa;
|
||
color:#1d4ed8;
|
||
}
|
||
|
||
.layout-tool-btn.primary {
|
||
background:#2563eb;
|
||
border-color:#2563eb;
|
||
color:#fff;
|
||
}
|
||
|
||
.layout-tool-btn.danger,
|
||
#layout-delete-word-inline,
|
||
#layout-popover-delete {
|
||
background:#fee2e2;
|
||
border-color:#fecaca;
|
||
color:#991b1b;
|
||
}
|
||
|
||
#layout-review-status {
|
||
color:#475569;
|
||
font-size:0.95rem;
|
||
}
|
||
|
||
#layout-review-canvas-wrap {
|
||
position:relative;
|
||
width:100%;
|
||
min-height:900px;
|
||
border:1px solid #ddd;
|
||
border-radius:18px;
|
||
overflow:auto;
|
||
background:#fafafa;
|
||
touch-action:none;
|
||
-webkit-user-select:none;
|
||
user-select:none;
|
||
-webkit-touch-callout:none;
|
||
}
|
||
|
||
#layout-review-stage {
|
||
position:relative;
|
||
width:100%;
|
||
display:block;
|
||
}
|
||
|
||
#layout-review-image {
|
||
display:block;
|
||
width:100%;
|
||
height:auto;
|
||
max-width:none;
|
||
pointer-events:none;
|
||
-webkit-user-drag:none;
|
||
user-drag:none;
|
||
}
|
||
|
||
#layout-review-canvas {
|
||
position:absolute;
|
||
inset:0;
|
||
width:100%;
|
||
height:100%;
|
||
touch-action:none;
|
||
}
|
||
|
||
#layout-review-debug {
|
||
position:sticky;
|
||
left:8px;
|
||
bottom:8px;
|
||
z-index:6;
|
||
width:fit-content;
|
||
margin:8px;
|
||
background:rgba(0,0,0,0.78);
|
||
color:#fff;
|
||
padding:4px 8px;
|
||
border-radius:8px;
|
||
font-size:12px;
|
||
}
|
||
|
||
#layout-props-card {
|
||
border:1px solid #ddd;
|
||
border-radius:18px;
|
||
padding:0.9rem;
|
||
background:#fff;
|
||
}
|
||
|
||
.layout-field-grid {
|
||
display:grid;
|
||
grid-template-columns:1fr 1fr;
|
||
gap:0.5rem;
|
||
}
|
||
|
||
@media (max-width: 560px) {
|
||
.layout-field-grid {
|
||
grid-template-columns:1fr;
|
||
}
|
||
}
|
||
|
||
#layout-props-actions {
|
||
display:flex;
|
||
gap:0.5rem;
|
||
flex-wrap:wrap;
|
||
}
|
||
|
||
#layout-word-popover {
|
||
display:none;
|
||
position:absolute;
|
||
z-index:20;
|
||
min-width:240px;
|
||
max-width:300px;
|
||
background:#ffffff;
|
||
border:1px solid #cbd5e1;
|
||
border-radius:14px;
|
||
box-shadow:0 10px 30px rgba(15,23,42,0.16);
|
||
padding:0.7rem;
|
||
}
|
||
|
||
.layout-mini-grid {
|
||
display:grid;
|
||
grid-template-columns:1fr auto auto auto;
|
||
gap:0.35rem;
|
||
align-items:center;
|
||
}
|
||
</style>
|
||
|
||
<div id="layout-review-toolbar">
|
||
<button type="button" class="layout-tool-btn active" id="layout-tool-select">Select</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>
|
||
<button type="button" class="layout-tool-btn" id="layout-undo">Undo</button>
|
||
<button type="button" class="layout-tool-btn" id="layout-redo">Redo</button>
|
||
<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>
|
||
<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 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">
|
||
<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">
|
||
</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>
|
||
|
||
<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");
|
||
|
||
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 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 applyStyleWordBtn = document.getElementById("layout-apply-style-word");
|
||
const applyStyleLineBtn = document.getElementById("layout-apply-style-line");
|
||
const applyStylePageBtn = document.getElementById("layout-apply-style-page");
|
||
const resetStyleWordBtn = document.getElementById("layout-reset-style-word");
|
||
|
||
const HANDLE_SIZE_PX = 14;
|
||
const HANDLE_HIT_PX = 26;
|
||
const SNAP_THRESHOLD_PX = 10;
|
||
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 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);
|
||
|
||
if (fontFamilyInput) fontFamilyInput.value = word.resolved_style?.font_family || "Helvetica";
|
||
if (fontWeightInput) fontWeightInput.value = String(word.resolved_style?.font_weight || 400);
|
||
if (fontStyleInput) fontStyleInput.value = word.resolved_style?.font_style || "normal";
|
||
if (letterSpacingInput) letterSpacingInput.value = String(word.resolved_style?.letter_spacing || 0);
|
||
if (textColorInput) textColorInput.value = word.resolved_style?.text_color || "#000000";
|
||
}
|
||
|
||
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 refreshSelectionUI(opts = {}) {
|
||
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));
|
||
|
||
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 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 };
|
||
|
||
const candidates = [];
|
||
for (const word of words) {
|
||
if (String(word.id) === String(movingWordId)) continue;
|
||
const m = bboxMetrics(word.bbox || [0,0,0,0]);
|
||
candidates.push(m);
|
||
}
|
||
|
||
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 = [
|
||
{ value: current.x1, kind: "left" },
|
||
{ value: current.cx, kind: "center" },
|
||
{ value: current.x2, kind: "right" },
|
||
];
|
||
const yPoints = [
|
||
{ value: current.y1, kind: "top" },
|
||
{ value: current.cy, kind: "center" },
|
||
{ value: current.y2, kind: "bottom" },
|
||
];
|
||
|
||
for (const c of candidates) {
|
||
const cxPoints = [c.x1, c.cx, c.x2];
|
||
const cyPoints = [c.y1, c.cy, c.y2];
|
||
|
||
for (const xp of xPoints) {
|
||
for (const gv of cxPoints) {
|
||
const delta = gv - xp.value;
|
||
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 cyPoints) {
|
||
const delta = gv - yp.value;
|
||
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 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 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 = String(word.id) === String(selectedId);
|
||
|
||
ctx.save();
|
||
ctx.strokeStyle = selected ? "rgba(37,99,235,0.98)" : "rgba(220,38,38,0.85)";
|
||
ctx.lineWidth = selected ? 2 : 1;
|
||
ctx.fillStyle = selected ? "rgba(37,99,235,0.12)" : "rgba(220,38,38,0.03)";
|
||
ctx.fillRect(x1, y1, w, h);
|
||
ctx.strokeRect(x1, y1, w, h);
|
||
|
||
if (selected) {
|
||
const handle = HANDLE_SIZE_PX;
|
||
ctx.fillStyle = "rgba(37,99,235,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();
|
||
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 hitTestSelectedHandles(clientX, clientY) {
|
||
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 w = getSelectedWord();
|
||
if (!w) return;
|
||
if (push) pushHistory();
|
||
|
||
const nextText = popoverTextInput && document.activeElement === popoverTextInput
|
||
? popoverTextInput.value
|
||
: (textInput ? textInput.value : w.text);
|
||
|
||
const nextFontSize = popoverFontSizeInput && document.activeElement === popoverFontSizeInput
|
||
? Number(popoverFontSizeInput.value)
|
||
: Number(fontSizeInput ? fontSizeInput.value : getWordFontSize(w));
|
||
|
||
w.text = nextText;
|
||
w.font_size_guess = (Number.isFinite(nextFontSize) && nextFontSize > 0) ? nextFontSize : getWordFontSize(w);
|
||
if (!w.font_family_guess) w.font_family_guess = "Helvetica";
|
||
|
||
if (textInput) textInput.value = nextText;
|
||
if (popoverTextInput) popoverTextInput.value = nextText;
|
||
syncFontInputs(w.font_size_guess, w);
|
||
|
||
w.bbox = normalizeBBox([
|
||
Number(x1Input ? x1Input.value : 0),
|
||
Number(y1Input ? y1Input.value : 0),
|
||
Number(x2Input ? x2Input.value : 0),
|
||
Number(y2Input ? y2Input.value : 0),
|
||
]);
|
||
|
||
snapGuides = null;
|
||
refreshSelectionUI({ forceText: true });
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
setStatus("Applied changes");
|
||
}
|
||
|
||
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 w = getSelectedWord();
|
||
if (!w) return;
|
||
pushHistory();
|
||
w.bbox = normalizeBBox([
|
||
Number(w.bbox[0]) + dx,
|
||
Number(w.bbox[1]) + dy,
|
||
Number(w.bbox[2]) + dx,
|
||
Number(w.bbox[3]) + dy,
|
||
]);
|
||
w.bbox = snapBBox(w.bbox, w.id);
|
||
refreshSelectionUI({ geometryOnly: true });
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
setStatus("Nudged selection");
|
||
}
|
||
|
||
function deleteSelectedWord() {
|
||
const w = getSelectedWord();
|
||
if (!w) return;
|
||
pushHistory();
|
||
words = words.filter(item => String(item.id) !== String(selectedId));
|
||
selectedId = null;
|
||
nextWordId = Math.max(0, ...words.map(item => Number(item.id || 0))) + 1;
|
||
snapGuides = null;
|
||
refreshSelectionUI({ forceText: true });
|
||
syncReviewedTextarea();
|
||
renderCanvas();
|
||
setStatus("Deleted selected box");
|
||
}
|
||
|
||
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) => {
|
||
ensureStyle(w);
|
||
resolveStyle(w);
|
||
return {
|
||
id: Number(w.id || (idx + 1)),
|
||
text: w.text || "",
|
||
font_size_guess: getWordFontSize(w),
|
||
font_family_guess: getWordFontFamily(w),
|
||
font_weight_guess: Number((w.resolved_style && w.resolved_style.font_weight) || 400),
|
||
font_style_guess: String((w.resolved_style && w.resolved_style.font_style) || "normal"),
|
||
letter_spacing_guess: Number((w.resolved_style && w.resolved_style.letter_spacing) || 0),
|
||
text_color_guess: String((w.resolved_style && w.resolved_style.text_color) || "#000000"),
|
||
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),
|
||
]),
|
||
};
|
||
}),
|
||
}],
|
||
});
|
||
}
|
||
|
||
window.prepareLayoutReviewSubmit = function () {
|
||
applyEditorValues(false);
|
||
syncReviewedTextarea();
|
||
const payload = buildLayoutReviewPayload();
|
||
if (saveJsonInput) saveJsonInput.value = payload;
|
||
if (statusEl) statusEl.textContent = "[layout-review] manual payload bytes: " + (payload ? payload.length : -1);
|
||
return payload;
|
||
};
|
||
|
||
|
||
function prepareLayoutReviewSubmit() {
|
||
try { applyEditorValues(false); } catch (e) { console.error("[layout-review] applyEditorValues failed", e); }
|
||
try { syncReviewedTextarea(); } catch (e) { console.error("[layout-review] syncReviewedTextarea failed", e); }
|
||
try {
|
||
if (saveJsonInput) {
|
||
saveJsonInput.value = buildLayoutReviewPayload();
|
||
console.log("[layout-review] prepared payload length", saveJsonInput.value.length);
|
||
}
|
||
} catch (e) {
|
||
console.error("[layout-review] build payload failed", e);
|
||
}
|
||
}
|
||
|
||
window.buildLayoutReviewPayload = buildLayoutReviewPayload;
|
||
window.prepareLayoutReviewSubmit = prepareLayoutReviewSubmit;
|
||
|
||
function prepareLayoutReviewSubmit() {
|
||
try { applyEditorValues(false); } catch (e) { console.error("[layout-review] applyEditorValues failed", e); }
|
||
try { syncReviewedTextarea(); } catch (e) { console.error("[layout-review] syncReviewedTextarea failed", e); }
|
||
try {
|
||
if (saveJsonInput) {
|
||
saveJsonInput.value = buildLayoutReviewPayload();
|
||
console.log("[layout-review] prepared payload length", saveJsonInput.value.length);
|
||
}
|
||
} catch (e) {
|
||
console.error("[layout-review] build payload failed", e);
|
||
}
|
||
}
|
||
|
||
window.buildLayoutReviewPayload = buildLayoutReviewPayload;
|
||
window.prepareLayoutReviewSubmit = prepareLayoutReviewSubmit;
|
||
|
||
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();
|
||
pushHistory();
|
||
|
||
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) {
|
||
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) {
|
||
ev.preventDefault();
|
||
|
||
if (tool === "pan") {
|
||
beginPan(ev);
|
||
return;
|
||
}
|
||
if (tool === "add") {
|
||
beginAdd(ev);
|
||
return;
|
||
}
|
||
|
||
const handleName = hitTestSelectedHandles(ev.clientX, ev.clientY);
|
||
if (handleName) {
|
||
beginResize(ev, handleName);
|
||
return;
|
||
}
|
||
|
||
const hit = pickWord(ev.clientX, ev.clientY);
|
||
if (!hit) {
|
||
renderCanvas();
|
||
setStatus(selectedId != null ? "Selection kept" : "No selection");
|
||
return;
|
||
}
|
||
|
||
beginMove(hit, ev);
|
||
}
|
||
|
||
function handlePointerMove(ev) {
|
||
if (!dragState) return;
|
||
ev.preventDefault();
|
||
|
||
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") {
|
||
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 endDrag() {
|
||
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();
|
||
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-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", () => {
|
||
if (textInput && popoverTextInput) textInput.value = popoverTextInput.value;
|
||
if (fontSizeInput && popoverFontSizeInput) fontSizeInput.value = popoverFontSizeInput.value;
|
||
applyEditorValues(true);
|
||
});
|
||
|
||
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);
|
||
|
||
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 initialRender() {
|
||
fitWidth();
|
||
syncReviewedTextarea();
|
||
refreshSelectionUI({ forceText: true });
|
||
renderCanvas();
|
||
setStatus("Ready");
|
||
}
|
||
|
||
if (image.complete) initialRender();
|
||
else image.addEventListener("load", initialRender, { once: true });
|
||
window.addEventListener("resize", renderCanvas);
|
||
|
||
function collectStylePatchFromInputs() {
|
||
return {
|
||
font_family: (fontFamilyInput?.value || "Helvetica").trim() || "Helvetica",
|
||
font_size: Number(fontSizeInput?.value || 10),
|
||
font_weight: Number(fontWeightInput?.value || 400),
|
||
font_style: fontStyleInput?.value || "normal",
|
||
letter_spacing: Number(letterSpacingInput?.value || 0),
|
||
text_color: textColorInput?.value || "#000000",
|
||
};
|
||
}
|
||
|
||
if (applyStyleWordBtn) {
|
||
applyStyleWordBtn.addEventListener("click", function () {
|
||
const word = getSelectedWord();
|
||
if (!word) return;
|
||
applyStyleToWord(word, collectStylePatchFromInputs());
|
||
pushHistoryState("style-word");
|
||
redraw();
|
||
});
|
||
}
|
||
|
||
if (applyStyleLineBtn) {
|
||
applyStyleLineBtn.addEventListener("click", function () {
|
||
const word = getSelectedWord();
|
||
if (!word) return;
|
||
const y = ((word.bbox || [0,0,0,0])[1] + (word.bbox || [0,0,0,0])[3]) / 2;
|
||
const patch = collectStylePatchFromInputs();
|
||
for (const w of words) {
|
||
const bbox = w.bbox || [0,0,0,0];
|
||
const wy = (bbox[1] + bbox[3]) / 2;
|
||
if (Math.abs(wy - y) <= 12) applyStyleToWord(w, patch);
|
||
}
|
||
pushHistoryState("style-line");
|
||
redraw();
|
||
});
|
||
}
|
||
|
||
if (applyStylePageBtn) {
|
||
applyStylePageBtn.addEventListener("click", function () {
|
||
const patch = collectStylePatchFromInputs();
|
||
for (const w of words) applyStyleToWord(w, patch);
|
||
pushHistoryState("style-page");
|
||
redraw();
|
||
});
|
||
}
|
||
|
||
if (resetStyleWordBtn) {
|
||
resetStyleWordBtn.addEventListener("click", function () {
|
||
const word = getSelectedWord();
|
||
if (!word) return;
|
||
ensureStyle(word);
|
||
word.override_style = {};
|
||
word.manual_flags.style_edited = false;
|
||
resolveStyle(word);
|
||
pushHistoryState("style-reset");
|
||
redraw();
|
||
});
|
||
}
|
||
})();
|
||
</script>
|
||
{% else %}
|
||
<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>
|
||
|
||
|
||
|
||
|
||
|
||
|