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

1381 lines
71 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 == "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>
</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>
{% 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 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">
<div class="ocr-review-header-row">
<h2 class="card-title">Layout Review</h2>
<div style="font-size:0.95rem; color:#666;">Browser-only scaffold editor</div>
</div>
{% if layout_review_pages %}
<style>
@media (max-width: 900px) {
#layout-review-root { grid-template-columns: 1fr !important; }
#layout-review-canvas-wrap { min-height: 520px !important; }
}
#layout-review-stage, #layout-review-stage * {
-webkit-user-select: none !important;
user-select: none !important;
-webkit-touch-callout: none !important;
}
#layout-review-image {
display: block;
width: 100%;
height: auto;
pointer-events: none;
-webkit-user-drag: none;
user-drag: none;
}
#layout-review-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: auto;
touch-action: none;
display: block;
z-index: 5;
background: transparent;
}
#layout-review-debug {
position: absolute;
left: 8px;
bottom: 8px;
z-index: 6;
background: rgba(0,0,0,0.75);
color: #fff;
padding: 4px 8px;
border-radius: 8px;
font-size: 12px;
}
</style>
<div id="layout-review-root" style="display:grid; grid-template-columns:minmax(0,1fr) 320px; gap:1rem; align-items:start;">
<div>
<div id="layout-review-canvas-wrap" style="position:relative; width:100%; min-height:900px; border:1px solid #ddd; border-radius:12px; overflow:hidden; background:#fafafa; -webkit-user-select:none; user-select:none; -webkit-touch-callout:none;" oncontextmenu="return false;">
{% if layout_review_image_url %}
<div id="layout-review-stage" style="position:relative; width:100%; display:block;">
<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>
{% endif %}
</div>
</div>
<div style="border:1px solid #ddd; border-radius:12px; padding:0.9rem; background:#fff;">
<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 style="display:grid; grid-template-columns:1fr 1fr; gap:0.5rem;">
<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 style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<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>
</div>
<div style="font-size:0.9rem; color:#666;">
Apply changes updates the layout editor only. Save layout review persists layout. Save reviewed OCR persists text and marks layout for review.
</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">
<button type="submit" class="primary">Save layout review</button>
</form>
<script id="layout-review-data" type="application/json">{{ layout_review_pages|tojson }}</script>
<script>
(function () {
const dataTag = document.getElementById("layout-review-data");
const canvas = document.getElementById("layout-review-canvas");
const image = document.getElementById("layout-review-image");
const debugEl = document.getElementById("layout-review-debug");
const idInput = document.getElementById("layout-word-id");
const textInput = document.getElementById("layout-word-text");
const x1Input = document.getElementById("layout-x1");
const y1Input = document.getElementById("layout-y1");
const x2Input = document.getElementById("layout-x2");
const y2Input = document.getElementById("layout-y2");
function debug(msg) {
if (debugEl) debugEl.textContent = msg;
try { console.log("[layout-review]", msg); } catch (e) {}
}
if (!dataTag || !canvas || !image) {
debug("missing-elements");
return;
}
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;
const ctx = canvas.getContext("2d");
if (!ctx) {
debug("no-ctx");
return;
}
function getSelectedWord() {
return words.find(w => String(w.id) === String(selectedId)) || null;
}
function syncEditor() {
const w = getSelectedWord();
if (!w) {
if (idInput) idInput.value = "";
if (textInput) textInput.value = "";
if (x1Input) x1Input.value = "";
if (y1Input) y1Input.value = "";
if (x2Input) x2Input.value = "";
if (y2Input) y2Input.value = "";
return;
}
if (idInput) idInput.value = w.id;
if (textInput) textInput.value = w.text || "";
if (x1Input) x1Input.value = w.bbox[0];
if (y1Input) y1Input.value = w.bbox[1];
if (x2Input) x2Input.value = w.bbox[2];
if (y2Input) y2Input.value = w.bbox[3];
}
function sizeCanvasToImage() {
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 renderCanvas() {
const sized = sizeCanvasToImage();
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 = word.bbox || [0, 0, 0, 0];
const x1 = Number(bbox[0] || 0) * scaleX;
const y1 = Number(bbox[1] || 0) * scaleY;
const x2 = Number(bbox[2] || 0) * scaleX;
const y2 = Number(bbox[3] || 0) * 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.95)" : "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);
ctx.restore();
}
debug("render words=" + words.length);
}
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 = words[i].bbox || [0, 0, 0, 0];
const x1 = Number(bbox[0] || 0);
const y1 = Number(bbox[1] || 0);
const x2 = Number(bbox[2] || 0);
const y2 = Number(bbox[3] || 0);
if (px >= x1 && px <= x2 && py >= y1 && py <= y2) return words[i];
}
return null;
}
function applyEditorValues() {
const w = getSelectedWord();
if (!w) return;
w.text = textInput ? textInput.value : w.text;
w.bbox = [
Number(x1Input ? x1Input.value : 0),
Number(y1Input ? y1Input.value : 0),
Number(x2Input ? x2Input.value : 0),
Number(y2Input ? y2Input.value : 0),
];
renderCanvas();
}
function nudge(dx, dy) {
const w = getSelectedWord();
if (!w) return;
w.bbox = [
Number(w.bbox[0]) + dx,
Number(w.bbox[1]) + dy,
Number(w.bbox[2]) + dx,
Number(w.bbox[3]) + dy,
];
syncEditor();
renderCanvas();
}
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) => ({
id: Number(w.id || (idx + 1)),
text: w.text || "",
bbox: [
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),
],
})),
}],
});
}
function handlePick(ev) {
ev.preventDefault();
const point = (ev.touches && ev.touches.length) ? ev.touches[0] : ev;
const hit = pickWord(point.clientX, point.clientY);
if (!hit) {
debug("pick-miss");
return;
}
selectedId = hit.id;
syncEditor();
renderCanvas();
debug("picked " + hit.id);
}
["contextmenu", "selectstart", "dragstart", "touchstart", "touchend", "mousedown"].forEach((evt) => {
canvas.addEventListener(evt, (e) => { e.preventDefault(); }, { passive: false });
image.addEventListener(evt, (e) => { e.preventDefault(); }, { passive: false });
});
canvas.addEventListener("pointerdown", handlePick, { passive: false });
canvas.addEventListener("touchstart", handlePick, { passive: false });
document.getElementById("layout-apply-word")?.addEventListener("click", applyEditorValues);
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-review-save-form")?.addEventListener("submit", function () {
applyEditorValues();
const hidden = document.getElementById("layout-review-json");
if (hidden) {
hidden.value = buildLayoutReviewPayload();
}
});
window.addEventListener("resize", renderCanvas);
image.addEventListener("load", renderCanvas);
syncEditor();
renderCanvas();
setTimeout(renderCanvas, 300);
setTimeout(renderCanvas, 1000);
})();
</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>