Files
breakpilot-lehrer/admin-lehrer/components/ocr-pipeline/StepReconstruction.tsx
Benjamin Admin 729ebff63c feat: add border ghost filter + graphic detection tests + structure overlay
- Add _filter_border_ghost_words() to remove OCR artefacts from box borders
  (vertical + horizontal edge detection, column cleanup, re-indexing)
- Add 20 tests for border ghost filter (basic filtering + column cleanup)
- Add 24 tests for cv_graphic_detect (color detection, word overlap, boxes)
- Clean up cv_graphic_detect.py logging (per-candidate → DEBUG)
- Add structure overlay layer to StepReconstruction (boxes + graphics toggle)
- Show border_ghosts_removed badge in StepStructureDetection
- Update MkDocs with structure detection documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:28:53 +01:00

1102 lines
42 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import type { GridResult, GridCell, ColumnResult, RowResult, PageZone, PageRegion, RowItem, StructureResult, StructureBox, StructureGraphic } from '@/app/(admin)/ai/ocr-pipeline/types'
import { usePixelWordPositions } from './usePixelWordPositions'
const KLAUSUR_API = '/klausur-api'
// Lazy-load Fabric.js canvas editor (SSR-incompatible)
const FabricReconstructionCanvas = dynamic(
() => import('./FabricReconstructionCanvas').then(m => ({ default: m.FabricReconstructionCanvas })),
{ ssr: false, loading: () => <div className="py-8 text-center text-sm text-gray-400">Editor wird geladen...</div> }
)
type EditorMode = 'simple' | 'editor' | 'overlay'
interface StepReconstructionProps {
sessionId: string | null
onNext: () => void
}
interface EditableCell {
cellId: string
text: string
originalText: string
bboxPct: { x: number; y: number; w: number; h: number }
colType: string
rowIndex: number
colIndex: number
}
type UndoAction = { cellId: string; oldText: string; newText: string }
export function StepReconstruction({ sessionId, onNext }: StepReconstructionProps) {
const [status, setStatus] = useState<'loading' | 'ready' | 'saving' | 'saved' | 'error'>('loading')
const [error, setError] = useState('')
const [cells, setCells] = useState<EditableCell[]>([])
const [gridCells, setGridCells] = useState<GridCell[]>([])
const [editorMode, setEditorMode] = useState<EditorMode>('simple')
const [editedTexts, setEditedTexts] = useState<Map<string, string>>(new Map())
const [zoom, setZoom] = useState(100)
const [imageNaturalH, setImageNaturalH] = useState(0)
const [showEmptyHighlight, setShowEmptyHighlight] = useState(true)
// Undo/Redo stacks
const [undoStack, setUndoStack] = useState<UndoAction[]>([])
const [redoStack, setRedoStack] = useState<UndoAction[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const imageRef = useRef<HTMLImageElement>(null)
// Overlay mode state
const [isParentWithBoxes, setIsParentWithBoxes] = useState(false)
const [mergedGridCells, setMergedGridCells] = useState<GridCell[]>([])
const [parentColumns, setParentColumns] = useState<PageRegion[]>([])
const [parentRows, setParentRows] = useState<RowItem[]>([])
const [parentZones, setParentZones] = useState<PageZone[]>([])
const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null)
const [fontScale, setFontScale] = useState(0.7)
const [globalBold, setGlobalBold] = useState(false)
const [imageRotation, setImageRotation] = useState<0 | 180>(0)
const [structureBoxes, setStructureBoxes] = useState<StructureBox[]>([])
const [structureGraphics, setStructureGraphics] = useState<StructureGraphic[]>([])
const [showStructure, setShowStructure] = useState(true)
const reconRef = useRef<HTMLDivElement>(null)
const [reconWidth, setReconWidth] = useState(0)
// Pixel-based word positions for overlay mode
const overlayImageUrl = sessionId
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
: ''
const cellWordPositions = usePixelWordPositions(
overlayImageUrl,
mergedGridCells,
editorMode === 'overlay',
imageRotation,
)
// Track reconstruction container width for font size calculation
useEffect(() => {
const el = reconRef.current
if (!el) return
const obs = new ResizeObserver(entries => {
for (const entry of entries) setReconWidth(entry.contentRect.width)
})
obs.observe(el)
return () => obs.disconnect()
}, [editorMode])
// Load session data on mount
useEffect(() => {
if (!sessionId) return
loadSessionData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
// Track image natural dimensions for font scaling and structure layer
const handleImageLoad = useCallback(() => {
if (imageRef.current) {
setImageNaturalH(imageRef.current.naturalHeight)
if (!imageNaturalSize) {
setImageNaturalSize({ w: imageRef.current.naturalWidth, h: imageRef.current.naturalHeight })
}
}
}, [imageNaturalSize])
const loadSessionData = async () => {
if (!sessionId) return
setStatus('loading')
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
const wordResult: GridResult | undefined = data.word_result
if (!wordResult) {
setError('Keine Worterkennungsdaten gefunden. Bitte zuerst Schritt 5 abschliessen.')
setStatus('error')
return
}
// Build editable cells from grid cells
const rawGridCells: GridCell[] = wordResult.cells || []
setGridCells(rawGridCells)
const allEditableCells: EditableCell[] = rawGridCells.map(c => ({
cellId: c.cell_id,
text: c.text,
originalText: c.text,
bboxPct: c.bbox_pct,
colType: c.col_type,
rowIndex: c.row_index,
colIndex: c.col_index,
}))
setCells(allEditableCells)
setEditedTexts(new Map())
setUndoStack([])
setRedoStack([])
// Load structure result (boxes, graphics, colors)
const structureResult: StructureResult | undefined = data.structure_result
if (structureResult) {
setStructureBoxes(structureResult.boxes || [])
setStructureGraphics(structureResult.graphics || [])
}
// Check for parent with boxes (sub-sessions + zones)
const columnResult: ColumnResult | undefined = data.column_result
const rowResult: RowResult | undefined = data.row_result
const subSessions: { id: string; box_index: number }[] = data.sub_sessions || []
const zones: PageZone[] = columnResult?.zones || []
const hasBoxes = subSessions.length > 0 && zones.some(z => z.zone_type === 'box')
setIsParentWithBoxes(hasBoxes)
if (hasBoxes) setImageRotation(180) // Default: rotate for correct pixel matching
if (columnResult?.columns) setParentColumns(columnResult.columns)
if (rowResult?.rows) setParentRows(rowResult.rows)
if (zones.length > 0) setParentZones(zones)
// Store image dimensions
if (wordResult.image_width && wordResult.image_height) {
setImageNaturalSize({ w: wordResult.image_width, h: wordResult.image_height })
}
if (hasBoxes) {
// Default to overlay mode for parent sessions with boxes
setEditorMode('overlay')
// Load sub-sessions and merge cells
const imgW = wordResult.image_width || 1
const imgH = wordResult.image_height || 1
const allMergedCells: GridCell[] = [...rawGridCells]
for (const sub of subSessions) {
try {
const subRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sub.id}`)
if (!subRes.ok) continue
const subData = await subRes.json()
const subWordResult: GridResult | undefined = subData.word_result
if (!subWordResult?.cells) continue
// Find the box zone for this sub-session
const boxZone = zones.find(z => z.zone_type === 'box')
if (!boxZone?.box) continue
const box = boxZone.box
// Box coordinates are in pixels, convert to pct
const boxXPct = (box.x / imgW) * 100
const boxYPct = (box.y / imgH) * 100
const boxWPct = (box.width / imgW) * 100
const boxHPct = (box.height / imgH) * 100
// Convert sub-session cell coordinates to parent coordinates
for (const subCell of subWordResult.cells) {
if (!subCell.bbox_pct) continue
const parentCellX = boxXPct + (subCell.bbox_pct.x / 100) * boxWPct
const parentCellY = boxYPct + (subCell.bbox_pct.y / 100) * boxHPct
const parentCellW = (subCell.bbox_pct.w / 100) * boxWPct
const parentCellH = (subCell.bbox_pct.h / 100) * boxHPct
allMergedCells.push({
...subCell,
cell_id: `sub_${sub.id}_${subCell.cell_id}`,
bbox_pct: {
x: parentCellX,
y: parentCellY,
w: parentCellW,
h: parentCellH,
},
bbox_px: {
x: Math.round(parentCellX / 100 * imgW),
y: Math.round(parentCellY / 100 * imgH),
w: Math.round(parentCellW / 100 * imgW),
h: Math.round(parentCellH / 100 * imgH),
},
})
}
} catch {
// Skip failing sub-sessions
}
}
setMergedGridCells(allMergedCells)
// Also add merged cells as editable cells
const mergedEditableCells: EditableCell[] = allMergedCells.map(c => ({
cellId: c.cell_id,
text: c.text,
originalText: c.text,
bboxPct: c.bbox_pct,
colType: c.col_type,
rowIndex: c.row_index,
colIndex: c.col_index,
}))
setCells(mergedEditableCells)
} else {
setMergedGridCells(rawGridCells)
}
setStatus('ready')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
setStatus('error')
}
}
const handleTextChange = useCallback((cellId: string, newText: string) => {
setEditedTexts(prev => {
const oldText = prev.get(cellId)
const cell = cells.find(c => c.cellId === cellId)
const prevText = oldText ?? cell?.text ?? ''
// Push to undo stack
setUndoStack(stack => [...stack, { cellId, oldText: prevText, newText }])
setRedoStack([]) // Clear redo on new edit
const next = new Map(prev)
next.set(cellId, newText)
return next
})
}, [cells])
const undo = useCallback(() => {
setUndoStack(stack => {
if (stack.length === 0) return stack
const action = stack[stack.length - 1]
const newStack = stack.slice(0, -1)
setRedoStack(rs => [...rs, action])
setEditedTexts(prev => {
const next = new Map(prev)
next.set(action.cellId, action.oldText)
return next
})
return newStack
})
}, [])
const redo = useCallback(() => {
setRedoStack(stack => {
if (stack.length === 0) return stack
const action = stack[stack.length - 1]
const newStack = stack.slice(0, -1)
setUndoStack(us => [...us, action])
setEditedTexts(prev => {
const next = new Map(prev)
next.set(action.cellId, action.newText)
return next
})
return newStack
})
}, [])
const resetCell = useCallback((cellId: string) => {
const cell = cells.find(c => c.cellId === cellId)
if (!cell) return
setEditedTexts(prev => {
const next = new Map(prev)
next.delete(cellId)
return next
})
}, [cells])
// Global keyboard shortcuts for undo/redo
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
e.preventDefault()
if (e.shiftKey) {
redo()
} else {
undo()
}
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [undo, redo])
const getDisplayText = useCallback((cell: EditableCell): string => {
return editedTexts.get(cell.cellId) ?? cell.text
}, [editedTexts])
const isEdited = useCallback((cell: EditableCell): boolean => {
const edited = editedTexts.get(cell.cellId)
return edited !== undefined && edited !== cell.originalText
}, [editedTexts])
const changedCount = useMemo(() => {
let count = 0
for (const cell of cells) {
if (isEdited(cell)) count++
}
return count
}, [cells, isEdited])
// Identify empty required cells (EN or DE columns with no text)
const emptyCellIds = useMemo(() => {
const required = new Set(['column_en', 'column_de'])
const ids = new Set<string>()
for (const cell of cells) {
if (required.has(cell.colType) && !cell.text.trim()) {
ids.add(cell.cellId)
}
}
return ids
}, [cells])
// Sort cells for tab navigation: by row, then by column
const sortedCellIds = useMemo(() => {
return [...cells]
.sort((a, b) => a.rowIndex !== b.rowIndex ? a.rowIndex - b.rowIndex : a.colIndex - b.colIndex)
.map(c => c.cellId)
}, [cells])
const handleKeyDown = useCallback((e: React.KeyboardEvent, cellId: string) => {
if (e.key === 'Tab') {
e.preventDefault()
const idx = sortedCellIds.indexOf(cellId)
const nextIdx = e.shiftKey ? idx - 1 : idx + 1
if (nextIdx >= 0 && nextIdx < sortedCellIds.length) {
const nextId = sortedCellIds[nextIdx]
const el = document.getElementById(`cell-${nextId}`)
el?.focus()
}
}
}, [sortedCellIds])
const saveReconstruction = useCallback(async () => {
if (!sessionId) return
setStatus('saving')
try {
const cellUpdates = Array.from(editedTexts.entries())
.filter(([cellId, text]) => {
const cell = cells.find(c => c.cellId === cellId)
return cell && text !== cell.originalText
})
.map(([cellId, text]) => ({ cell_id: cellId, text }))
if (cellUpdates.length === 0) {
// Nothing changed, just advance
setStatus('saved')
return
}
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cells: cellUpdates }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
setStatus('saved')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
setStatus('error')
}
}, [sessionId, editedTexts, cells])
// Handler for Fabric.js editor cell changes
const handleFabricCellsChanged = useCallback((updates: { cell_id: string; text: string }[]) => {
for (const u of updates) {
setEditedTexts(prev => {
const next = new Map(prev)
next.set(u.cell_id, u.text)
return next
})
}
}, [])
const dewarpedUrl = sessionId
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
: ''
const colTypeColor = (colType: string): string => {
const colors: Record<string, string> = {
column_en: 'border-blue-400/40 focus:border-blue-500',
column_de: 'border-green-400/40 focus:border-green-500',
column_example: 'border-orange-400/40 focus:border-orange-500',
column_text: 'border-purple-400/40 focus:border-purple-500',
page_ref: 'border-cyan-400/40 focus:border-cyan-500',
column_marker: 'border-gray-400/40 focus:border-gray-500',
}
return colors[colType] || 'border-gray-400/40 focus:border-gray-500'
}
// Font size based on image natural height (not container) scaled by zoom
const getFontSize = useCallback((bboxH: number): number => {
const baseH = imageNaturalH || 800
const px = (bboxH / 100) * baseH * 0.55
return Math.max(8, Math.min(18, px * (zoom / 100)))
}, [imageNaturalH, zoom])
// Box zones in percent for clamping cell positions in overlay mode
const boxZonesPct = useMemo(() =>
parentZones
.filter(z => z.zone_type === 'box' && z.box)
.map(z => {
const imgH = imageNaturalSize?.h || 1
return {
topPct: (z.box!.y / imgH) * 100,
bottomPct: ((z.box!.y + z.box!.height) / imgH) * 100,
}
}),
[parentZones, imageNaturalSize]
)
if (!sessionId) {
return <div className="text-center py-12 text-gray-400">Bitte zuerst eine Session auswaehlen.</div>
}
if (status === 'loading') {
return (
<div className="flex items-center gap-3 justify-center py-12">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-teal-500" />
<span className="text-gray-500">Rekonstruktionsdaten werden geladen...</span>
</div>
)
}
if (status === 'error') {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-5xl mb-4">&#x26A0;&#xFE0F;</div>
<h3 className="text-lg font-medium text-red-600 dark:text-red-400 mb-2">Fehler</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-lg mb-4">{error}</p>
<div className="flex gap-3">
<button onClick={() => { setError(''); loadSessionData() }}
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm">
Erneut versuchen
</button>
<button onClick={onNext}
className="px-5 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
Ueberspringen &rarr;
</button>
</div>
</div>
)
}
if (status === 'saved') {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-5xl mb-4">&#x2705;</div>
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">Rekonstruktion gespeichert</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{changedCount > 0 ? `${changedCount} Zellen wurden aktualisiert.` : 'Keine Aenderungen vorgenommen.'}
</p>
<button onClick={onNext}
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium">
Weiter &rarr;
</button>
</div>
)
}
// Clamp cell positions so they don't overlap with box zones
const adjustCellForBoxZones = (
bboxPct: { x: number; y: number; w: number; h: number },
cellId: string,
): { x: number; y: number; w: number; h: number } => {
// Sub-session cells (inside box) → no adjustment
if (cellId.startsWith('sub_')) return bboxPct
if (boxZonesPct.length === 0) return bboxPct
const cellTop = bboxPct.y
const cellBottom = bboxPct.y + bboxPct.h
const boxMid = (boxZonesPct[0].topPct + boxZonesPct[0].bottomPct) / 2
for (const { topPct, bottomPct } of boxZonesPct) {
// Check if cell overlaps with box zone at all
if (cellBottom <= topPct || cellTop >= bottomPct) continue
// Cell starts ABOVE box midpoint → belongs above, clamp bottom to box top
if (cellTop < boxMid) {
return { ...bboxPct, h: Math.max(0.5, topPct - cellTop) }
}
// Cell starts AT or BELOW box midpoint → belongs below, push top to box bottom
return { ...bboxPct, y: bottomPct, h: Math.max(0.5, cellBottom - bottomPct) }
}
return bboxPct
}
// Structure layer: boxes and graphic elements as background
const renderStructureLayer = (imgW: number, imgH: number) => {
if (!showStructure) return null
const hasElements = structureBoxes.length > 0 || structureGraphics.length > 0
if (!hasElements) return null
return (
<>
{/* Structure boxes */}
{structureBoxes.map((box, i) => {
const bgColor = box.bg_color_hex || '#6b7280'
return (
<div
key={`sbox-${i}`}
className="absolute pointer-events-none"
style={{
left: `${(box.x / imgW) * 100}%`,
top: `${(box.y / imgH) * 100}%`,
width: `${(box.w / imgW) * 100}%`,
height: `${(box.h / imgH) * 100}%`,
border: `${Math.max(1, box.border_thickness)}px solid ${bgColor}40`,
backgroundColor: `${bgColor}0a`,
borderRadius: '2px',
}}
/>
)
})}
{/* Graphic elements */}
{structureGraphics.map((g, i) => (
<div
key={`sgfx-${i}`}
className="absolute pointer-events-none"
style={{
left: `${(g.x / imgW) * 100}%`,
top: `${(g.y / imgH) * 100}%`,
width: `${(g.w / imgW) * 100}%`,
height: `${(g.h / imgH) * 100}%`,
border: `1px dashed ${g.color_hex}60`,
backgroundColor: `${g.color_hex}08`,
borderRadius: '2px',
}}
>
<span
className="absolute text-[8px] leading-none opacity-50"
style={{
top: '1px',
left: '2px',
color: g.color_hex,
}}
>
{g.shape === 'illustration' ? 'Illust' : 'Bild'}
</span>
</div>
))}
</>
)
}
// Overlay rendering helper
const renderOverlayMode = () => {
const imgW = imageNaturalSize?.w || 1
const imgH = imageNaturalSize?.h || 1
const aspect = imgH / imgW
const containerH = reconWidth * aspect
return (
<div className="grid grid-cols-2 gap-4">
{/* Left: Original image */}
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Originalbild
</div>
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900 sticky top-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={dewarpedUrl}
alt="Original"
className="w-full h-auto"
onLoad={(e) => {
const img = e.target as HTMLImageElement
setImageNaturalSize({ w: img.naturalWidth, h: img.naturalHeight })
}}
/>
</div>
</div>
{/* Right: Reconstructed table overlay */}
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Rekonstruktion ({cells.length} Zellen)
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white">
<div
ref={reconRef}
className="relative"
style={{
aspectRatio: `${imgW} / ${imgH}`,
}}
>
{/* Column lines */}
{parentColumns
.filter(c => !['header', 'footer'].includes(c.type))
.map((col, i) => (
<div
key={`col-${i}`}
className="absolute top-0 bottom-0 border-l border-gray-300/50"
style={{ left: `${(col.x / imgW) * 100}%` }}
/>
))}
{/* Row lines */}
{parentRows.map((row, i) => (
<div
key={`row-${i}`}
className="absolute left-0 right-0 border-t border-gray-300/50"
style={{ top: `${(row.y / imgH) * 100}%` }}
/>
))}
{/* Box zone highlight */}
{parentZones
.filter(z => z.zone_type === 'box' && z.box)
.map((z, i) => {
const box = z.box!
return (
<div
key={`box-${i}`}
className="absolute border-2 border-blue-400/30 bg-blue-50/10 pointer-events-none"
style={{
left: `${(box.x / imgW) * 100}%`,
top: `${(box.y / imgH) * 100}%`,
width: `${(box.width / imgW) * 100}%`,
height: `${(box.height / imgH) * 100}%`,
}}
/>
)
})}
{/* Structure elements (boxes, graphics) */}
{renderStructureLayer(imgW, imgH)}
{/* Pixel-positioned words / editable inputs */}
{cells.map((cell) => {
const displayText = getDisplayText(cell)
const edited = isEdited(cell)
const wordPos = cellWordPositions.get(cell.cellId)
const adjBbox = adjustCellForBoxZones(cell.bboxPct, cell.cellId)
const cellHeightPx = containerH * (adjBbox.h / 100)
// Pixel-analysed: render word-groups at detected positions as inputs
if (wordPos && wordPos.length > 0) {
return wordPos.map((wp, i) => {
const autoFontPx = cellHeightPx * wp.fontRatio * fontScale
const fs = Math.max(6, autoFontPx)
// For multi-group cells, only the first group is the primary input
// Show as span (read-only positioned) — editing happens at cell level
if (wordPos.length > 1) {
return (
<span
key={`${cell.cellId}_wp_${i}`}
className="absolute leading-none pointer-events-none select-none"
style={{
left: `${wp.xPct}%`,
top: `${adjBbox.y}%`,
width: `${wp.wPct}%`,
height: `${adjBbox.h}%`,
fontSize: `${fs}px`,
fontWeight: globalBold ? 'bold' : (cell.colType === 'column_en' ? 'bold' : 'normal'),
fontFamily: "'Liberation Sans', Arial, sans-serif",
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
overflow: 'visible',
color: '#1a1a1a',
}}
>
{wp.text}
</span>
)
}
// Single group: render as editable input at pixel position
return (
<div key={`${cell.cellId}_wp_${i}`} className="absolute group" style={{
left: `${wp.xPct}%`,
top: `${adjBbox.y}%`,
width: `${wp.wPct}%`,
height: `${adjBbox.h}%`,
}}>
<input
id={`cell-${cell.cellId}`}
type="text"
value={displayText}
onChange={(e) => handleTextChange(cell.cellId, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, cell.cellId)}
className={`w-full h-full bg-transparent border-0 outline-none px-0 transition-colors ${
edited ? 'bg-green-50/30' : ''
}`}
style={{
fontSize: `${fs}px`,
fontWeight: globalBold ? 'bold' : (cell.colType === 'column_en' ? 'bold' : 'normal'),
fontFamily: "'Liberation Sans', Arial, sans-serif",
lineHeight: '1',
color: '#1a1a1a',
}}
title={`${cell.cellId} (${cell.colType})`}
/>
{edited && (
<button
onClick={() => resetCell(cell.cellId)}
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] leading-none opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Zuruecksetzen"
>
&times;
</button>
)}
</div>
)
})
}
// Multi-group cell with pixel positions: already handled above
// Fallback: no pixel data — single input at cell bbox
if (!cell.text) return null
const fontSize = Math.max(6, cellHeightPx * fontScale)
return (
<div key={cell.cellId} className="absolute group" style={{
left: `${adjBbox.x}%`,
top: `${adjBbox.y}%`,
width: `${adjBbox.w}%`,
height: `${adjBbox.h}%`,
}}>
<input
id={`cell-${cell.cellId}`}
type="text"
value={displayText}
onChange={(e) => handleTextChange(cell.cellId, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, cell.cellId)}
className={`w-full h-full bg-transparent border-0 outline-none px-0 transition-colors ${
edited ? 'bg-green-50/30' : ''
}`}
style={{
fontSize: `${fontSize}px`,
fontWeight: globalBold ? 'bold' : 'normal',
fontFamily: "'Liberation Sans', Arial, sans-serif",
lineHeight: '1',
color: '#1a1a1a',
}}
title={`${cell.cellId} (${cell.colType})`}
/>
{edited && (
<button
onClick={() => resetCell(cell.cellId)}
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] leading-none opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Zuruecksetzen"
>
&times;
</button>
)}
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
return (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Schritt 7: Rekonstruktion
</h3>
{/* Mode toggle */}
<div className="flex items-center ml-2 border border-gray-300 dark:border-gray-600 rounded overflow-hidden text-xs">
<button
onClick={() => setEditorMode('simple')}
className={`px-2 py-0.5 transition-colors ${
editorMode === 'simple'
? 'bg-teal-600 text-white'
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}
>
Einfach
</button>
{isParentWithBoxes && (
<button
onClick={() => setEditorMode('overlay')}
className={`px-2 py-0.5 transition-colors ${
editorMode === 'overlay'
? 'bg-teal-600 text-white'
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}
>
Overlay
</button>
)}
<button
onClick={() => setEditorMode('editor')}
className={`px-2 py-0.5 transition-colors ${
editorMode === 'editor'
? 'bg-teal-600 text-white'
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}
>
Editor
</button>
</div>
<span className="text-xs text-gray-400">
{cells.length} Zellen &middot; {changedCount} geaendert
{emptyCellIds.size > 0 && showEmptyHighlight && (
<span className="text-red-400 ml-1">&middot; {emptyCellIds.size} leer</span>
)}
</span>
</div>
<div className="flex items-center gap-2">
{/* Undo/Redo */}
<button
onClick={undo}
disabled={undoStack.length === 0}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-30"
title="Rueckgaengig (Ctrl+Z)"
>
&#x21A9;
</button>
<button
onClick={redo}
disabled={redoStack.length === 0}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-30"
title="Wiederholen (Ctrl+Shift+Z)"
>
&#x21AA;
</button>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Overlay-specific toolbar */}
{editorMode === 'overlay' && (
<>
<label className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
Schrift
<input
type="range" min={30} max={120} value={Math.round(fontScale * 100)}
onChange={e => setFontScale(Number(e.target.value) / 100)}
className="w-20 h-1 accent-teal-600"
/>
<span className="w-8 text-right font-mono">{Math.round(fontScale * 100)}%</span>
</label>
<button
onClick={() => setGlobalBold(b => !b)}
className={`px-2 py-1 text-xs rounded border transition-colors font-bold ${
globalBold
? 'bg-teal-600 text-white border-teal-600'
: 'bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600'
}`}
>
B
</button>
<button
onClick={() => setImageRotation(r => r === 0 ? 180 : 0)}
className={`px-2 py-1 text-xs rounded border transition-colors ${
imageRotation === 180
? 'bg-teal-600 text-white border-teal-600'
: 'bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600'
}`}
title="Bild 180° drehen"
>
180°
</button>
{(structureBoxes.length > 0 || structureGraphics.length > 0) && (
<button
onClick={() => setShowStructure(v => !v)}
className={`px-2 py-1 text-xs border rounded transition-colors ${
showStructure
? 'border-violet-300 bg-violet-50 text-violet-600 dark:border-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
title="Strukturelemente anzeigen"
>
Struktur
</button>
)}
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
</>
)}
{/* Non-overlay controls */}
{editorMode !== 'overlay' && (
<>
{/* Empty field toggle */}
<button
onClick={() => setShowEmptyHighlight(v => !v)}
className={`px-2 py-1 text-xs border rounded transition-colors ${
showEmptyHighlight
? 'border-red-300 bg-red-50 text-red-600 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
title="Leere Pflichtfelder markieren"
>
Leer
</button>
{/* Structure toggle */}
{(structureBoxes.length > 0 || structureGraphics.length > 0) && (
<button
onClick={() => setShowStructure(v => !v)}
className={`px-2 py-1 text-xs border rounded transition-colors ${
showStructure
? 'border-violet-300 bg-violet-50 text-violet-600 dark:border-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
title="Strukturelemente anzeigen"
>
Struktur
</button>
)}
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Zoom controls */}
<button
onClick={() => setZoom(z => Math.max(50, z - 25))}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
>
&minus;
</button>
<span className="text-xs text-gray-500 w-10 text-center">{zoom}%</span>
<button
onClick={() => setZoom(z => Math.min(200, z + 25))}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
>
+
</button>
<button
onClick={() => setZoom(100)}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
>
Fit
</button>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
</>
)}
<button
onClick={saveReconstruction}
disabled={status === 'saving'}
className="px-4 py-1.5 text-xs bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 transition-colors font-medium"
>
{status === 'saving' ? 'Speichert...' : 'Speichern'}
</button>
</div>
</div>
{/* Reconstruction canvas */}
{editorMode === 'overlay' ? (
renderOverlayMode()
) : editorMode === 'editor' && sessionId ? (
<FabricReconstructionCanvas
sessionId={sessionId}
cells={gridCells}
onCellsChanged={handleFabricCellsChanged}
/>
) : (
<div className="border rounded-lg overflow-auto dark:border-gray-700 bg-gray-100 dark:bg-gray-900" style={{ maxHeight: '75vh' }}>
<div
ref={containerRef}
className="relative inline-block"
style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'top left' }}
>
{/* Background image at reduced opacity */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imageRef}
src={dewarpedUrl}
alt="Dewarped"
className="block"
style={{ opacity: 0.3 }}
onLoad={handleImageLoad}
/>
{/* Structure elements (boxes, graphics) */}
{imageNaturalSize && renderStructureLayer(imageNaturalSize.w, imageNaturalSize.h)}
{/* Empty field markers */}
{showEmptyHighlight && cells
.filter(c => emptyCellIds.has(c.cellId))
.map(cell => (
<div
key={`empty-${cell.cellId}`}
className="absolute border-2 border-dashed border-red-400/60 rounded pointer-events-none"
style={{
left: `${cell.bboxPct.x}%`,
top: `${cell.bboxPct.y}%`,
width: `${cell.bboxPct.w}%`,
height: `${cell.bboxPct.h}%`,
}}
/>
))}
{/* Editable text fields at bbox positions */}
{cells.map((cell) => {
const displayText = getDisplayText(cell)
const edited = isEdited(cell)
return (
<div key={cell.cellId} className="absolute group" style={{
left: `${cell.bboxPct.x}%`,
top: `${cell.bboxPct.y}%`,
width: `${cell.bboxPct.w}%`,
height: `${cell.bboxPct.h}%`,
}}>
<input
id={`cell-${cell.cellId}`}
type="text"
value={displayText}
onChange={(e) => handleTextChange(cell.cellId, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, cell.cellId)}
className={`w-full h-full bg-transparent text-black dark:text-white border px-0.5 outline-none transition-colors ${
colTypeColor(cell.colType)
} ${edited ? 'border-green-500 bg-green-50/30 dark:bg-green-900/20' : ''}`}
style={{
fontSize: `${getFontSize(cell.bboxPct.h)}px`,
lineHeight: '1',
}}
title={`${cell.cellId} (${cell.colType})`}
/>
{/* Per-cell reset button (X) — only shown for edited cells on hover */}
{edited && (
<button
onClick={() => resetCell(cell.cellId)}
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] leading-none opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Zuruecksetzen"
>
&times;
</button>
)}
</div>
)
})}
</div>
</div>
)}
{/* Bottom action */}
<div className="flex justify-end">
<button
onClick={() => {
if (changedCount > 0) {
saveReconstruction()
} else {
onNext()
}
}}
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium text-sm"
>
{changedCount > 0 ? 'Speichern & Weiter \u2192' : 'Weiter \u2192'}
</button>
</div>
</div>
)
}