'use client' /** * OCR Comparison Tool * * Zeigt Original-PDF neben den Extraktionsergebnissen von verschiedenen OCR-Methoden. * Ermoeglicht direkten visuellen Vergleich mit voller Breite. * Bietet Session-Historie fuer Verbesserungsvergleiche. */ import { useState, useEffect, useCallback, useMemo } from 'react' import { PagePurpose } from '@/components/common/PagePurpose' import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar' import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload' import { GridOverlay, GridStats, GridLegend, CellCorrectionDialog, BlockReviewPanel, BlockReviewSummary, getCellBlockNumber, GroundTruthPanel } from '@/components/ocr' import type { GridData, GridCell, BlockReviewData, BlockStatus } from '@/components/ocr' interface VocabEntry { english: string german: string example?: string } interface MethodResult { name: string model: string duration_seconds: number vocabulary_count: number vocabulary: VocabEntry[] confidence: number error?: string success: boolean } interface ComparisonResult { session_id: string page_number: number methods: Record comparison: { found_by_all_methods: Array<{ english: string; german: string; methods: string[] }> found_by_some_methods: Array<{ english: string; german: string; methods: string[] }> total_unique_vocabulary: number agreement_rate: number } recommendation: { best_method: string reason: string } } interface SessionInfo { id: string name: string created_at: string page_count?: number } // OCR-Methoden Konfiguration const OCR_METHODS = { local_llm: { id: 'local_llm', name: 'Loesung A: Lokales 32B LLM', shortName: 'A: Local LLM', model: 'qwen2.5:32b extern', color: 'slate', description: 'Externes 32B LLM', enabled: true, }, vision_llm: { id: 'vision_llm', name: 'Loesung B: Vision LLM', shortName: 'B: Vision LLM', model: 'qwen2.5vl:32b', color: 'blue', description: 'Direkte Bild-zu-Text Extraktion', enabled: true, }, paddleocr: { id: 'paddleocr', name: 'Loesung C: PaddleOCR', shortName: 'C: PaddleOCR', model: 'paddleocr (x86)', color: 'red', description: 'Aktuell deaktiviert (Rosetta)', enabled: false, }, tesseract: { id: 'tesseract', name: 'Loesung D: Tesseract', shortName: 'D: Tesseract', model: 'tesseract + qwen2.5:14b', color: 'purple', description: 'ARM64-nativ, Standard', enabled: true, }, cv_pipeline: { id: 'cv_pipeline', name: 'Loesung E: Document Reconstruction', shortName: 'E: Doc Recon', model: 'opencv + tesseract (multi-pass)', color: 'green', description: 'CV-Pipeline: Deskew, Dewarp, Binarisierung, Multi-Pass OCR', enabled: true, }, } export default function OCRComparePage() { // Session State const [sessionId, setSessionId] = useState(null) const [pageCount, setPageCount] = useState(0) const [selectedPage, setSelectedPage] = useState(0) const [thumbnails, setThumbnails] = useState([]) const [loadingThumbnails, setLoadingThumbnails] = useState(false) // Session History const [sessions, setSessions] = useState([]) const [loadingSessions, setLoadingSessions] = useState(false) const [showHistory, setShowHistory] = useState(false) // Comparison State const [comparing, setComparing] = useState(false) const [result, setResult] = useState(null) const [error, setError] = useState(null) const [uploading, setUploading] = useState(false) // Method Selection const [selectedMethods, setSelectedMethods] = useState(['vision_llm', 'tesseract', 'cv_pipeline']) // QR Upload State const [showQRModal, setShowQRModal] = useState(false) const [qrUploadSessionId, setQrUploadSessionId] = useState('') const [mobileUploadedFiles, setMobileUploadedFiles] = useState([]) // View Mode State const [isFullscreen, setIsFullscreen] = useState(false) const [expandedMethod, setExpandedMethod] = useState(null) // For single document view const [visibleMethods, setVisibleMethods] = useState([]) // For custom multi-column view // Grid Detection State const [gridData, setGridData] = useState(null) const [analyzingGrid, setAnalyzingGrid] = useState(false) const [showGridOverlay, setShowGridOverlay] = useState(true) const [selectedCell, setSelectedCell] = useState(null) const [showCellDialog, setShowCellDialog] = useState(false) const [showMmGrid, setShowMmGrid] = useState(false) const [showTextAtPosition, setShowTextAtPosition] = useState(false) const [editableText, setEditableText] = useState(false) // Block Review State const [blockReviewMode, setBlockReviewMode] = useState(false) const [currentBlockNumber, setCurrentBlockNumber] = useState(1) const [blockReviewData, setBlockReviewData] = useState>({}) // Export State const [isExporting, setIsExporting] = useState(false) const [exportSuccess, setExportSuccess] = useState(false) // Tab State (compare vs ground truth) const [activeTab, setActiveTab] = useState<'compare' | 'groundtruth'>('compare') const KLAUSUR_API = '/klausur-api' // Load session history const loadSessions = useCallback(async () => { setLoadingSessions(true) try { const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions`) if (res.ok) { const data = await res.json() // Filter to only show OCR Vergleich sessions and sort by date const ocrSessions = (data.sessions || data || []) .filter((s: SessionInfo) => s.name?.includes('OCR Vergleich')) .sort((a: SessionInfo, b: SessionInfo) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ) .slice(0, 20) // Limit to 20 most recent setSessions(ocrSessions) } } catch (e) { console.error('Failed to load sessions:', e) } finally { setLoadingSessions(false) } }, []) // Initialize and restore session useEffect(() => { loadSessions() let sid = localStorage.getItem('ocr-compare-upload-session') if (!sid) { sid = `ocr-compare-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` localStorage.setItem('ocr-compare-upload-session', sid) } setQrUploadSessionId(sid) // Restore last active session if available const lastSessionId = localStorage.getItem('ocr-compare-active-session') if (lastSessionId) { // Load the session data fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${lastSessionId}`) .then(res => { if (res.ok) return res.json() throw new Error('Session not found') }) .then(data => { setSessionId(lastSessionId) setPageCount(data.page_count || 1) setSelectedPage(0) loadAllThumbnails(lastSessionId, data.page_count || 1) }) .catch(() => { // Session no longer exists, clear localStorage localStorage.removeItem('ocr-compare-active-session') }) } }, [loadSessions]) // ESC key to exit fullscreen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (expandedMethod) { setExpandedMethod(null) } else if (isFullscreen) { setIsFullscreen(false) } } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [isFullscreen, expandedMethod]) // Load a session from history const loadSession = async (session: SessionInfo) => { setSessionId(session.id) localStorage.setItem('ocr-compare-active-session', session.id) setResult(null) setThumbnails([]) try { // Get session details const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${session.id}`) if (res.ok) { const data = await res.json() setPageCount(data.page_count || 1) setSelectedPage(0) // Load thumbnails await loadAllThumbnails(session.id, data.page_count || 1) } } catch (e) { setError('Session konnte nicht geladen werden') } } // Handle mobile file upload const handleMobileFile = useCallback(async (file: UploadedFile) => { if (!file.dataUrl) return setUploading(true) setError(null) setResult(null) setThumbnails([]) try { // Create session const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `OCR Vergleich - ${file.name}` }) }) if (!sessionRes.ok) throw new Error('Session konnte nicht erstellt werden') const sessionData = await sessionRes.json() setSessionId(sessionData.id) localStorage.setItem('ocr-compare-active-session', sessionData.id) // Convert dataUrl to blob and upload const response = await fetch(file.dataUrl) const blob = await response.blob() const formData = new FormData() formData.append('file', blob, file.name) const uploadRes = await fetch( `${KLAUSUR_API}/api/v1/vocab/sessions/${sessionData.id}/upload-pdf-info`, { method: 'POST', body: formData } ) if (!uploadRes.ok) throw new Error('PDF Upload fehlgeschlagen') const uploadData = await uploadRes.json() setPageCount(uploadData.page_count || 1) setSelectedPage(0) // Load thumbnails await loadAllThumbnails(sessionData.id, uploadData.page_count || 1) // Refresh session list loadSessions() } catch (err) { setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen') } finally { setUploading(false) } }, [loadSessions]) // Watch for new mobile files useEffect(() => { if (mobileUploadedFiles.length > 0) { const latestFile = mobileUploadedFiles[mobileUploadedFiles.length - 1] handleMobileFile(latestFile) setShowQRModal(false) } }, [mobileUploadedFiles, handleMobileFile]) const handleFileUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return setUploading(true) setError(null) setResult(null) setThumbnails([]) try { // Create session const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `OCR Vergleich - ${file.name}` }) }) if (!sessionRes.ok) throw new Error('Session konnte nicht erstellt werden') const sessionData = await sessionRes.json() setSessionId(sessionData.id) localStorage.setItem('ocr-compare-active-session', sessionData.id) // Upload PDF const formData = new FormData() formData.append('file', file) const uploadRes = await fetch( `${KLAUSUR_API}/api/v1/vocab/sessions/${sessionData.id}/upload-pdf-info`, { method: 'POST', body: formData } ) if (!uploadRes.ok) throw new Error('PDF Upload fehlgeschlagen') const uploadData = await uploadRes.json() setPageCount(uploadData.page_count || 1) setSelectedPage(0) // Load all thumbnails await loadAllThumbnails(sessionData.id, uploadData.page_count || 1) // Refresh session list loadSessions() } catch (err) { setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen') } finally { setUploading(false) } } const loadAllThumbnails = async (sid: string, count: number) => { setLoadingThumbnails(true) const thumbs: string[] = [] for (let i = 0; i < count; i++) { try { const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sid}/pdf-thumbnail/${i}?hires=true`) if (res.ok) { const blob = await res.blob() thumbs.push(URL.createObjectURL(blob)) } else { thumbs.push('') } } catch { thumbs.push('') } } setThumbnails(thumbs) setLoadingThumbnails(false) } const toggleMethod = (methodId: string) => { setSelectedMethods(prev => prev.includes(methodId) ? prev.filter(m => m !== methodId) : [...prev, methodId] ) } const runComparison = async () => { if (!sessionId || selectedMethods.length === 0) return setComparing(true) setError(null) try { const res = await fetch( `${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/compare-ocr/${selectedPage}`, { method: 'POST' } ) if (!res.ok) throw new Error(`Vergleich fehlgeschlagen: ${res.status}`) const data = await res.json() setResult(data) } catch (err) { setError(err instanceof Error ? err.message : 'Vergleich fehlgeschlagen') } finally { setComparing(false) } } // Grid Analysis const analyzeGrid = async () => { if (!sessionId) return setAnalyzingGrid(true) setError(null) try { const res = await fetch( `${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/analyze-grid/${selectedPage}`, { method: 'POST' } ) if (!res.ok) throw new Error(`Grid-Analyse fehlgeschlagen: ${res.status}`) const data = await res.json() if (data.success && data.grid) { setGridData(data.grid) } else { setError(data.error || 'Grid-Erkennung fehlgeschlagen') } } catch (err) { setError(err instanceof Error ? err.message : 'Grid-Analyse fehlgeschlagen') } finally { setAnalyzingGrid(false) } } // Handle cell click for correction const handleCellClick = useCallback((cell: GridCell) => { setSelectedCell(cell) setShowCellDialog(true) }, []) // Handle cell save const handleCellSave = useCallback((text: string) => { if (!gridData || !selectedCell) return // Update local grid data const updatedCells = gridData.cells.map(row => row.map(cell => cell.row === selectedCell.row && cell.col === selectedCell.col ? { ...cell, text, status: 'manual' as const, confidence: 1.0 } : cell ) ) // Recalculate stats const recognized = updatedCells.flat().filter(c => c.status === 'recognized').length const manual = updatedCells.flat().filter(c => c.status === 'manual').length const problematic = updatedCells.flat().filter(c => c.status === 'problematic').length const total = updatedCells.flat().length setGridData({ ...gridData, cells: updatedCells, stats: { ...gridData.stats, recognized, manual, problematic, empty: total - recognized - manual - problematic, coverage: (recognized + manual) / total } }) setShowCellDialog(false) setSelectedCell(null) }, [gridData, selectedCell]) // Block Review Handlers const handleBlockApprove = useCallback((blockNumber: number, methodId: string, text: string) => { if (!gridData) return const cell = gridData.cells.flat().find(c => getCellBlockNumber(c, gridData) === blockNumber) if (!cell) return setBlockReviewData(prev => ({ ...prev, [blockNumber]: { blockNumber, cell, methodResults: [], status: 'approved' as BlockStatus, correctedText: text, approvedMethodId: methodId, } })) }, [gridData]) const handleBlockCorrect = useCallback((blockNumber: number, correctedText: string) => { if (!gridData) return const cell = gridData.cells.flat().find(c => getCellBlockNumber(c, gridData) === blockNumber) if (!cell) return setBlockReviewData(prev => ({ ...prev, [blockNumber]: { blockNumber, cell, methodResults: [], status: 'corrected' as BlockStatus, correctedText, } })) }, [gridData]) const handleBlockSkip = useCallback((blockNumber: number) => { if (!gridData) return const cell = gridData.cells.flat().find(c => getCellBlockNumber(c, gridData) === blockNumber) if (!cell) return setBlockReviewData(prev => ({ ...prev, [blockNumber]: { blockNumber, cell, methodResults: [], status: 'skipped' as BlockStatus, } })) }, [gridData]) // Start block review mode const startBlockReview = useCallback(() => { if (!gridData) return // Find first non-empty block const firstBlock = gridData.cells.flat().find(c => c.status !== 'empty') if (firstBlock) { setCurrentBlockNumber(getCellBlockNumber(firstBlock, gridData)) setBlockReviewMode(true) } }, [gridData]) // Export to Worksheet Editor const handleExportToEditor = useCallback(async () => { if (!gridData || !sessionId) return setIsExporting(true) setExportSuccess(false) try { // Convert grid cells (percent coordinates) to mm for A4 const A4_WIDTH_MM = 210 const A4_HEIGHT_MM = 297 const words = gridData.cells.flat() .filter(cell => cell.status !== 'empty' && cell.text) .map(cell => ({ text: cell.text, x_mm: (cell.x / 100) * A4_WIDTH_MM, y_mm: (cell.y / 100) * A4_HEIGHT_MM, width_mm: (cell.width / 100) * A4_WIDTH_MM, height_mm: (cell.height / 100) * A4_HEIGHT_MM, column_type: cell.column_type || 'unknown', logical_row: cell.row, confidence: cell.confidence, })) const detectedColumns = gridData.column_types.map((type, idx) => ({ column_type: type, x_start_mm: (gridData.column_boundaries[idx] / 100) * A4_WIDTH_MM, x_end_mm: (gridData.column_boundaries[idx + 1] / 100) * A4_WIDTH_MM, })) const exportData = { version: '1.0', source: 'ocr-compare', exported_at: new Date().toISOString(), session_id: sessionId, page_number: selectedPage + 1, page_dimensions: { width_mm: A4_WIDTH_MM, height_mm: A4_HEIGHT_MM, format: 'A4', }, words, detected_columns: detectedColumns, } const res = await fetch( `${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/ocr-export/${selectedPage + 1}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(exportData), } ) if (res.ok) { setExportSuccess(true) setTimeout(() => setExportSuccess(false), 3000) } } catch (e) { console.error('Export failed:', e) } finally { setIsExporting(false) } }, [gridData, sessionId, selectedPage, KLAUSUR_API]) // Count non-empty blocks const nonEmptyBlockCount = useMemo(() => { if (!gridData) return 0 return gridData.cells.flat().filter(c => c.status !== 'empty').length }, [gridData]) const VocabList = ({ vocab, highlight }: { vocab: VocabEntry[]; highlight?: Set }) => (
{vocab.map((v, idx) => { const key = `${v.english}|${v.german}` const isUnique = highlight?.has(key) return (
{v.english}
{v.german}
{v.example && (
{v.example}
)}
) })}
) const getUniqueVocab = (methodKey: string): Set => { if (!result?.comparison?.found_by_some_methods) return new Set() const unique = new Set() result.comparison.found_by_some_methods.forEach(v => { if (v.methods.includes(methodKey) && v.methods.length === 1) { unique.add(`${v.english}|${v.german}`) } }) return unique } const getMethodColor = (color: string, type: 'bg' | 'border' | 'text') => { const colors: Record> = { slate: { bg: 'bg-slate-50', border: 'border-slate-300', text: 'text-slate-700' }, blue: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' }, red: { bg: 'bg-red-50', border: 'border-red-300', text: 'text-red-700' }, purple: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' }, green: { bg: 'bg-green-50', border: 'border-green-300', text: 'text-green-700' }, } return colors[color]?.[type] || colors.slate[type] } // Anzahl der ausgewaehlten Methoden + 1 fuer das Original const columnCount = selectedMethods.length + 1 return (
{/* KI-Werkzeuge Sidebar */}
{/* Left Sidebar: Upload & History */}
{/* Upload Section */}

