diff --git a/studio-v2/app/worksheet-editor/page.tsx b/studio-v2/app/worksheet-editor/page.tsx index d3be37e..cc5e0c9 100644 --- a/studio-v2/app/worksheet-editor/page.tsx +++ b/studio-v2/app/worksheet-editor/page.tsx @@ -15,6 +15,7 @@ import { ExportPanel } from '@/components/worksheet-editor/ExportPanel' import { AIPromptBar } from '@/components/worksheet-editor/AIPromptBar' import { DocumentImporter } from '@/components/worksheet-editor/DocumentImporter' import { CleanupPanel } from '@/components/worksheet-editor/CleanupPanel' +import { OCRImportPanel } from '@/components/worksheet-editor/OCRImportPanel' import { ThemeToggle } from '@/components/ThemeToggle' import { LanguageDropdown } from '@/components/LanguageDropdown' @@ -51,6 +52,7 @@ function WorksheetEditorContent() { const [isExportPanelOpen, setIsExportPanelOpen] = useState(false) const [isDocumentImporterOpen, setIsDocumentImporterOpen] = useState(false) const [isCleanupPanelOpen, setIsCleanupPanelOpen] = useState(false) + const [isOCRImportOpen, setIsOCRImportOpen] = useState(false) const [isDocumentListOpen, setIsDocumentListOpen] = useState(false) const [savedWorksheets, setSavedWorksheets] = useState([]) const [isSaving, setIsSaving] = useState(false) @@ -300,6 +302,7 @@ function WorksheetEditorContent() { onOpenAIGenerator={() => setIsAIGeneratorOpen(true)} onOpenDocumentImporter={() => setIsDocumentImporterOpen(true)} onOpenCleanupPanel={() => setIsCleanupPanelOpen(true)} + onOpenOCRImport={() => setIsOCRImportOpen(true)} className="h-full" /> @@ -471,6 +474,11 @@ function WorksheetEditorContent() { isOpen={isCleanupPanelOpen} onClose={() => setIsCleanupPanelOpen(false)} /> + + setIsOCRImportOpen(false)} + /> ) } diff --git a/studio-v2/components/worksheet-editor/EditorToolbar.tsx b/studio-v2/components/worksheet-editor/EditorToolbar.tsx index 6ed7769..cb3a2c9 100644 --- a/studio-v2/components/worksheet-editor/EditorToolbar.tsx +++ b/studio-v2/components/worksheet-editor/EditorToolbar.tsx @@ -10,6 +10,7 @@ interface EditorToolbarProps { onOpenAIGenerator: () => void onOpenDocumentImporter: () => void onOpenCleanupPanel?: () => void + onOpenOCRImport?: () => void 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 { t } = useLanguage() const fileInputRef = useRef(null) @@ -251,6 +252,23 @@ export function EditorToolbar({ onOpenAIGenerator, onOpenDocumentImporter, onOpe )} + {/* OCR Import */} + {onOpenOCRImport && ( + + )} +
{/* Table Tool */} diff --git a/studio-v2/components/worksheet-editor/OCRImportPanel.tsx b/studio-v2/components/worksheet-editor/OCRImportPanel.tsx new file mode 100644 index 0000000..669e2d9 --- /dev/null +++ b/studio-v2/components/worksheet-editor/OCRImportPanel.tsx @@ -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(null) + const [ocrData, setOcrData] = useState(null) + const [selectedWords, setSelectedWords] = useState>(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>((acc, w) => { + acc[w.column_type] = (acc[w.column_type] || 0) + 1 + return acc + }, {}) + : {} + + return ( +
+ {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+

+ OCR Daten importieren +

+

+ Erkannte Texte aus der Grid-Analyse einfuegen +

+
+ +
+ + {/* Content */} +
+ {/* Loading */} + {loading && ( +
+
+

+ Lade OCR-Daten... +

+
+ )} + + {/* Error */} + {error && ( +
+ + + +

{error}

+ +
+ )} + + {/* Success State */} + {importSuccess && ( +
+ + + +

+ {selectedWords.size} Texte erfolgreich importiert! +

+
+ )} + + {/* OCR Data Display */} + {ocrData && !importSuccess && ( + <> + {/* Summary */} +
+
+ + {ocrData.words.length} Woerter + + {Object.entries(columnSummary).map(([type, count]) => ( + + {type === 'english' + ? 'Englisch' + : type === 'german' + ? 'Deutsch' + : type === 'example' + ? 'Beispiel' + : 'Unbekannt'} + : {count} + + ))} +
+

+ Session: {ocrData.session_id.slice(0, 8)}... | Seite{' '} + {ocrData.page_number} | Exportiert:{' '} + {new Date(ocrData.exported_at).toLocaleString('de-DE')} +

+
+ + {/* Selection Controls */} +
+ + {selectedWords.size} von {ocrData.words.length} ausgewaehlt + +
+ + +
+
+ + {/* Word List */} +
+ {ocrData.words.map((word, idx) => ( + + ))} +
+ + )} +
+ + {/* Footer */} + {ocrData && !importSuccess && ( +
+
+ + +
+
+ )} +
+
+ ) +} diff --git a/studio-v2/components/worksheet-editor/index.ts b/studio-v2/components/worksheet-editor/index.ts index 82388e2..3a288c7 100644 --- a/studio-v2/components/worksheet-editor/index.ts +++ b/studio-v2/components/worksheet-editor/index.ts @@ -13,3 +13,4 @@ export { PageNavigator } from './PageNavigator' export { ExportPanel } from './ExportPanel' export { DocumentImporter } from './DocumentImporter' export { CleanupPanel } from './CleanupPanel' +export { OCRImportPanel } from './OCRImportPanel' diff --git a/studio-v2/lib/worksheet-editor/index.ts b/studio-v2/lib/worksheet-editor/index.ts index b8009ec..eb2eaf6 100644 --- a/studio-v2/lib/worksheet-editor/index.ts +++ b/studio-v2/lib/worksheet-editor/index.ts @@ -8,3 +8,6 @@ export { WorksheetProvider, useWorksheet } from './WorksheetContext' // Cleanup Service for handwriting removal export * from './cleanup-service' + +// OCR Integration utilities +export * from './ocr-integration' diff --git a/studio-v2/lib/worksheet-editor/ocr-integration.ts b/studio-v2/lib/worksheet-editor/ocr-integration.ts new file mode 100644 index 0000000..d8d4b25 --- /dev/null +++ b/studio-v2/lib/worksheet-editor/ocr-integration.ts @@ -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 { + 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>> + detected_columns: Array> + 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 { + // 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 { + 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() +}