feat(worksheet-editor): Add OCR import panel for grid analysis data
Add OCRImportPanel component and ocr-integration utilities to import OCR-analyzed data from the grid detection service into the worksheet editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import { ExportPanel } from '@/components/worksheet-editor/ExportPanel'
|
|||||||
import { AIPromptBar } from '@/components/worksheet-editor/AIPromptBar'
|
import { AIPromptBar } from '@/components/worksheet-editor/AIPromptBar'
|
||||||
import { DocumentImporter } from '@/components/worksheet-editor/DocumentImporter'
|
import { DocumentImporter } from '@/components/worksheet-editor/DocumentImporter'
|
||||||
import { CleanupPanel } from '@/components/worksheet-editor/CleanupPanel'
|
import { CleanupPanel } from '@/components/worksheet-editor/CleanupPanel'
|
||||||
|
import { OCRImportPanel } from '@/components/worksheet-editor/OCRImportPanel'
|
||||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ function WorksheetEditorContent() {
|
|||||||
const [isExportPanelOpen, setIsExportPanelOpen] = useState(false)
|
const [isExportPanelOpen, setIsExportPanelOpen] = useState(false)
|
||||||
const [isDocumentImporterOpen, setIsDocumentImporterOpen] = useState(false)
|
const [isDocumentImporterOpen, setIsDocumentImporterOpen] = useState(false)
|
||||||
const [isCleanupPanelOpen, setIsCleanupPanelOpen] = useState(false)
|
const [isCleanupPanelOpen, setIsCleanupPanelOpen] = useState(false)
|
||||||
|
const [isOCRImportOpen, setIsOCRImportOpen] = useState(false)
|
||||||
const [isDocumentListOpen, setIsDocumentListOpen] = useState(false)
|
const [isDocumentListOpen, setIsDocumentListOpen] = useState(false)
|
||||||
const [savedWorksheets, setSavedWorksheets] = useState<SavedWorksheet[]>([])
|
const [savedWorksheets, setSavedWorksheets] = useState<SavedWorksheet[]>([])
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
@@ -300,6 +302,7 @@ function WorksheetEditorContent() {
|
|||||||
onOpenAIGenerator={() => setIsAIGeneratorOpen(true)}
|
onOpenAIGenerator={() => setIsAIGeneratorOpen(true)}
|
||||||
onOpenDocumentImporter={() => setIsDocumentImporterOpen(true)}
|
onOpenDocumentImporter={() => setIsDocumentImporterOpen(true)}
|
||||||
onOpenCleanupPanel={() => setIsCleanupPanelOpen(true)}
|
onOpenCleanupPanel={() => setIsCleanupPanelOpen(true)}
|
||||||
|
onOpenOCRImport={() => setIsOCRImportOpen(true)}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,6 +474,11 @@ function WorksheetEditorContent() {
|
|||||||
isOpen={isCleanupPanelOpen}
|
isOpen={isCleanupPanelOpen}
|
||||||
onClose={() => setIsCleanupPanelOpen(false)}
|
onClose={() => setIsCleanupPanelOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<OCRImportPanel
|
||||||
|
isOpen={isOCRImportOpen}
|
||||||
|
onClose={() => setIsOCRImportOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface EditorToolbarProps {
|
|||||||
onOpenAIGenerator: () => void
|
onOpenAIGenerator: () => void
|
||||||
onOpenDocumentImporter: () => void
|
onOpenDocumentImporter: () => void
|
||||||
onOpenCleanupPanel?: () => void
|
onOpenCleanupPanel?: () => void
|
||||||
|
onOpenOCRImport?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ function ToolButton({ tool, icon, label, isActive, onClick, isDark }: ToolButton
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorToolbar({ onOpenAIGenerator, onOpenDocumentImporter, onOpenCleanupPanel, className = '' }: EditorToolbarProps) {
|
export function EditorToolbar({ onOpenAIGenerator, onOpenDocumentImporter, onOpenCleanupPanel, onOpenOCRImport, className = '' }: EditorToolbarProps) {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -251,6 +252,23 @@ export function EditorToolbar({ onOpenAIGenerator, onOpenDocumentImporter, onOpe
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* OCR Import */}
|
||||||
|
{onOpenOCRImport && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenOCRImport}
|
||||||
|
title="OCR Daten importieren (aus Grid Analyse)"
|
||||||
|
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
|
||||||
|
isDark
|
||||||
|
? 'text-green-300 hover:bg-green-500/20 hover:text-green-200'
|
||||||
|
: 'text-green-600 hover:bg-green-100 hover:text-green-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={`border-t ${dividerStyle}`} />
|
<div className={`border-t ${dividerStyle}`} />
|
||||||
|
|
||||||
{/* Table Tool */}
|
{/* Table Tool */}
|
||||||
|
|||||||
471
studio-v2/components/worksheet-editor/OCRImportPanel.tsx
Normal file
471
studio-v2/components/worksheet-editor/OCRImportPanel.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCR Import Panel
|
||||||
|
*
|
||||||
|
* Loads OCR export data from the klausur-service API (shared between admin-v2 and studio-v2)
|
||||||
|
* and imports recognized words as editable text objects onto the Fabric.js canvas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||||
|
import type { OCRExportData, OCRWord } from '@/lib/worksheet-editor/ocr-integration'
|
||||||
|
import { createTextProps, getColumnColor } from '@/lib/worksheet-editor/ocr-integration'
|
||||||
|
|
||||||
|
interface OCRImportPanelProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OCRImportPanel({ isOpen, onClose }: OCRImportPanelProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const { canvas, saveToHistory } = useWorksheet()
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [ocrData, setOcrData] = useState<OCRExportData | null>(null)
|
||||||
|
const [selectedWords, setSelectedWords] = useState<Set<number>>(new Set())
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [importSuccess, setImportSuccess] = useState(false)
|
||||||
|
|
||||||
|
// Load OCR data when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
loadOCRData()
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const loadOCRData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setOcrData(null)
|
||||||
|
setSelectedWords(new Set())
|
||||||
|
setImportSuccess(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/klausur-api/api/v1/vocab/ocr-export/latest')
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('not_found')
|
||||||
|
}
|
||||||
|
const data: OCRExportData = await res.json()
|
||||||
|
setOcrData(data)
|
||||||
|
// Select all words by default
|
||||||
|
setSelectedWords(new Set(data.words.map((_, i) => i)))
|
||||||
|
} catch {
|
||||||
|
setError(
|
||||||
|
'Keine OCR-Daten gefunden. Bitte zuerst im OCR-Compare Tool "Zum Editor exportieren" klicken.'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleWord = useCallback((index: number) => {
|
||||||
|
setSelectedWords(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(index)) {
|
||||||
|
next.delete(index)
|
||||||
|
} else {
|
||||||
|
next.add(index)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectAll = useCallback(() => {
|
||||||
|
if (!ocrData) return
|
||||||
|
setSelectedWords(new Set(ocrData.words.map((_, i) => i)))
|
||||||
|
}, [ocrData])
|
||||||
|
|
||||||
|
const deselectAll = useCallback(() => {
|
||||||
|
setSelectedWords(new Set())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleImport = useCallback(async () => {
|
||||||
|
if (!ocrData || !canvas || selectedWords.size === 0) return
|
||||||
|
|
||||||
|
setImporting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamic import of fabric to avoid SSR issues
|
||||||
|
const { IText } = await import('fabric')
|
||||||
|
|
||||||
|
// Save current state for undo
|
||||||
|
if (saveToHistory) {
|
||||||
|
saveToHistory('OCR Import')
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordsToImport = ocrData.words.filter((_, i) => selectedWords.has(i))
|
||||||
|
|
||||||
|
for (const word of wordsToImport) {
|
||||||
|
const props = createTextProps(word)
|
||||||
|
|
||||||
|
const textObj = new IText(props.text, {
|
||||||
|
left: props.left,
|
||||||
|
top: props.top,
|
||||||
|
fontSize: props.fontSize,
|
||||||
|
fontFamily: props.fontFamily,
|
||||||
|
fill: props.fill,
|
||||||
|
editable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
canvas.add(textObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.renderAll()
|
||||||
|
setImportSuccess(true)
|
||||||
|
|
||||||
|
// Close after brief delay
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose()
|
||||||
|
}, 1500)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Import failed:', e)
|
||||||
|
setError('Import fehlgeschlagen. Bitte erneut versuchen.')
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}, [ocrData, canvas, selectedWords, saveToHistory, onClose])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
// Count column types
|
||||||
|
const columnSummary = ocrData
|
||||||
|
? ocrData.words.reduce<Record<string, number>>((acc, w) => {
|
||||||
|
acc[w.column_type] = (acc[w.column_type] || 0) + 1
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
: {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div
|
||||||
|
className={`relative w-full max-w-2xl max-h-[85vh] rounded-3xl overflow-hidden flex flex-col ${
|
||||||
|
isDark
|
||||||
|
? 'bg-slate-900/95 border border-white/10'
|
||||||
|
: 'bg-white/95 border border-slate-200'
|
||||||
|
} backdrop-blur-xl`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between px-6 py-4 border-b ${
|
||||||
|
isDark ? 'border-white/10' : 'border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
className={`text-xl font-bold ${
|
||||||
|
isDark ? 'text-white' : 'text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
OCR Daten importieren
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className={`text-sm mt-0.5 ${
|
||||||
|
isDark ? 'text-white/50' : 'text-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Erkannte Texte aus der Grid-Analyse einfuegen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 ${isDark ? 'text-white' : 'text-slate-600'}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-green-500 border-t-transparent rounded-full" />
|
||||||
|
<p
|
||||||
|
className={`mt-3 text-sm ${
|
||||||
|
isDark ? 'text-white/50' : 'text-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Lade OCR-Daten...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded-xl text-center ${
|
||||||
|
isDark
|
||||||
|
? 'bg-red-500/10 text-red-300'
|
||||||
|
: 'bg-red-50 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 mx-auto mb-2 opacity-50"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={loadOCRData}
|
||||||
|
className={`mt-3 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isDark
|
||||||
|
? 'bg-white/10 text-white hover:bg-white/20'
|
||||||
|
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success State */}
|
||||||
|
{importSuccess && (
|
||||||
|
<div
|
||||||
|
className={`p-6 rounded-xl text-center ${
|
||||||
|
isDark
|
||||||
|
? 'bg-green-500/10 text-green-300'
|
||||||
|
: 'bg-green-50 text-green-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12 mx-auto mb-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="font-medium">
|
||||||
|
{selectedWords.size} Texte erfolgreich importiert!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OCR Data Display */}
|
||||||
|
{ocrData && !importSuccess && (
|
||||||
|
<>
|
||||||
|
{/* Summary */}
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded-xl mb-4 ${
|
||||||
|
isDark ? 'bg-white/5' : 'bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-3 mb-2">
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-lg text-sm font-medium ${
|
||||||
|
isDark
|
||||||
|
? 'bg-green-500/20 text-green-300'
|
||||||
|
: 'bg-green-100 text-green-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ocrData.words.length} Woerter
|
||||||
|
</span>
|
||||||
|
{Object.entries(columnSummary).map(([type, count]) => (
|
||||||
|
<span
|
||||||
|
key={type}
|
||||||
|
className="px-3 py-1 rounded-lg text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getColumnColor(type as any) + '15',
|
||||||
|
color: getColumnColor(type as any),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{type === 'english'
|
||||||
|
? 'Englisch'
|
||||||
|
: type === 'german'
|
||||||
|
? 'Deutsch'
|
||||||
|
: type === 'example'
|
||||||
|
? 'Beispiel'
|
||||||
|
: 'Unbekannt'}
|
||||||
|
: {count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-xs ${
|
||||||
|
isDark ? 'text-white/40' : 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Session: {ocrData.session_id.slice(0, 8)}... | Seite{' '}
|
||||||
|
{ocrData.page_number} | Exportiert:{' '}
|
||||||
|
{new Date(ocrData.exported_at).toLocaleString('de-DE')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selection Controls */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
isDark ? 'text-white/70' : 'text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedWords.size} von {ocrData.words.length} ausgewaehlt
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={selectAll}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
isDark
|
||||||
|
? 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||||
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={deselectAll}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
isDark
|
||||||
|
? 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||||
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Keine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Word List */}
|
||||||
|
<div className="space-y-1 max-h-[40vh] overflow-y-auto">
|
||||||
|
{ocrData.words.map((word, idx) => (
|
||||||
|
<label
|
||||||
|
key={idx}
|
||||||
|
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
|
||||||
|
selectedWords.has(idx)
|
||||||
|
? isDark
|
||||||
|
? 'bg-green-500/10'
|
||||||
|
: 'bg-green-50'
|
||||||
|
: isDark
|
||||||
|
? 'hover:bg-white/5'
|
||||||
|
: 'hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedWords.has(idx)}
|
||||||
|
onChange={() => toggleWord(idx)}
|
||||||
|
className="rounded border-slate-300"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getColumnColor(word.column_type),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm flex-1 ${
|
||||||
|
isDark ? 'text-white' : 'text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{word.text}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
isDark ? 'bg-white/10 text-white/40' : 'bg-slate-100 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{word.column_type === 'english'
|
||||||
|
? 'EN'
|
||||||
|
: word.column_type === 'german'
|
||||||
|
? 'DE'
|
||||||
|
: word.column_type === 'example'
|
||||||
|
? 'Ex'
|
||||||
|
: '?'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{ocrData && !importSuccess && (
|
||||||
|
<div
|
||||||
|
className={`px-6 py-4 border-t ${
|
||||||
|
isDark ? 'border-white/10' : 'border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
|
||||||
|
isDark
|
||||||
|
? 'text-white/60 hover:bg-white/10'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importing || selectedWords.size === 0}
|
||||||
|
className={`px-6 py-2.5 rounded-xl text-sm font-medium transition-colors flex items-center gap-2 ${
|
||||||
|
selectedWords.size > 0
|
||||||
|
? 'bg-green-600 text-white hover:bg-green-700'
|
||||||
|
: isDark
|
||||||
|
? 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||||
|
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{importing ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{selectedWords.size} Texte importieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,3 +13,4 @@ export { PageNavigator } from './PageNavigator'
|
|||||||
export { ExportPanel } from './ExportPanel'
|
export { ExportPanel } from './ExportPanel'
|
||||||
export { DocumentImporter } from './DocumentImporter'
|
export { DocumentImporter } from './DocumentImporter'
|
||||||
export { CleanupPanel } from './CleanupPanel'
|
export { CleanupPanel } from './CleanupPanel'
|
||||||
|
export { OCRImportPanel } from './OCRImportPanel'
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ export { WorksheetProvider, useWorksheet } from './WorksheetContext'
|
|||||||
|
|
||||||
// Cleanup Service for handwriting removal
|
// Cleanup Service for handwriting removal
|
||||||
export * from './cleanup-service'
|
export * from './cleanup-service'
|
||||||
|
|
||||||
|
// OCR Integration utilities
|
||||||
|
export * from './ocr-integration'
|
||||||
|
|||||||
288
studio-v2/lib/worksheet-editor/ocr-integration.ts
Normal file
288
studio-v2/lib/worksheet-editor/ocr-integration.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* OCR Integration Utility
|
||||||
|
*
|
||||||
|
* Provides types, conversion functions, and import/export utilities
|
||||||
|
* for sharing OCR data between admin-v2 (OCR Compare) and studio-v2 (Worksheet Editor).
|
||||||
|
*
|
||||||
|
* Both frontends proxy to klausur-service via /klausur-api/, enabling
|
||||||
|
* shared API-based storage since localStorage is port-isolated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Conversion factor: 1mm = 3.7795275591 pixels at 96 DPI */
|
||||||
|
export const MM_TO_PX = 96 / 25.4 // 3.7795275591
|
||||||
|
|
||||||
|
/** A4 dimensions in millimeters */
|
||||||
|
export const A4_WIDTH_MM = 210
|
||||||
|
export const A4_HEIGHT_MM = 297
|
||||||
|
|
||||||
|
/** A4 dimensions in pixels at 96 DPI */
|
||||||
|
export const A4_WIDTH_PX = Math.round(A4_WIDTH_MM * MM_TO_PX)
|
||||||
|
export const A4_HEIGHT_PX = Math.round(A4_HEIGHT_MM * MM_TO_PX)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type ColumnType = 'english' | 'german' | 'example' | 'unknown'
|
||||||
|
|
||||||
|
export interface OCRWord {
|
||||||
|
text: string
|
||||||
|
x_mm: number
|
||||||
|
y_mm: number
|
||||||
|
width_mm: number
|
||||||
|
height_mm: number
|
||||||
|
column_type: ColumnType
|
||||||
|
logical_row: number
|
||||||
|
confidence?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OCRExportData {
|
||||||
|
version: string
|
||||||
|
source: string
|
||||||
|
exported_at: string
|
||||||
|
session_id: string
|
||||||
|
page_number: number
|
||||||
|
page_dimensions: {
|
||||||
|
width_mm: number
|
||||||
|
height_mm: number
|
||||||
|
format: string
|
||||||
|
}
|
||||||
|
words: OCRWord[]
|
||||||
|
detected_columns: Array<{
|
||||||
|
column_type: ColumnType
|
||||||
|
x_start_mm?: number
|
||||||
|
x_end_mm?: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Conversion Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Convert millimeters to pixels at 96 DPI */
|
||||||
|
export function mmToPixel(mm: number): number {
|
||||||
|
return mm * MM_TO_PX
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert pixels to millimeters at 96 DPI */
|
||||||
|
export function pixelToMm(px: number): number {
|
||||||
|
return px / MM_TO_PX
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Color Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ColorOptions {
|
||||||
|
englishColor?: string
|
||||||
|
germanColor?: string
|
||||||
|
exampleColor?: string
|
||||||
|
unknownColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get color for a column type */
|
||||||
|
export function getColumnColor(
|
||||||
|
columnType: ColumnType,
|
||||||
|
options?: ColorOptions
|
||||||
|
): string {
|
||||||
|
switch (columnType) {
|
||||||
|
case 'english':
|
||||||
|
return options?.englishColor ?? '#1e40af'
|
||||||
|
case 'german':
|
||||||
|
return options?.germanColor ?? '#166534'
|
||||||
|
case 'example':
|
||||||
|
return options?.exampleColor ?? '#6b21a8'
|
||||||
|
case 'unknown':
|
||||||
|
default:
|
||||||
|
return options?.unknownColor ?? '#374151'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Canvas Integration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface TextPropsOptions {
|
||||||
|
offsetX?: number
|
||||||
|
offsetY?: number
|
||||||
|
fontFamily?: string
|
||||||
|
fontSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create Fabric.js IText properties from an OCR word */
|
||||||
|
export function createTextProps(
|
||||||
|
word: OCRWord,
|
||||||
|
options?: TextPropsOptions
|
||||||
|
): Record<string, any> {
|
||||||
|
const offsetX = options?.offsetX ?? 0
|
||||||
|
const offsetY = options?.offsetY ?? 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'i-text',
|
||||||
|
text: word.text,
|
||||||
|
left: mmToPixel(word.x_mm + offsetX),
|
||||||
|
top: mmToPixel(word.y_mm + offsetY),
|
||||||
|
fontSize: options?.fontSize ?? 14,
|
||||||
|
fontFamily: options?.fontFamily ?? 'Arial',
|
||||||
|
fill: getColumnColor(word.column_type),
|
||||||
|
editable: true,
|
||||||
|
ocrMetadata: {
|
||||||
|
x_mm: word.x_mm,
|
||||||
|
y_mm: word.y_mm,
|
||||||
|
width_mm: word.width_mm,
|
||||||
|
height_mm: word.height_mm,
|
||||||
|
column_type: word.column_type,
|
||||||
|
logical_row: word.logical_row,
|
||||||
|
confidence: word.confidence,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Export Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Convert grid analysis data to OCR export format */
|
||||||
|
export function exportOCRData(
|
||||||
|
gridData: {
|
||||||
|
cells: Array<Array<Record<string, any>>>
|
||||||
|
detected_columns: Array<Record<string, any>>
|
||||||
|
page_dimensions: { width_mm: number; height_mm: number; format: string }
|
||||||
|
},
|
||||||
|
sessionId: string,
|
||||||
|
pageNumber: number
|
||||||
|
): OCRExportData {
|
||||||
|
const words: OCRWord[] = []
|
||||||
|
|
||||||
|
for (const row of gridData.cells) {
|
||||||
|
for (const cell of row) {
|
||||||
|
if (!cell.text || cell.status === 'empty') continue
|
||||||
|
words.push({
|
||||||
|
text: cell.text,
|
||||||
|
x_mm: cell.x_mm ?? 0,
|
||||||
|
y_mm: cell.y_mm ?? 0,
|
||||||
|
width_mm: cell.width_mm ?? 0,
|
||||||
|
height_mm: cell.height_mm ?? 0,
|
||||||
|
column_type: (cell.column_type as ColumnType) ?? 'unknown',
|
||||||
|
logical_row: cell.logical_row ?? 0,
|
||||||
|
confidence: cell.confidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: '1.0',
|
||||||
|
source: 'ocr-compare',
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
session_id: sessionId,
|
||||||
|
page_number: pageNumber,
|
||||||
|
page_dimensions: gridData.page_dimensions,
|
||||||
|
words,
|
||||||
|
detected_columns: gridData.detected_columns.map((col) => ({
|
||||||
|
column_type: (col.column_type as ColumnType) ?? 'unknown',
|
||||||
|
x_start_mm: col.x_start_mm,
|
||||||
|
x_end_mm: col.x_end_mm,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// localStorage Operations (fallback)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'ocr_export_'
|
||||||
|
const LATEST_KEY = 'ocr_export_latest'
|
||||||
|
|
||||||
|
/** Save OCR export data to localStorage */
|
||||||
|
export function saveOCRExportToStorage(data: OCRExportData): void {
|
||||||
|
const key = `${STORAGE_PREFIX}${data.session_id}_${data.page_number}`
|
||||||
|
localStorage.setItem(key, JSON.stringify(data))
|
||||||
|
localStorage.setItem(LATEST_KEY, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the latest OCR export from localStorage */
|
||||||
|
export function loadLatestOCRExport(): OCRExportData | null {
|
||||||
|
try {
|
||||||
|
const latestKey = localStorage.getItem(LATEST_KEY)
|
||||||
|
if (!latestKey) return null
|
||||||
|
|
||||||
|
const raw = localStorage.getItem(latestKey)
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
return JSON.parse(raw) as OCRExportData
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load a specific OCR export from localStorage */
|
||||||
|
export function loadOCRExport(
|
||||||
|
sessionId: string,
|
||||||
|
pageNumber: number
|
||||||
|
): OCRExportData | null {
|
||||||
|
try {
|
||||||
|
const key = `${STORAGE_PREFIX}${sessionId}_${pageNumber}`
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
return JSON.parse(raw) as OCRExportData
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear all OCR exports from localStorage */
|
||||||
|
export function clearOCRExports(): void {
|
||||||
|
const keys = Object.keys(localStorage)
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key.startsWith(STORAGE_PREFIX)) {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// API Operations (primary - shared across ports)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const API_BASE = '/klausur-api/api/v1/vocab'
|
||||||
|
|
||||||
|
/** Save OCR export data via API (with localStorage fallback) */
|
||||||
|
export async function saveOCRExportToAPI(data: OCRExportData): Promise<boolean> {
|
||||||
|
// Always save to localStorage as fallback
|
||||||
|
saveOCRExportToStorage(data)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/sessions/${data.session_id}/ocr-export/${data.page_number}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return res.ok
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('API save failed, localStorage fallback used:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the latest OCR export from API (with localStorage fallback) */
|
||||||
|
export async function loadLatestOCRExportFromAPI(): Promise<OCRExportData | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ocr-export/latest`)
|
||||||
|
if (res.ok) {
|
||||||
|
return (await res.json()) as OCRExportData
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('API load failed, trying localStorage fallback:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to localStorage
|
||||||
|
return loadLatestOCRExport()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user