'use client' /** * StepAnsicht — Split-view page layout comparison. * * Left: Original scan with OCR word overlay * Right: Reconstructed layout with averaged row heights per section * * Layout principle: the page is divided into vertical sections separated * by boxes. Each section gets a uniform row height calculated from * (last_row_y - first_row_y) / (num_rows - 1). Boxes are rendered * inline between sections (not as floating overlays). */ import { useEffect, useMemo, useRef, useState } from 'react' import { useGridEditor } from '@/components/grid-editor/useGridEditor' import type { GridZone, GridEditorCell, GridRow } from '@/components/grid-editor/types' const KLAUSUR_API = '/klausur-api' interface StepAnsichtProps { sessionId: string | null onNext: () => void } /** A vertical section of the page: either content rows or a box zone. */ interface PageSection { type: 'content' | 'box' yStart: number // pixel y in original image yEnd: number // pixel y end zone?: GridZone // for box sections rows?: GridRow[] // for content sections — subset of content zone rows avgRowH: number // averaged row height in original pixels } function getCellColor(cell: GridEditorCell | undefined): string | null { if (!cell) return null if (cell.color_override) return cell.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 leftRef = useRef(null) const [panelWidth, setPanelWidth] = useState(0) const [showGrid, setShowGrid] = useState(true) const [gridSpacing, setGridSpacing] = useState(100) useEffect(() => { if (sessionId) loadGrid() }, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!leftRef.current) return const ro = new ResizeObserver(([entry]) => setPanelWidth(entry.contentRect.width)) ro.observe(leftRef.current) return () => ro.disconnect() }, []) // Build page sections: split content rows around box zones const sections = useMemo(() => { if (!grid) return [] const contentZone = grid.zones.find((z) => z.zone_type === 'content') const boxZones = grid.zones.filter((z) => z.zone_type === 'box') .sort((a, b) => a.bbox_px.y - b.bbox_px.y) if (!contentZone) return [] const allRows = contentZone.rows const result: PageSection[] = [] // Box boundaries sorted by y const boxBounds = boxZones.map((bz) => ({ zone: bz, yStart: bz.bbox_px.y, yEnd: bz.bbox_px.y + bz.bbox_px.h, })) // Split content rows into sections around boxes let currentRows: GridRow[] = [] let boxIdx = 0 for (const row of allRows) { const ry = row.y_min_px ?? (row as any).y_min ?? 0 // Check if we've passed a box boundary — insert box section while (boxIdx < boxBounds.length && ry >= boxBounds[boxIdx].yStart) { // Flush current content section if (currentRows.length > 0) { result.push(makeContentSection(currentRows)) currentRows = [] } // Insert box section const bb = boxBounds[boxIdx] const bRows = bb.zone.rows || [] let bAvgH = 35 if (bRows.length >= 2) { const bys = bRows.map((r) => r.y_min_px ?? (r as any).y_min ?? 0) bAvgH = (bys[bys.length - 1] - bys[0]) / (bRows.length - 1) } result.push({ type: 'box', yStart: bb.yStart, yEnd: bb.yEnd, zone: bb.zone, avgRowH: bAvgH, }) boxIdx++ } // Skip rows that fall inside a box boundary const insideBox = boxBounds.some((bb) => ry >= bb.yStart && ry <= bb.yEnd) if (!insideBox) { currentRows.push(row) } } // Flush remaining content rows if (currentRows.length > 0) { result.push(makeContentSection(currentRows)) } // Insert remaining boxes (if any rows didn't trigger them) while (boxIdx < boxBounds.length) { const bb = boxBounds[boxIdx] const bRows = bb.zone.rows || [] let bAvgH = 35 if (bRows.length >= 2) { const bys = bRows.map((r) => r.y_min_px ?? (r as any).y_min ?? 0) bAvgH = (bys[bys.length - 1] - bys[0]) / (bRows.length - 1) } result.push({ type: 'box', yStart: bb.yStart, yEnd: bb.yEnd, zone: bb.zone, avgRowH: bAvgH }) boxIdx++ } return result }, [grid]) if (loading) { return (
Lade Vorschau...
) } if (error || !grid) { return (

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

) } const imgW = grid.image_width || 1 const imgH = grid.image_height || 1 const scale = panelWidth > 0 ? panelWidth / imgW : 0.5 const panelHeight = imgH * scale const contentZone = grid.zones.find((z) => z.zone_type === 'content') return (
{/* Header */}

Ansicht — Original vs. Rekonstruktion

Links: Original mit OCR. Rechts: Rekonstruktion mit gemittelten Zeilenhöhen.

{/* Split view */}
{/* LEFT: Original + OCR overlay */}
Original + OCR
{sessionId && ( Original + OCR )} {showGrid && }
{/* RIGHT: Reconstruction */}
Rekonstruktion
{/* Render sections sequentially */} {sections.map((sec, si) => { if (sec.type === 'box' && sec.zone) { return ( ) } if (sec.type === 'content' && sec.rows && contentZone) { return ( ) } return null })} {showGrid && }
) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeContentSection(rows: GridRow[]): PageSection { const ys = rows.map((r) => r.y_min_px ?? (r as any).y_min ?? 0) const yEnd = rows[rows.length - 1].y_max_px ?? (rows[rows.length - 1] as any).y_max ?? ys[ys.length - 1] + 30 let avgRowH = 35 if (rows.length >= 2) { avgRowH = (ys[ys.length - 1] - ys[0]) / (rows.length - 1) } return { type: 'content', yStart: ys[0], yEnd, rows, avgRowH } } // --------------------------------------------------------------------------- // Content section renderer — rows from content zone at absolute positions // --------------------------------------------------------------------------- function ContentSectionRenderer({ zone, rows, yStart, scale, avgRowH }: { zone: GridZone; rows: GridRow[]; yStart: number; scale: number; avgRowH: number }) { const cellMap = new Map() for (const cell of zone.cells) { cellMap.set(`${cell.row_index}_${cell.col_index}`, cell) } const rowH = avgRowH * scale const fontSize = Math.max(7, rowH * 0.55) return ( <> {rows.map((row, ri) => { const rowY = (row.y_min_px ?? (row as any).y_min ?? 0) * scale const isSpanning = zone.cells.some((c) => c.row_index === row.index && c.col_type === 'spanning_header') // Column widths const colWidths = zone.columns.map((col) => Math.max(5, ((col.x_max_px ?? 0) - (col.x_min_px ?? 0)) * scale)) const zoneLeft = zone.bbox_px.x * scale const zoneWidth = zone.bbox_px.w * scale const totalColW = colWidths.reduce((s, w) => s + w, 0) const colScale = totalColW > 0 ? zoneWidth / totalColW : 1 return (
`${(w * colScale).toFixed(1)}px`).join(' '), fontSize: `${fontSize}px`, lineHeight: `${rowH}px`, }} > {isSpanning ? ( 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.colspan || zone.columns.length const color = getCellColor(cell) return (
{cell.text}
) }) ) : ( zone.columns.map((col) => { const cell = cellMap.get(`${row.index}_${col.index}`) const color = getCellColor(cell) const isBold = col.bold || cell?.is_bold || row.is_header return (
{cell?.text ?? ''}
) }) )}
) })} ) } // --------------------------------------------------------------------------- // Box section renderer — box zone at absolute position with border // --------------------------------------------------------------------------- function BoxSectionRenderer({ zone, scale, avgRowH }: { zone: GridZone; scale: number; avgRowH: number }) { 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 const rowH = avgRowH * scale const fontSize = Math.max(7, rowH * 0.5) const cellMap = new Map() for (const cell of zone.cells) { cellMap.set(`${cell.row_index}_${cell.col_index}`, cell) } const colWidths = zone.columns.map((col) => Math.max(5, ((col.x_max_px ?? 0) - (col.x_min_px ?? 0)) * scale)) const totalColW = colWidths.reduce((s, w) => s + w, 0) const colScale = totalColW > 0 ? width / totalColW : 1 const numCols = zone.columns.length // Evenly distribute rows within the box const numRows = zone.rows.length const evenRowH = numRows > 0 ? height / numRows : rowH return (
`${(w * colScale).toFixed(1)}px`).join(' ') }}> {zone.rows.map((row) => { const isSpanning = zone.cells.some((c) => c.row_index === row.index && c.col_type === 'spanning_header') // Multi-line height const maxLines = Math.max(1, ...zone.cells .filter((c) => c.row_index === row.index) .map((c) => (c.text ?? '').split('\n').length)) const cellRowH = evenRowH * (maxLines > 1 ? maxLines * 0.7 : 1) return (
{isSpanning ? ( 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.colspan || numCols const color = getCellColor(cell) return (
{cell.text}
) }) ) : ( zone.columns.map((col) => { const cell = cellMap.get(`${row.index}_${col.index}`) 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}
) }) )}
) })}
) } // --------------------------------------------------------------------------- // Coordinate grid // --------------------------------------------------------------------------- function CoordinateGrid({ imgW, imgH, scale, spacing }: { imgW: number; imgH: number; scale: number; spacing: number }) { const lines: JSX.Element[] = [] for (let x = 0; x <= imgW; x += spacing) { const px = x * scale lines.push(
{x}
) } for (let y = 0; y <= imgH; y += spacing) { const px = y * scale lines.push(
{y}
) } return <>{lines} }