"use strict"; const $ = id => document.getElementById(id); const input = $("image-file"), original = $("original"), cornersCanvas = $("corners"); const processed = $("processed"), evidenceCanvas = $("evidence"); const run = $("run"), status = $("status"), rotation = $("rotation"), finePrint = $("fine-print"); const initialCorners = () => [[0, 0], [1, 0], [1, 1], [0, 1]]; let corners = initialCorners(), edited = false, busy = false, ready = false; let objectUrl = null, drag = null, result = null, selectedEvidence = null; const manual = {classic: new Map(), vision: new Map()}; const suppressed = {classic: new Set(), vision: new Set()}; const priorityCodes = ["C.1.1", "C.1.2", "C.1.3", "B", "2.1", "2.2"]; const reviewMethods = new Set(["merged_code", "vl_table_context_code", "caption_region_next_line", "surname_above_forename"]); const fieldNames = {"A":"Kennzeichen","C.1.1":"Name / Firmenname","C.1.2":"Vorname", "C.1.3":"Anschrift","B":"Erstzulassung","2.1":"Herstellerschlüssel (HSN)", "2.2":"Typschlüssel (TSN)","2":"Hersteller","D.1":"Marke","D.2":"Typ / Variante", "D.3":"Handelsbezeichnung","E":"Fahrzeug-Identifizierungsnummer","P.3":"Kraftstoffart", "R":"Farbe","17":"Merkmal zur Betriebserlaubnis"}; function setStatus(message, kind = "") { status.textContent = message; status.className = `status ${kind}`; } function validCorners() { if (!ready) return false; if (!edited) return true; const w = original.naturalWidth, h = original.naturalHeight; const points = corners.map(([x, y]) => [x * w, y * h]); const edges = points.map((p, i) => [points[(i + 1) % 4][0] - p[0], points[(i + 1) % 4][1] - p[1]]); const crosses = edges.map((a, i) => a[0] * edges[(i + 1) % 4][1] - a[1] * edges[(i + 1) % 4][0]); const area = Math.abs(points.reduce((sum, p, i) => sum + p[0] * points[(i + 1) % 4][1] - p[1] * points[(i + 1) % 4][0], 0)) / 2; return crosses.every(value => value > 0) && area > .02 * w * h && edges.every(([x, y]) => Math.hypot(x, y) >= 30); } function updateControls() { input.disabled = busy; $("reset-corners").disabled = !ready || busy; rotation.disabled = busy; finePrint.disabled = busy; run.disabled = busy || !validCorners(); } function drawCorners() { const w = original.naturalWidth, h = original.naturalHeight; if (!w || !h) return; cornersCanvas.width = w; cornersCanvas.height = h; const ctx = cornersCanvas.getContext("2d"); const pts = corners.map(([x, y]) => [x * (w - 1), y * (h - 1)]); ctx.strokeStyle = "#1459dd"; ctx.lineWidth = Math.max(5, w / 550); ctx.beginPath(); pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath();ctx.stroke(); const radius = Math.max(25, w / 110); pts.forEach(([x, y], i) => { ctx.beginPath();ctx.arc(x, y, radius, 0, 2 * Math.PI); ctx.fillStyle = "#fff";ctx.fill();ctx.stroke(); ctx.fillStyle = "#1547a5";ctx.font = `bold ${Math.max(22, w / 125)}px system-ui`; ctx.fillText(String(i + 1), Math.min(w - radius, x + radius * .9), Math.max(radius, y - radius * .65)); }); } function pointInCanvas(event) { const b = cornersCanvas.getBoundingClientRect(); return [(event.clientX - b.left) / b.width, (event.clientY - b.top) / b.height]; } cornersCanvas.addEventListener("pointerdown", event => { if (!ready || busy) return; const b = cornersCanvas.getBoundingClientRect(); const [x, y] = pointInCanvas(event); const distances = corners.map(([cx, cy]) => Math.hypot((cx - x) * b.width, (cy - y) * b.height)); const index = distances.indexOf(Math.min(...distances)); if (distances[index] > 32) return; drag = index; cornersCanvas.setPointerCapture(event.pointerId); event.preventDefault(); }); cornersCanvas.addEventListener("pointermove", event => { if (drag === null || busy) return; const [x, y] = pointInCanvas(event); corners[drag] = [Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y))]; edited = true; result = null; $("comparison").hidden = true; drawCorners();updateControls(); setStatus(validCorners() ? "Ecken geändert. Bitte beide Verfahren neu starten." : "Ecken müssen ein nicht gekreuztes Viereck um das Dokument bilden.", validCorners() ? "" : "error"); }); function finishDrag() {drag = null;} cornersCanvas.addEventListener("pointerup", finishDrag); cornersCanvas.addEventListener("pointercancel", finishDrag); $("reset-corners").addEventListener("click", () => { corners = initialCorners(); edited = false; result = null; $("comparison").hidden = true;drawCorners();updateControls();setStatus("Ganzes Bild ausgewählt."); }); rotation.addEventListener("change", () => { result = null;$("comparison").hidden = true;setStatus("Leserichtung geändert. Bitte erneut vergleichen."); }); finePrint.addEventListener("change", () => { result = null;$("comparison").hidden = true; setStatus("OCR-Detailgrad geändert. Bitte erneut vergleichen."); }); input.addEventListener("change", () => { if (objectUrl) URL.revokeObjectURL(objectUrl); objectUrl = null; ready = false; edited = false; corners = initialCorners(); drag = null; result = null; selectedEvidence = null; manual.classic.clear();manual.vision.clear(); suppressed.classic.clear();suppressed.vision.clear(); $("comparison").hidden = true;rotation.value = "0"; const file = input.files?.[0]; $("original-wrap").hidden = !file; $("file-name").textContent = file ? `${file.name} · ${(file.size / 1048576).toFixed(2)} MiB` : "JPEG oder PNG, maximal 16 MiB."; if (file) {objectUrl = URL.createObjectURL(file);original.src = objectUrl;} else original.removeAttribute("src"); setStatus("");updateControls(); }); original.addEventListener("load", () => { ready = true;drawCorners();updateControls(); $("file-name").textContent += ` · ${original.naturalWidth} × ${original.naturalHeight} Pixel`; }); original.addEventListener("error", () => { ready = false;updateControls();setStatus("Das Bild kann nicht geöffnet werden. Bitte JPEG oder PNG verwenden.", "error"); }); window.addEventListener("pagehide", () => {if (objectUrl) URL.revokeObjectURL(objectUrl);}); const errorText = { busy: "Ein Vergleich läuft bereits.", unsupported_media_type: "Nur JPEG und PNG sind erlaubt.", upload_too_large: "Das Bild ist größer als 16 MiB.", upload_timeout: "Der Upload hat zu lange gedauert.", invalid_image: "Die Bilddatei lässt sich nicht vollständig lesen.", unsupported_encoding: "Der Bildinhalt ist kein unterstütztes JPEG oder PNG.", image_too_large: "Die Auflösung überschreitet 24 Megapixel oder 10.000 Pixel pro Seite.", invalid_corners: "Die vier Ecken bilden kein gültiges Dokumentviereck.", invalid_rotation: "Die gewählte Drehung ist ungültig.", invalid_detail: "Der OCR-Detailgrad ist ungültig.", classic_timeout: "Die klassische OCR hat das Zeitlimit überschritten.", classic_models_missing: "Die lokalen OCR-Modelle fehlen. Zuerst die Modell-Einrichtung aus der README ausführen.", classic_failed: "Die klassische OCR ist lokal fehlgeschlagen.", classic_invalid: "Die klassische OCR hat ein ungültiges Ergebnis geliefert.", vision_timeout: "PaddleOCR-VL hat das Zeitlimit überschritten.", vision_failed: "PaddleOCR-VL konnte auf der konfigurierten eigenen Hardware nicht ausgeführt werden.", vision_disabled: "Vision ist in dieser Installation deaktiviert.", vision_config_error: "Die Vision-Konfiguration ist unvollständig oder ungültig.", vision_models_missing: "Die lokalen Vision-Modelle fehlen. Zuerst die Modell-Einrichtung aus der README ausführen.", vision_invalid: "PaddleOCR-VL hat ein ungültiges Ergebnis geliefert.", }; function element(tag, value, className = "") { const node = document.createElement(tag); node.textContent = value; if (className) node.className = className; return node; } function drawEvidence() { if (!result || !processed.naturalWidth) return; const w = processed.naturalWidth, h = processed.naturalHeight; evidenceCanvas.width = w;evidenceCanvas.height = h; const ctx = evidenceCanvas.getContext("2d"); if ($( "all-boxes" ).checked) { for (const [engine, branch] of Object.entries(result.results)) { if (branch.status !== "ok") continue; ctx.strokeStyle = engine === "classic" ? "#008778" : "#425edf"; ctx.lineWidth = Math.max(2, w / 900); for (const line of branch.lines) if (line.box) { const [x1, y1, x2, y2] = line.box; ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); } } } if (selectedEvidence?.anchor && selectedEvidence.anchor.join() !== selectedEvidence.box.join()) { const [x1, y1, x2, y2] = selectedEvidence.anchor; ctx.strokeStyle = "#d88614";ctx.fillStyle = "rgba(216,134,20,.13)"; ctx.lineWidth = Math.max(4, w / 400); ctx.fillRect(x1, y1, x2 - x1, y2 - y1); ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); } if (selectedEvidence?.box) { const [x1, y1, x2, y2] = selectedEvidence.box; ctx.strokeStyle = "#d72b4a";ctx.fillStyle = "rgba(215,43,74,.16)"; ctx.lineWidth = Math.max(4, w / 400); ctx.fillRect(x1, y1, x2 - x1, y2 - y1); ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); } } function focusEvidence(box, anchor = null) { if (!box) return; selectedEvidence = {box, anchor};drawEvidence(); $("comparison").scrollIntoView({behavior:"smooth",block:"start"}); } processed.addEventListener("load", drawEvidence); $("all-boxes").addEventListener("change", drawEvidence); function renderCard(engine) { const card = $(`${engine}-card`), branch = result.results[engine]; card.replaceChildren(element("h3", engine === "classic" ? "A · Klassische OCR" : "B · Vision-Dokumentmodell")); if (branch.status !== "ok") { card.append(element("p", errorText[branch.error] || "Dieses Verfahren ist fehlgeschlagen.", "error"), element("p", `${(branch.elapsed_ms / 1000).toFixed(1)} s`, "meta")); return; } const automatic = branch.fields.filter(field => !suppressed[engine].has(field.code)); card.append(element("p", `${branch.model} · ${(branch.elapsed_ms / 1000).toFixed(1)} s · ${branch.lines.length} Textbereiche · ${automatic.length} automatische Feldvorschläge`, "meta")); if (branch.layout_note) card.append(element("p", branch.layout_note, "note")); card.append(element("p", "Die Zuordnung ist ein Vorschlag. Orange markiert den gelesenen Feldcode, Rot den Wert. Falsche Vorschläge lassen sich verwerfen; der Rohtext bleibt erhalten.", "assignment-note")); card.append(element("h4", "Strukturierte Feldvorschläge")); const merged = new Map(automatic.map(field => [field.code, field])); for (const [code, field] of manual[engine]) merged.set(code, field); const priority = element("div", "", "priority-fields"); priority.append(element("h5", "Wichtige Angaben")); for (const code of priorityCodes) { const field = merged.get(code); priority.append(element("span", `${code} ${fieldNames[code]}: ${field ? (field.manual ? "manuell" : reviewMethods.has(field.method) ? "prüfen" : "Vorschlag") : "offen"}`, `priority-item${field ? "" : " missing"}`)); } card.append(priority); if (!merged.size) card.append(element("p", "Keine sicher belegbaren Felder. Rohtext prüfen und bei Bedarf manuell zuordnen.", "meta")); const fieldList = element("div", "", "field-list"); for (const field of merged.values()) { const row = element("div", "", `field-item${reviewMethods.has(field.method) ? " needs-review" : ""}`); const show = element("button", "", "field-show");show.type = "button"; const heading = element("span", "", "field-heading"); heading.append(element("strong", field.code), element("small", fieldNames[field.code] || "Feldcode")); const method = field.manual ? "Manuell aus OCR-Text" : field.method === "adjacent_code" ? "Code und Wert in Nachbarboxen" : field.method === "below_caption" ? "Wert unter gedruckter Beschriftung" : field.method === "caption_region_next_line" ? "Nächste Textzeile derselben Layoutregion · bitte prüfen" : field.method === "surname_above_forename" ? "C.1.1-Code nicht gelesen · Wert oberhalb von C.1.2 · bitte prüfen" : field.method === "vl_table_adjacent_cell" ? "Benachbarte Modellzellen · grober Tabellenbeleg" : field.method === "vl_table_context_code" ? "Punkt im Feldcode fehlt · Tabellenfolge B/2.1/2.2 · bitte prüfen" : field.method === "merged_code" ? "Code und Wert verschmolzen · bitte prüfen" : "Code und Wert in einer Textzeile"; show.append(heading, element("span", field.value, "field-value"), element("small", method, "field-method")); show.disabled = !field.box; show.addEventListener("click", () => focusEvidence(field.box, field.anchor_box)); const dismiss = element("button", field.manual ? "Entfernen" : "Verwerfen", "field-dismiss"); dismiss.type = "button"; dismiss.setAttribute("aria-label", `${field.code} ${field.manual ? "entfernen" : "verwerfen"}`); dismiss.addEventListener("click", () => { if (field.manual) manual[engine].delete(field.code); else suppressed[engine].add(field.code); renderCard(engine); }); row.append(show, dismiss); fieldList.append(row); } card.append(fieldList, element("h4", "Rohtext mit Bildbelegen")); if (!branch.lines.length) card.append(element("p", "Kein Text erkannt.", "meta")); const raw = element("div", "", "raw-lines"); let code, lineSelect, value, form; branch.lines.forEach((line, index) => { const entry = element("div", "", "raw-entry"); const row = element("button", "", "raw-line");row.type = "button"; row.append(element("span", `${index + 1}.`), element("span", line.text)); row.disabled = !line.box; row.title = line.source || "Bildbereich markieren"; row.addEventListener("click", () => { lineSelect.value = String(index);value.value = line.text; focusEvidence(line.box); }); const pick = element("button", "Zuordnen", "raw-pick");pick.type = "button"; pick.disabled = !line.box; pick.addEventListener("click", () => { lineSelect.value = String(index);value.value = line.text; selectedEvidence = {box:line.box,anchor:null};drawEvidence(); form.scrollIntoView({behavior:"smooth",block:"center"});code.focus(); }); entry.append(row,pick);raw.append(entry); }); card.append(raw); if (!branch.lines.length) return; card.append(element("h4", "Zeile manuell zuordnen")); form = element("div", "", "manual"); code = document.createElement("select");lineSelect = document.createElement("select"); code.setAttribute("aria-label", "Feldcode");lineSelect.setAttribute("aria-label", "OCR-Zeile"); code.append(new Option("Feldcode", "")); result.field_codes.forEach(fieldCode => code.append(new Option(`${fieldCode} · ${fieldNames[fieldCode] || "Feldcode"}`, fieldCode))); lineSelect.append(new Option("OCR-Zeile wählen", "")); branch.lines.forEach((row, index) => lineSelect.append(new Option(`${index + 1}: ${row.text.slice(0, 55)}`, String(index)))); value = document.createElement("input");value.type = "text";value.maxLength = 500; value.setAttribute("aria-label", "Wörtlicher Ausschnitt aus der OCR-Zeile"); value.placeholder = "OCR-Zeile wählen, dann bei Bedarf auf den Wert kürzen"; lineSelect.addEventListener("change", () => {value.value = lineSelect.value === "" ? "" : branch.lines[Number(lineSelect.value)].text;}); const add = element("button", "Zuordnen");add.type = "button"; const hint = element("p", "Nur wörtlich erkannter Text mit Bildbereich ist zulässig.", "manual-hint"); add.addEventListener("click", () => { const row = lineSelect.value === "" ? null : branch.lines[Number(lineSelect.value)]; const chosen = value.value.trim(); if (!code.value || !chosen || !row?.box || !row.text.includes(chosen)) { hint.textContent = "Feldcode und wörtlichen Ausschnitt aus der gewählten Zeile angeben."; hint.className = "manual-hint error";return; } manual[engine].set(code.value, {code:code.value,value:chosen,box:row.box,manual:true}); suppressed[engine].delete(code.value); renderCard(engine); }); const clear = element("button", "Manuelle Zuordnungen löschen");clear.type = "button"; clear.addEventListener("click", () => {manual[engine].clear();renderCard(engine);}); form.append(code,lineSelect,value,add,clear,hint);card.append(form); } run.addEventListener("click", async () => { const file = input.files?.[0]; if (!file || !validCorners() || busy) return; if (file.size > 16 * 1048576) {setStatus(errorText.upload_too_large, "error");return;} const mime = file.type || (/\.jpe?g$/i.test(file.name) ? "image/jpeg" : /\.png$/i.test(file.name) ? "image/png" : ""); if (!["image/jpeg", "image/png"].includes(mime)) {setStatus(errorText.unsupported_media_type, "error");return;} busy = true;updateControls();result = null;selectedEvidence = null; manual.classic.clear();manual.vision.clear();$("comparison").hidden = true; suppressed.classic.clear();suppressed.vision.clear(); setStatus("Die lokalen Verfahren verarbeiten dasselbe Bild …"); try { const geometry = {rotation:Number(rotation.value)}; if (edited) geometry.corners = corners; const response = await fetch("/api/compare", {method:"POST",body:file, headers:{"Content-Type":mime,"X-OCR-Geometry":JSON.stringify(geometry), "X-OCR-Detail":finePrint.checked ? "fine" : "standard"},cache:"no-store"}); const data = await response.json(); if (!response.ok) {setStatus(errorText[data.error] || `Vergleich fehlgeschlagen (${response.status}).`,"error");return;} result = data;processed.src = data.image; $("image-size").textContent = `Gemeinsames OCR-Bild: ${data.image_size.width} × ${data.image_size.height} Pixel. Klick auf Wert oder Rohtext markiert seinen Bildbeleg.`; renderCard("classic");renderCard("vision");$("comparison").hidden = false; const both = Object.values(data.results).every(branch => branch.status === "ok"); setStatus(both ? "Beide Ergebnisse bereit. Feldvorschläge anhand der Bildbelege prüfen." : "Ein Verfahren ist fehlgeschlagen; das andere Ergebnis und der Fehler sind sichtbar.",both ? "ok" : "error"); } catch { setStatus(result ? "Die Antwort des lokalen Vergleichs konnte nicht angezeigt werden. Seite neu laden." : "Der lokale Dienst antwortet nicht. Prozess und /api/health prüfen.","error"); } finally {busy = false;updateControls();} });