import * as pdfjsLib from "pdfjs-dist"; import type { PDFDocumentProxy, PageViewport } from "pdfjs-dist"; pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.mjs"; // ─── Types ──────────────────────────────────────────────────────────────────── type AntwortFormat = "einzelfrage" | "mehrfachfrage_ein_antwortfeld"; interface FrageVorlage { id: string; text: string; } interface FieldEntry { id: string; fieldName: string; // PDF AcroForm field name, or user-defined for manual jsonKey: string; // Target nested JSON path rect: [number, number, number, number]; // PDF user-space [x1, y1, x2, y2] page: number; // 1-indexed type: "acroform" | "manual"; label?: string; antwortFormat?: AntwortFormat; fragen?: FrageVorlage[]; } // ─── State ──────────────────────────────────────────────────────────────────── let pdfDoc: PDFDocumentProxy | null = null; let currentPage = 1; let viewport: PageViewport | null = null; let fields: FieldEntry[] = []; let selectedFieldId: string | null = null; let isDrawMode = false; let drawStart: { x: number; y: number } | null = null; let pendingManualRect: [number, number, number, number] | null = null; let currentFilename = ""; // ─── DOM refs ───────────────────────────────────────────────────────────────── const fileInput = q("#file-input"); const uploadBtn = q("#upload-btn"); const drawBtn = q("#draw-btn"); const saveBtn = q("#save-btn"); const prevPageBtn = q("#prev-page"); const nextPageBtn = q("#next-page"); const pageInfo = q("#page-info"); const fieldList = q("#field-list"); const fieldCount = q("#field-count"); const filenameDisp = q("#filename-display"); const uploadPrompt = q("#upload-prompt"); const canvases = q("#canvases"); const pdfCanvas = q("#pdf-canvas"); const overlayCanvas = q("#overlay-canvas"); // Key panel const keyPanel = q("#key-panel"); const kpFieldName = q("#kp-field-name"); const kpTypeBadge = q("#kp-type-badge"); const kpNameRow = q("#kp-name-row"); const kpFieldId = q("#kp-field-id"); const kpJsonKey = q("#kp-json-key"); const kpLabel = q("#kp-label"); const kpAntwortFormat = q("#kp-antwort-format"); const kpFragen = q("#kp-fragen"); const kpAssign = q("#kp-assign"); const kpCancel = q("#kp-cancel"); const kpRemove = q("#kp-remove"); function q(sel: string): T { return document.querySelector(sel) as T; } function csrfHeaders(extra: Record = {}): Record { const csrf = document.cookie .split("; ") .find((part) => part.startsWith("bmp_demo_csrf=")) ?.slice("bmp_demo_csrf=".length); return csrf ? { ...extra, "X-BMP-CSRF": decodeURIComponent(csrf) } : extra; } // ─── PDF Loading ────────────────────────────────────────────────────────────── uploadBtn.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", async () => { const file = fileInput.files?.[0]; if (file) await loadPDF(file); }); async function loadPDF(file: File) { currentFilename = file.name; filenameDisp.textContent = file.name; selectedFieldId = null; hideKeyPanel(); const buf = await file.arrayBuffer(); pdfDoc = await pdfjsLib.getDocument({ data: buf }).promise; const acroFields = await extractAcroFields(pdfDoc); fields = mergeWithSaved(file.name, acroFields); currentPage = 1; uploadPrompt.style.display = "none"; canvases.style.display = "inline-block"; drawBtn.disabled = false; saveBtn.disabled = false; await renderPage(1); renderFieldList(); } async function extractAcroFields(doc: PDFDocumentProxy): Promise { const result: FieldEntry[] = []; const seen = new Set(); for (let p = 1; p <= doc.numPages; p++) { const page = await doc.getPage(p); const annotations = await page.getAnnotations(); for (const ann of annotations) { if (ann.subtype === "Widget" && ann.fieldName) { const key = `${ann.fieldName}::p${p}`; if (!seen.has(key)) { seen.add(key); result.push({ id: `acro-p${p}-${ann.id ?? ann.fieldName}`, fieldName: ann.fieldName, jsonKey: "", rect: ann.rect as [number, number, number, number], page: p, type: "acroform", }); } } } } return result; } function mergeWithSaved(filename: string, acroFields: FieldEntry[]): FieldEntry[] { const raw = localStorage.getItem(`tb:${filename}`); if (!raw) return acroFields; const saved: FieldEntry[] = JSON.parse(raw); const savedByKey = new Map(saved.map(f => [`${f.fieldName}::p${f.page}`, f])); const merged = acroFields.map(f => { const s = savedByKey.get(`${f.fieldName}::p${f.page}`); return s ? { ...f, jsonKey: s.jsonKey, label: s.label, antwortFormat: s.antwortFormat, fragen: s.fragen, } : f; }); const manuals = saved.filter(f => f.type === "manual"); return [...merged, ...manuals]; } // ─── Page Rendering ─────────────────────────────────────────────────────────── async function renderPage(pageNum: number) { if (!pdfDoc) return; const page = await pdfDoc.getPage(pageNum); viewport = page.getViewport({ scale: 1.5 }); pdfCanvas.width = viewport.width; pdfCanvas.height = viewport.height; overlayCanvas.width = viewport.width; overlayCanvas.height = viewport.height; const ctx = pdfCanvas.getContext("2d")!; await page.render({ canvasContext: ctx, viewport, canvas: pdfCanvas }).promise; renderOverlay(); updatePagination(); } function updatePagination() { const total = pdfDoc?.numPages ?? 0; pageInfo.textContent = total ? `Page ${currentPage} of ${total}` : "—"; prevPageBtn.disabled = currentPage <= 1; nextPageBtn.disabled = !total || currentPage >= total; } prevPageBtn.addEventListener("click", async () => { if (currentPage > 1) { currentPage--; selectedFieldId = null; hideKeyPanel(); await renderPage(currentPage); } }); nextPageBtn.addEventListener("click", async () => { if (pdfDoc && currentPage < pdfDoc.numPages) { currentPage++; selectedFieldId = null; hideKeyPanel(); await renderPage(currentPage); } }); // ─── Overlay ───────────────────────────────────────────────────────────────── function renderOverlay(drawPreview?: { x: number; y: number; w: number; h: number }) { if (!viewport) return; const ctx = overlayCanvas.getContext("2d")!; ctx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); for (const field of fields.filter(f => f.page === currentPage)) { const [l, t, w, h] = rectToCanvas(field.rect, viewport!); const sel = field.id === selectedFieldId; const mapped = field.jsonKey.trim() !== ""; if (sel) { ctx.fillStyle = "rgba(37,99,235,0.12)"; ctx.fillRect(l, t, w, h); ctx.strokeStyle = "#2563eb"; ctx.lineWidth = 2; } else { ctx.strokeStyle = mapped ? "#16a34a" : "#ea580c"; ctx.lineWidth = 1.5; } ctx.strokeRect(l, t, w, h); // Label const label = field.jsonKey || field.fieldName; ctx.font = "10px monospace"; ctx.fillStyle = sel ? "#2563eb" : mapped ? "#16a34a" : "#ea580c"; const labelY = t > 14 ? t - 3 : t + h + 11; ctx.fillText(label, l + 2, labelY); } if (drawPreview) { ctx.strokeStyle = "#2563eb"; ctx.lineWidth = 2; ctx.setLineDash([5, 4]); ctx.strokeRect(drawPreview.x, drawPreview.y, drawPreview.w, drawPreview.h); ctx.setLineDash([]); } } function rectToCanvas( rect: [number, number, number, number], vp: PageViewport, ): [number, number, number, number] { const [cx1, cy1, cx2, cy2] = vp.convertToViewportRectangle(rect); const l = Math.min(cx1, cx2); const t = Math.min(cy1, cy2); return [l, t, Math.abs(cx2 - cx1), Math.abs(cy2 - cy1)]; } function canvasToRect( x1: number, y1: number, x2: number, y2: number, vp: PageViewport, ): [number, number, number, number] { const [a, b, c, d, e, f] = vp.transform as [number, number, number, number, number, number]; const det = a * d - b * c; function inv(vx: number, vy: number): [number, number] { return [ (d * vx - c * vy + (c * f - d * e)) / det, (-b * vx + a * vy + (b * e - a * f)) / det, ]; } const [px1, py1] = inv(x1, y1); const [px2, py2] = inv(x2, y2); return [ Math.min(px1, px2), Math.min(py1, py2), Math.max(px1, px2), Math.max(py1, py2), ]; } // ─── Mouse on overlay ──────────────────────────────────────────────────────── function canvasPos(e: MouseEvent) { const r = overlayCanvas.getBoundingClientRect(); return { x: (e.clientX - r.left) * (overlayCanvas.width / r.width), y: (e.clientY - r.top) * (overlayCanvas.height / r.height), }; } overlayCanvas.addEventListener("click", (e) => { if (isDrawMode || !viewport) return; const { x, y } = canvasPos(e); for (const field of fields.filter(f => f.page === currentPage)) { const [l, t, w, h] = rectToCanvas(field.rect, viewport!); if (x >= l && x <= l + w && y >= t && y <= t + h) { selectField(field.id); return; } } selectedFieldId = null; renderOverlay(); hideKeyPanel(); }); overlayCanvas.addEventListener("mousedown", (e) => { if (!isDrawMode) return; drawStart = canvasPos(e); }); overlayCanvas.addEventListener("mousemove", (e) => { if (!isDrawMode || !drawStart) return; const { x, y } = canvasPos(e); renderOverlay({ x: Math.min(drawStart.x, x), y: Math.min(drawStart.y, y), w: Math.abs(x - drawStart.x), h: Math.abs(y - drawStart.y), }); }); overlayCanvas.addEventListener("mouseup", (e) => { if (!isDrawMode || !drawStart || !viewport) return; const { x, y } = canvasPos(e); const dx = Math.abs(x - drawStart.x); const dy = Math.abs(y - drawStart.y); if (dx > 8 && dy > 8) { pendingManualRect = canvasToRect( Math.min(drawStart.x, x), Math.min(drawStart.y, y), Math.max(drawStart.x, x), Math.max(drawStart.y, y), viewport, ); exitDrawMode(); showNewManualPanel(); } else { drawStart = null; renderOverlay(); } drawStart = null; }); // ─── Draw mode ──────────────────────────────────────────────────────────────── drawBtn.addEventListener("click", () => { if (isDrawMode) exitDrawMode(); else enterDrawMode(); }); function enterDrawMode() { isDrawMode = true; drawBtn.textContent = "Cancel Draw"; drawBtn.classList.add("active"); overlayCanvas.style.cursor = "crosshair"; hideKeyPanel(); } function exitDrawMode() { isDrawMode = false; drawBtn.textContent = "Draw Field"; drawBtn.classList.remove("active"); overlayCanvas.style.cursor = "default"; drawStart = null; renderOverlay(); } // ─── Field selection ───────────────────────────────────────────────────────── function selectField(id: string) { selectedFieldId = id; renderOverlay(); const field = fields.find(f => f.id === id)!; kpFieldName.textContent = field.fieldName; kpTypeBadge.textContent = field.type === "acroform" ? "AcroForm" : "Manual"; kpTypeBadge.className = `kp-badge kp-badge-${field.type}`; kpJsonKey.value = field.jsonKey; kpLabel.value = field.label ?? ""; kpAntwortFormat.value = field.antwortFormat ?? "einzelfrage"; kpFragen.value = (field.fragen ?? []).map((frage) => frage.text).join("\n"); kpNameRow.classList.add("hidden"); kpRemove.style.display = field.type === "manual" ? "" : "none"; keyPanel.classList.remove("hidden"); kpJsonKey.focus(); // Sync sidebar highlight document.querySelectorAll(".field-item").forEach(el => el.classList.toggle("selected", el.getAttribute("data-id") === id)); document.querySelector(`.field-item[data-id="${id}"]`) ?.scrollIntoView({ block: "nearest" }); } function showNewManualPanel() { kpFieldName.textContent = "(new manual field)"; kpTypeBadge.textContent = "Manual"; kpTypeBadge.className = "kp-badge kp-badge-manual"; kpJsonKey.value = ""; kpLabel.value = ""; kpAntwortFormat.value = "einzelfrage"; kpFragen.value = ""; kpNameRow.classList.remove("hidden"); kpFieldId.value = ""; kpRemove.style.display = "none"; keyPanel.classList.remove("hidden"); kpFieldId.focus(); } function hideKeyPanel() { keyPanel.classList.add("hidden"); pendingManualRect = null; } // ─── Key panel actions ──────────────────────────────────────────────────────── function normalizeJsonKey(jsonKey: string): string { return jsonKey.replace(/\.antwort$/, "").trim(); } function parseFragen(lines: string, jsonKey: string): FrageVorlage[] { const clean = lines .split("\n") .map((line) => line.trim()) .filter(Boolean); const base = jsonKey.split(".").filter(Boolean).at(-1) || "frage"; return clean.map((text, index) => ({ id: `${base}_${index + 1}`, text, })); } kpAssign.addEventListener("click", () => { const jsonKey = normalizeJsonKey(kpJsonKey.value); const label = kpLabel.value.trim(); const antwortFormat = kpAntwortFormat.value as AntwortFormat; const fragen = parseFragen(kpFragen.value, jsonKey); kpJsonKey.value = jsonKey; if (pendingManualRect) { const name = kpFieldId.value.trim(); if (!name) { kpFieldId.focus(); return; } const entry: FieldEntry = { id: `manual-${Date.now()}`, fieldName: name, jsonKey, rect: pendingManualRect, page: currentPage, type: "manual", label: label || undefined, antwortFormat, fragen, }; fields.push(entry); selectedFieldId = entry.id; pendingManualRect = null; } else if (selectedFieldId) { const field = fields.find(f => f.id === selectedFieldId); if (field) { field.jsonKey = jsonKey; field.label = label || undefined; field.antwortFormat = antwortFormat; field.fragen = fragen; } } persist(); hideKeyPanel(); renderOverlay(); renderFieldList(); }); kpCancel.addEventListener("click", () => { pendingManualRect = null; selectedFieldId = null; hideKeyPanel(); renderOverlay(); }); kpRemove.addEventListener("click", () => { if (!selectedFieldId) return; fields = fields.filter(f => f.id !== selectedFieldId); selectedFieldId = null; persist(); hideKeyPanel(); renderOverlay(); renderFieldList(); }); kpJsonKey.addEventListener("keydown", e => { if (e.key === "Enter") kpAssign.click(); if (e.key === "Escape") kpCancel.click(); }); kpFieldId.addEventListener("keydown", e => { if (e.key === "Enter") kpJsonKey.focus(); }); // ─── Global keyboard shortcuts ──────────────────────────────────────────────── document.addEventListener("keydown", e => { if (e.key === "Escape") { if (isDrawMode) { exitDrawMode(); return; } if (!keyPanel.classList.contains("hidden")) kpCancel.click(); } }); // ─── Field list sidebar ─────────────────────────────────────────────────────── function renderFieldList() { const mapped = fields.filter(f => f.jsonKey).length; fieldCount.textContent = `${mapped} / ${fields.length} mapped`; if (fields.length === 0) { fieldList.innerHTML = '