PDF hochladen

{uploading && (
Wird hochgeladen...
)} {error && (
{error}
)}
{/* Session History Panel */}
{showHistory && (
{loadingSessions ? (
Lade Sessions...
) : sessions.length === 0 ? (
Keine Sessions vorhanden
) : ( sessions.map(session => ( )) )}
)}
{/* Method Selection */} {sessionId && pageCount > 0 && (

OCR-Methoden

{Object.values(OCR_METHODS).map(method => ( ))}
{/* Grid Analysis Button */}
{/* Grid Overlay Toggle */} {gridData && (
{showTextAtPosition && ( )} {/* Block Review Button */} {result && nonEmptyBlockCount > 0 && ( )} {/* Export to Editor Button */}
)}
)} {/* Grid Stats */} {gridData && (

Grid-Erkennung

)} {/* Block Review Summary */} {blockReviewMode && gridData && Object.keys(blockReviewData).length > 0 && (
setCurrentBlockNumber(blockNumber)} />
)}
{/* Main Content Area */}
{/* Page Thumbnails Grid */} {sessionId && pageCount > 0 && (

Seite auswaehlen ({pageCount} Seiten)

{loadingThumbnails ? (
Lade Seitenvorschau...
) : (
{thumbnails.map((thumb, idx) => ( ))}
)}
)} {/* Tab Bar */} {sessionId && pageCount > 0 && (
)} {/* Ground Truth Panel */} {activeTab === 'groundtruth' && sessionId && ( )} {/* Full-Width Comparison View */} {activeTab === 'compare' && (thumbnails[selectedPage] || result) && sessionId && (
{/* Header with Controls */}

Vergleich - Seite {selectedPage + 1}

{/* Layout Selector - only show after comparison */} {result && (
Ansicht: {[1, 2, 3, 4].map(cols => ( ))}
)} {/* Fullscreen Toggle */}
{/* Single Method Expanded View */} {expandedMethod && (
{expandedMethod === 'original' ? (

Original - Seite {selectedPage + 1}

{thumbnails[selectedPage] ? ( gridData && showGridOverlay ? ( { if (!gridData) return const newCells = gridData.cells.map(row => row.map(c => c.row === cell.row && c.col === cell.col ? { ...c, text: newText, status: 'manual' as const } : c ) ) setGridData({ ...gridData, cells: newCells }) }} highlightedBlockNumber={blockReviewMode ? currentBlockNumber : null} className={`rounded-lg border border-slate-200 overflow-hidden ${isFullscreen ? 'max-h-[80vh] mx-auto' : 'w-full max-w-2xl mx-auto'}`} /> ) : ( {`Seite ) ) : (
Kein Bild verfuegbar
)}
) : ( (() => { const method = OCR_METHODS[expandedMethod as keyof typeof OCR_METHODS] const methodResult = result?.methods?.[expandedMethod] const isBest = result?.recommendation?.best_method === expandedMethod return (

{method.name}

{method.model}

{isBest && ( Beste Methode )}
{methodResult && (
{methodResult.duration_seconds}s
Dauer
{methodResult.vocabulary_count}
Vokabeln
{(methodResult.confidence * 100).toFixed(0)}%
Konfidenz
{methodResult.vocabulary?.length > 0 && (
)}
)}
) })() )}
)} {/* Grid View (Normal or Custom Selection) */} {!expandedMethod && (
0 ? visibleMethods.length : columnCount }, minmax(0, 1fr))` }} > {/* Original PDF Column */} {(visibleMethods.length === 0 || visibleMethods.includes('original')) && (
setExpandedMethod('original')} >

Original

Seite {selectedPage + 1}

{thumbnails[selectedPage] ? (
{/* Show Grid Overlay if available */} {gridData && showGridOverlay ? ( { if (!gridData) return const newCells = gridData.cells.map(row => row.map(c => c.row === cell.row && c.col === cell.col ? { ...c, text: newText, status: 'manual' as const } : c ) ) setGridData({ ...gridData, cells: newCells }) }} highlightedBlockNumber={blockReviewMode ? currentBlockNumber : null} className="rounded-lg border border-slate-200 overflow-hidden" /> ) : ( {`Seite )}
) : (
Kein Bild verfuegbar
)}
)} {/* Method Result Columns */} {selectedMethods .filter(methodId => visibleMethods.length === 0 || visibleMethods.includes(methodId)) .map(methodId => { const method = OCR_METHODS[methodId as keyof typeof OCR_METHODS] const methodResult = result?.methods?.[methodId] const isBest = result?.recommendation?.best_method === methodId return (
setExpandedMethod(methodId)} >

{method.shortName}

{method.model}

{isBest && ( Beste )}
{comparing && !methodResult && (
Extrahiere...
)} {methodResult && (
Dauer: {methodResult.duration_seconds}s
Vokabeln: {methodResult.vocabulary_count}
{methodResult.error && (
{methodResult.error}
)}
{methodResult.vocabulary?.length > 0 && ( )}
)} {!comparing && !methodResult && (
Noch keine Ergebnisse
)}
) })}
)} {/* Method Selector Chips (for custom view) */} {result && visibleMethods.length > 0 && visibleMethods.length < selectedMethods.length + 1 && (
Methoden ein-/ausblenden: {selectedMethods.map(methodId => { const method = OCR_METHODS[methodId as keyof typeof OCR_METHODS] return ( ) })}
)} {/* Block Review Panel */} {blockReviewMode && gridData && result && (

Block-Review

Prüfen Sie jeden Block und wählen Sie die korrekte Erkennung oder korrigieren Sie manuell.

)}
)} {/* Comparison Summary */} {activeTab === 'compare' && result?.comparison && (

Vergleichszusammenfassung

{result.comparison.total_unique_vocabulary}
Gesamt eindeutig
{result.comparison.found_by_all_methods?.length || 0}
Von allen erkannt
{result.comparison.found_by_some_methods?.length || 0}
Unterschiede
{(result.comparison.agreement_rate * 100).toFixed(0)}%
Uebereinstimmung
{result.recommendation && (
Empfehlung: {OCR_METHODS[result.recommendation.best_method as keyof typeof OCR_METHODS]?.name || result.recommendation.best_method}

{result.recommendation.reason}

)} {result.comparison.found_by_some_methods?.length > 0 && (

Unterschiede (gelb markiert):

{result.comparison.found_by_some_methods.map((v, idx) => (
{v.english} = {v.german} (nur: {v.methods.join(', ')})
))}
)}
)} {/* Empty State */} {!sessionId && (

PDF hochladen

Laden Sie ein PDF hoch oder waehlen Sie eine Session aus der Historie, um OCR-Methoden zu vergleichen.

)}
{/* QR Code Upload Modal */} {showQRModal && (
setShowQRModal(false)} />
setShowQRModal(false)} onFilesChanged={(files) => { setMobileUploadedFiles(files) }} />
)} {/* Cell Correction Dialog */} {showCellDialog && selectedCell && sessionId && gridData && ( { setShowCellDialog(false) setSelectedCell(null) }} /> )}
) }