diff --git a/admin-lehrer/app/(admin)/ai/ocr-kombi/page.tsx b/admin-lehrer/app/(admin)/ai/ocr-kombi/page.tsx index c240432..113e239 100644 --- a/admin-lehrer/app/(admin)/ai/ocr-kombi/page.tsx +++ b/admin-lehrer/app/(admin)/ai/ocr-kombi/page.tsx @@ -17,6 +17,7 @@ import { StepGridBuild } from '@/components/ocr-kombi/StepGridBuild' import { StepGridReview } from '@/components/ocr-kombi/StepGridReview' import { StepGutterRepair } from '@/components/ocr-kombi/StepGutterRepair' import { StepBoxGridReview } from '@/components/ocr-kombi/StepBoxGridReview' +import { StepAnsicht } from '@/components/ocr-kombi/StepAnsicht' import { StepGroundTruth } from '@/components/ocr-kombi/StepGroundTruth' import { useKombiPipeline } from './useKombiPipeline' @@ -100,6 +101,8 @@ function OcrKombiContent() { case 11: return case 12: + return + case 13: return ( = { 9: 11, // grid-review 10: 11, // gutter-repair (shares DB step with grid-review) 11: 11, // box-review (shares DB step with grid-review) - 12: 12, // ground-truth + 12: 11, // ansicht (shares DB step with grid-review) + 13: 12, // ground-truth } /** Map from DB step to Kombi V2 UI step index */ @@ -72,7 +74,7 @@ export function dbStepToKombiV2Ui(dbStep: number): number { if (dbStep === 9) return 7 // structure if (dbStep === 10) return 8 // grid-build if (dbStep === 11) return 9 // grid-review - return 12 // ground-truth + return 13 // ground-truth } /** Document group: groups multiple sessions from a multi-page upload */ diff --git a/admin-lehrer/components/grid-editor/types.ts b/admin-lehrer/components/grid-editor/types.ts index dd82e36..4f777e7 100644 --- a/admin-lehrer/components/grid-editor/types.ts +++ b/admin-lehrer/components/grid-editor/types.ts @@ -126,6 +126,8 @@ export interface GridEditorCell { is_bold: boolean /** Manual color override: hex string or null to clear. */ color_override?: string | null + /** Number of columns this cell spans (merged cell). Default 1. */ + colspan?: number } /** Layout dividers for the visual column/margin editor on the original image. */ diff --git a/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx b/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx new file mode 100644 index 0000000..f14d3b0 --- /dev/null +++ b/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx @@ -0,0 +1,276 @@ +'use client' + +/** + * StepAnsicht — Read-only page layout preview. + * + * Shows the reconstructed page with all zones (content grid + embedded boxes) + * positioned at their original coordinates. Pure CSS positioning, no canvas. + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import { useGridEditor } from '@/components/grid-editor/useGridEditor' +import type { GridZone, GridEditorCell } from '@/components/grid-editor/types' + +const KLAUSUR_API = '/klausur-api' + +interface StepAnsichtProps { + sessionId: string | null + onNext: () => void +} + +/** Get dominant color from a cell's word_boxes or color_override. */ +function getCellColor(cell: GridEditorCell | undefined): string | null { + if (!cell) return null + if ((cell as any).color_override) return (cell as any).color_override + const colored = cell.word_boxes?.find((wb) => wb.color_name && wb.color_name !== 'black') + return colored?.color ?? null +} + +export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) { + const { + grid, + loading, + error, + loadGrid, + } = useGridEditor(sessionId) + + const containerRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(0) + const [showOriginal, setShowOriginal] = useState(false) + + // Load grid on mount + useEffect(() => { + if (sessionId) loadGrid() + }, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Track container width + useEffect(() => { + if (!containerRef.current) return + const ro = new ResizeObserver(([entry]) => { + setContainerWidth(entry.contentRect.width) + }) + ro.observe(containerRef.current) + return () => ro.disconnect() + }, []) + + if (loading) { + return ( +
+
+ Lade Vorschau... +
+ ) + } + + if (error || !grid) { + return ( +
+

{error || 'Keine Grid-Daten vorhanden.'}

+ +
+ ) + } + + const imgW = grid.image_width || 1 + const imgH = grid.image_height || 1 + const scale = containerWidth > 0 ? containerWidth / imgW : 1 + const containerHeight = imgH * scale + + // Font size: scale from original, with minimum + const baseFontPx = (grid as any).layout_metrics?.font_size_suggestion_px || 14 + const scaledFont = Math.max(7, baseFontPx * scale * 0.85) + + return ( +
+ {/* Header */} +
+
+

+ Ansicht — Seitenrekonstruktion +

+

+ Vorschau der rekonstruierten Seite mit allen Zonen und Boxen an Originalpositionen. +

+
+
+ + +
+
+ + {/* Page container */} +
0 ? `${containerHeight}px` : 'auto' }} + > + {/* Original image background (toggleable) */} + {showOriginal && sessionId && ( + Original + )} + + {/* Render zones */} + {grid.zones.map((zone) => ( + + ))} +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Zone renderer +// --------------------------------------------------------------------------- + +function ZoneRenderer({ zone, scale, fontSize }: { + zone: GridZone + scale: number + fontSize: number +}) { + const isBox = zone.zone_type === 'box' + const boxColor = (zone as any).box_bg_hex || '#6b7280' + + if (!zone.cells || zone.cells.length === 0) return null + + const left = zone.bbox_px.x * scale + const top = zone.bbox_px.y * scale + const width = zone.bbox_px.w * scale + const height = zone.bbox_px.h * scale + + // Build cell map + const cellMap = new Map() + for (const cell of zone.cells) { + cellMap.set(`${cell.row_index}_${cell.col_index}`, cell) + } + + // Column widths (relative to zone) + const colWidths = zone.columns.map((col) => { + const w = (col.x_max_px ?? 0) - (col.x_min_px ?? 0) + return Math.max(10, w * scale) + }) + const totalColW = colWidths.reduce((s, w) => s + w, 0) + // Scale columns to fit zone width + const colScale = totalColW > 0 ? width / totalColW : 1 + const scaledColWidths = colWidths.map((w) => w * colScale) + + const gridTemplateCols = scaledColWidths.map((w) => `${w.toFixed(1)}px`).join(' ') + const numCols = zone.columns.length + + return ( +
+
+ {zone.rows.map((row) => { + const isSpanning = zone.cells.some( + (c) => c.row_index === row.index && c.col_type === 'spanning_header', + ) + + // Row height from measured px + const measuredH = (row.y_max_px ?? 0) - (row.y_min_px ?? 0) + const rowH = Math.max(fontSize * 1.4, measuredH * scale) + + return ( +
+ {isSpanning ? ( + // Render spanning cells + zone.cells + .filter((c) => c.row_index === row.index && c.col_type === 'spanning_header') + .sort((a, b) => a.col_index - b.col_index) + .map((cell) => { + const colspan = (cell as any).colspan || numCols + const gridColStart = cell.col_index + 1 + const gridColEnd = gridColStart + colspan + const color = getCellColor(cell) + return ( +
+ {cell.text} +
+ ) + }) + ) : ( + // Render normal columns + zone.columns.map((col) => { + const cell = cellMap.get(`${row.index}_${col.index}`) + if (!cell) { + return
+ } + const color = getCellColor(cell) + const isBold = col.bold || cell.is_bold || row.is_header + const text = cell.text ?? '' + const isMultiLine = text.includes('\n') + + return ( +
+ {text} +
+ ) + }) + )} +
+ ) + })} +
+
+ ) +}