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

2535 lines
111 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% 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>
&nbsp; | &nbsp;
Current editor lines: <span id="actual-lines">{{ actual_line_count }}</span>
</p>
<form method="post" action="/documents/{{ document.document_id }}/review-text">
<div class="form-field full">
<label for="reviewed_text">
Review OCR text (1 line each; mismatch affects PDF)
</label>
<div class="editor-wrap">
<pre class="line-numbers" id="line-numbers">{% for n in line_numbers %}{{ n }}
{% endfor %}</pre>
<textarea id="reviewed_text" name="reviewed_text" rows="34" spellcheck="false">{{ review_text_value }}</textarea>
</div>
</div>
<div class="form-field full">
<label>Quality flags</label>
<div>
{% for flag in quality_flag_options %}
<label style="display:block; margin-bottom: 0.25rem;">
<input type="checkbox" name="quality_flags" value="{{ flag }}" {% if flag in current_quality_flags %}checked{% endif %}>
{{ flag }}
</label>
{% endfor %}
</div>
</div>
<div class="form-field full">
<label for="quality_note">Quality note</label>
<textarea id="quality_note" name="quality_note" rows="4">{{ current_quality_note }}</textarea>
</div>
<div class="button-row">
<button class="primary" type="submit" id="save-reviewed-btn">Save reviewed OCR</button>
</div>
</form>
</div>
<div class="tab-panel{% if active_tab == 'layout-review' %} active{% endif %}" data-panel="layout-review">
{% if layout_review_image_url and layout_review_pages %}
<style>
#layout-review-toolbar {
display:flex;
flex-wrap:wrap;
gap:0.5rem;
align-items:center;
margin-bottom:0.75rem;
padding:0.85rem;
border:1px solid #ddd;
border-radius:18px;
background:#fff;
}
#layout-review-shell {
display:grid;
grid-template-columns:minmax(0,1fr) 320px;
gap:1rem;
align-items:start;
}
@media (max-width: 980px) {
#layout-review-shell {
grid-template-columns:1fr;
}
}
.layout-tool-btn {
border:1px solid #cbd5e1;
background:#fff;
color:#0f172a;
border-radius:999px;
padding:0.5rem 0.8rem;
font:inherit;
}
.layout-tool-btn.active {
background:#eff6ff;
border-color:#60a5fa;
color:#1d4ed8;
}
.layout-tool-btn.primary {
background:#2563eb;
border-color:#2563eb;
color:#fff;
}
.layout-tool-btn.danger,
#layout-delete-word-inline,
#layout-popover-delete {
background:#fee2e2;
border-color:#fecaca;
color:#991b1b;
}
#layout-review-status {
color:#475569;
font-size:0.95rem;
}
#layout-review-canvas-wrap {
position:relative;
width:100%;
min-height:900px;
border:1px solid #ddd;
border-radius:18px;
overflow:auto;
background:#fafafa;
touch-action:none;
-webkit-user-select:none;
user-select:none;
-webkit-touch-callout:none;
}
#layout-review-stage {
position:relative;
width:100%;
display:block;
}
#layout-review-image {
display:block;
width:100%;
height:auto;
max-width:none;
pointer-events:none;
-webkit-user-drag:none;
user-drag:none;
}
#layout-review-canvas {
position:absolute;
inset:0;
width:100%;
height:100%;
touch-action:none;
}
#layout-review-debug {
position:sticky;
left:8px;
bottom:8px;
z-index:6;
width:fit-content;
margin:8px;
background:rgba(0,0,0,0.78);
color:#fff;
padding:4px 8px;
border-radius:8px;
font-size:12px;
}
#layout-props-card {
border:1px solid #ddd;
border-radius:18px;
padding:0.9rem;
background:#fff;
}
.layout-field-grid {
display:grid;
grid-template-columns:1fr 1fr;
gap:0.5rem;
}
@media (max-width: 560px) {
.layout-field-grid {
grid-template-columns:1fr;
}
}
#layout-props-actions {
display:flex;
gap:0.5rem;
flex-wrap:wrap;
}
#layout-word-popover {
display:none;
position:absolute;
z-index:20;
min-width:240px;
max-width:300px;
background:#ffffff;
border:1px solid #cbd5e1;
border-radius:14px;
box-shadow:0 10px 30px rgba(15,23,42,0.16);
padding:0.7rem;
}
.layout-mini-grid {
display:grid;
grid-template-columns:1fr auto auto auto;
gap:0.35rem;
align-items:center;
}
</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>