From 18213f0bde36a1f3b2016e5b3a6ff58f78c4c1ca Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 13 Apr 2026 23:00:22 +0200 Subject: [PATCH] StepAnsicht: split-view with coordinate grid for comparison Left panel: Original scan + OCR word overlay (red text at exact word_box positions) + coordinate grid Right panel: Reconstructed layout + same coordinate grid Features: - Coordinate grid toggle with 50/100/200px spacing options - Grid lines labeled with pixel coordinates in original image space - Both panels share the same scale for direct visual comparison - OCR overlay shows detected text in red mono font at original positions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ocr-kombi/StepAnsicht.tsx | 253 ++++++++++-------- 1 file changed, 146 insertions(+), 107 deletions(-) diff --git a/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx b/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx index f14d3b0..1f727de 100644 --- a/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx +++ b/admin-lehrer/components/ocr-kombi/StepAnsicht.tsx @@ -1,13 +1,15 @@ 'use client' /** - * StepAnsicht — Read-only page layout preview. + * StepAnsicht — Split-view page layout comparison. * - * Shows the reconstructed page with all zones (content grid + embedded boxes) - * positioned at their original coordinates. Pure CSS positioning, no canvas. + * Left: Original scan with OCR word overlay (red) + coordinate grid + * Right: Reconstructed layout with all zones + coordinate grid + * + * Both sides share the same coordinate system for easy visual comparison. */ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useGridEditor } from '@/components/grid-editor/useGridEditor' import type { GridZone, GridEditorCell } from '@/components/grid-editor/types' @@ -18,38 +20,30 @@ interface StepAnsichtProps { 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 + 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 { grid, loading, error, loadGrid } = useGridEditor(sessionId) - const containerRef = useRef(null) - const [containerWidth, setContainerWidth] = useState(0) - const [showOriginal, setShowOriginal] = useState(false) + const leftRef = useRef(null) + const [panelWidth, setPanelWidth] = useState(0) + const [showGrid, setShowGrid] = useState(true) + const [gridSpacing, setGridSpacing] = useState(100) // px in original coordinates - // Load grid on mount useEffect(() => { if (sessionId) loadGrid() }, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps - // Track container width + // Track panel width useEffect(() => { - if (!containerRef.current) return - const ro = new ResizeObserver(([entry]) => { - setContainerWidth(entry.contentRect.width) - }) - ro.observe(containerRef.current) + if (!leftRef.current) return + const ro = new ResizeObserver(([entry]) => setPanelWidth(entry.contentRect.width)) + ro.observe(leftRef.current) return () => ro.disconnect() }, []) @@ -64,93 +58,158 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) { if (error || !grid) { return ( -
-

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

- +
+

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

+
) } const imgW = grid.image_width || 1 const imgH = grid.image_height || 1 - const scale = containerWidth > 0 ? containerWidth / imgW : 1 - const containerHeight = imgH * scale + const scale = panelWidth > 0 ? panelWidth / imgW : 0.5 + const panelHeight = 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) + // Collect all word boxes for OCR overlay + const allWordBoxes = grid.zones.flatMap((z) => + z.cells.flatMap((c) => (c.word_boxes || []).map((wb) => ({ ...wb, zone: z }))) + ) + return (
{/* Header */}

- Ansicht — Seitenrekonstruktion + Ansicht — Original vs. Rekonstruktion

- Vorschau der rekonstruierten Seite mit allen Zonen und Boxen an Originalpositionen. + Links: Original mit OCR-Overlay. Rechts: Rekonstruierte Seite. Koordinatengitter zum Abgleich.

- -
- {/* Page container */} -
0 ? `${containerHeight}px` : 'auto' }} - > - {/* Original image background (toggleable) */} - {showOriginal && sessionId && ( - Original - )} + {/* Split view */} +
0 ? `${panelHeight + 40}px` : '600px' }}> + {/* LEFT: Original + OCR overlay */} +
+
+ Original + OCR +
- {/* Render zones */} - {grid.zones.map((zone) => ( - - ))} + {/* Scan image */} + {sessionId && ( + Original + )} + + {/* OCR word overlay (red text) */} + {allWordBoxes.map((wb, i) => ( +
+ {wb.text} +
+ ))} + + {/* Coordinate grid */} + {showGrid && } +
+ + {/* RIGHT: Reconstruction */} +
+
+ Rekonstruktion +
+ + {/* Rendered zones */} + {grid.zones.map((zone) => ( + + ))} + + {/* Coordinate grid */} + {showGrid && } +
) } // --------------------------------------------------------------------------- -// Zone renderer +// Coordinate grid overlay +// --------------------------------------------------------------------------- + +function CoordinateGrid({ imgW, imgH, scale, spacing }: { + imgW: number; imgH: number; scale: number; spacing: number +}) { + const lines: JSX.Element[] = [] + + // Vertical lines + for (let x = 0; x <= imgW; x += spacing) { + const px = x * scale + lines.push( +
+ {x} +
+ ) + } + + // Horizontal lines + for (let y = 0; y <= imgH; y += spacing) { + const px = y * scale + lines.push( +
+ {y} +
+ ) + } + + return <>{lines} +} + +// --------------------------------------------------------------------------- +// Zone renderer (reconstruction side) // --------------------------------------------------------------------------- function ZoneRenderer({ zone, scale, fontSize }: { - zone: GridZone - scale: number - fontSize: number + zone: GridZone; scale: number; fontSize: number }) { const isBox = zone.zone_type === 'box' const boxColor = (zone as any).box_bg_hex || '#6b7280' @@ -162,23 +221,20 @@ function ZoneRenderer({ zone, scale, fontSize }: { 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) + // Column widths scaled 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) + return Math.max(5, 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 ( @@ -189,47 +245,34 @@ function ZoneRenderer({ zone, scale, fontSize }: { top: `${top}px`, width: `${width}px`, minHeight: `${height}px`, - border: isBox ? `${Math.max(2, 3 * scale)}px solid ${boxColor}` : undefined, + border: isBox ? `${Math.max(1.5, 2.5 * scale)}px solid ${boxColor}` : undefined, backgroundColor: isBox ? `${boxColor}08` : undefined, - borderRadius: isBox ? `${Math.max(2, 4 * scale)}px` : undefined, + borderRadius: isBox ? `${Math.max(1, 3 * scale)}px` : undefined, fontSize: `${fontSize}px`, - lineHeight: '1.3', + lineHeight: '1.25', }} > -
+
`${w.toFixed(1)}px`).join(' ') }}> {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 isSpanning = zone.cells.some((c) => c.row_index === row.index && c.col_type === 'spanning_header') const measuredH = (row.y_max_px ?? 0) - (row.y_min_px ?? 0) - const rowH = Math.max(fontSize * 1.4, measuredH * scale) + const rowH = Math.max(fontSize * 1.3, 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 colspan = cell.colspan || numCols const color = getCellColor(cell) return (
{ const cell = cellMap.get(`${row.index}_${col.index}`) - if (!cell) { - return
- } + 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}