No fields detected.
Use "Draw Field" to add manually.

'; return; } const byPage = new Map(); for (const f of fields) { if (!byPage.has(f.page)) byPage.set(f.page, []); byPage.get(f.page)!.push(f); } fieldList.innerHTML = ""; for (const [page, pf] of [...byPage.entries()].sort((a, b) => a[0] - b[0])) { const label = document.createElement("div"); label.className = "field-page-label"; label.textContent = `Page ${page}`; fieldList.appendChild(label); for (const field of pf) { const item = document.createElement("div"); item.className = "field-item" + (field.id === selectedFieldId ? " selected" : "") + (field.jsonKey ? " mapped" : ""); item.dataset.id = field.id; item.innerHTML = ` ${field.fieldName} ${field.jsonKey || "unmapped"} `; item.addEventListener("click", async () => { if (field.page !== currentPage) { currentPage = field.page; await renderPage(currentPage); } selectField(field.id); }); fieldList.appendChild(item); } } } // ─── Save ───────────────────────────────────────────────────────────────────── function setNested(obj: Record, path: string, value: unknown): void { const parts = path.split("."); let cur: Record = obj; for (let i = 0; i < parts.length - 1; i++) { const key = parts[i]!; if (typeof cur[key] !== "object" || cur[key] === null) cur[key] = {}; cur = cur[key] as Record; } cur[parts[parts.length - 1]!] = value; } saveBtn.addEventListener("click", async () => { const template: Record = {}; for (const f of fields) { if (!f.jsonKey) continue; const isAntwortBlock = f.jsonKey.startsWith("fragen.") || f.jsonKey.startsWith("kriterium."); setNested( template, f.jsonKey, isAntwortBlock ? { fieldName: f.fieldName, label: f.label, antwortFormat: f.antwortFormat ?? "einzelfrage", fragen: f.fragen ?? [], } : f.fieldName, ); } try { await fetch("/api/save", { method: "POST", headers: csrfHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(template), }); const orig = saveBtn.textContent; saveBtn.textContent = "Saved ✓"; setTimeout(() => { saveBtn.textContent = orig; }, 2000); } catch (err) { console.error("Save failed:", err); } }); function persist() { if (!currentFilename) return; localStorage.setItem(`tb:${currentFilename}`, JSON.stringify(fields)); }