Initial commit: breakpilot-lehrer - Lehrer KI Platform

Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:26 +01:00
commit 5a31f52310
1224 changed files with 425430 additions and 0 deletions

View File

@@ -0,0 +1,419 @@
'use client'
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react'
import type { Canvas, Object as FabricObject } from 'fabric'
import type {
EditorTool,
WorksheetDocument,
WorksheetPage,
PageFormat,
HistoryEntry,
DEFAULT_PAGE_FORMAT
} from '@/app/worksheet-editor/types'
// Context Types
interface WorksheetContextType {
// Canvas
canvas: Canvas | null
setCanvas: (canvas: Canvas | null) => void
// Document
document: WorksheetDocument | null
setDocument: (doc: WorksheetDocument | null) => void
// Tool State
activeTool: EditorTool
setActiveTool: (tool: EditorTool) => void
// Selection
selectedObjects: FabricObject[]
setSelectedObjects: (objects: FabricObject[]) => void
// Zoom
zoom: number
setZoom: (zoom: number) => void
zoomIn: () => void
zoomOut: () => void
zoomToFit: () => void
// Grid
showGrid: boolean
setShowGrid: (show: boolean) => void
snapToGrid: boolean
setSnapToGrid: (snap: boolean) => void
gridSize: number
setGridSize: (size: number) => void
// Pages
currentPageIndex: number
setCurrentPageIndex: (index: number) => void
addPage: () => void
deletePage: (index: number) => void
// History
canUndo: boolean
canRedo: boolean
undo: () => void
redo: () => void
saveToHistory: (action: string) => void
// Save/Load
saveDocument: () => Promise<void>
loadDocument: (id: string) => Promise<void>
// Export
exportToPDF: () => Promise<Blob>
exportToImage: (format: 'png' | 'jpg') => Promise<Blob>
// Dirty State
isDirty: boolean
setIsDirty: (dirty: boolean) => void
}
const WorksheetContext = createContext<WorksheetContextType | null>(null)
// Provider Props
interface WorksheetProviderProps {
children: ReactNode
}
// Generate unique ID
const generateId = () => `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
// Default Page Format
const defaultPageFormat: PageFormat = {
width: 210,
height: 297,
orientation: 'portrait',
margins: { top: 15, right: 15, bottom: 15, left: 15 }
}
export function WorksheetProvider({ children }: WorksheetProviderProps) {
// Canvas State
const [canvas, setCanvas] = useState<Canvas | null>(null)
// Document State
const [document, setDocument] = useState<WorksheetDocument | null>(null)
// Editor State
const [activeTool, setActiveTool] = useState<EditorTool>('select')
const [selectedObjects, setSelectedObjects] = useState<FabricObject[]>([])
const [zoom, setZoom] = useState(1)
const [showGrid, setShowGrid] = useState(true)
const [snapToGrid, setSnapToGrid] = useState(true)
const [gridSize, setGridSize] = useState(10)
const [currentPageIndex, setCurrentPageIndex] = useState(0)
const [isDirty, setIsDirty] = useState(false)
// History State
const historyRef = useRef<HistoryEntry[]>([])
const historyIndexRef = useRef(-1)
const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false)
// Initialize empty document
useEffect(() => {
if (!document) {
const newDoc: WorksheetDocument = {
id: generateId(),
title: 'Neues Arbeitsblatt',
pages: [{
id: generateId(),
index: 0,
canvasJSON: ''
}],
pageFormat: defaultPageFormat,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
setDocument(newDoc)
}
}, [document])
// Zoom functions
const zoomIn = useCallback(() => {
setZoom(prev => Math.min(prev * 1.2, 4))
}, [])
const zoomOut = useCallback(() => {
setZoom(prev => Math.max(prev / 1.2, 0.25))
}, [])
const zoomToFit = useCallback(() => {
setZoom(1)
}, [])
// Page functions
const addPage = useCallback(() => {
if (!document) return
const newPage: WorksheetPage = {
id: generateId(),
index: document.pages.length,
canvasJSON: ''
}
setDocument({
...document,
pages: [...document.pages, newPage],
updatedAt: new Date().toISOString()
})
setCurrentPageIndex(document.pages.length)
setIsDirty(true)
}, [document])
const deletePage = useCallback((index: number) => {
if (!document || document.pages.length <= 1) return
const newPages = document.pages.filter((_, i) => i !== index)
.map((page, i) => ({ ...page, index: i }))
setDocument({
...document,
pages: newPages,
updatedAt: new Date().toISOString()
})
if (currentPageIndex >= newPages.length) {
setCurrentPageIndex(newPages.length - 1)
}
setIsDirty(true)
}, [document, currentPageIndex])
// History functions
const saveToHistory = useCallback((action: string) => {
if (!canvas) return
try {
const canvasData = canvas.toJSON()
if (!canvasData) return
const entry: HistoryEntry = {
canvasJSON: JSON.stringify(canvasData),
timestamp: Date.now(),
action
}
// Remove any future history if we're not at the end
if (historyIndexRef.current < historyRef.current.length - 1) {
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1)
}
historyRef.current.push(entry)
historyIndexRef.current = historyRef.current.length - 1
// Limit history size
if (historyRef.current.length > 50) {
historyRef.current.shift()
historyIndexRef.current--
}
setCanUndo(historyIndexRef.current > 0)
setCanRedo(false)
setIsDirty(true)
} catch (error) {
console.error('Failed to save history:', error)
}
}, [canvas])
const undo = useCallback(() => {
if (!canvas || historyIndexRef.current <= 0) return
historyIndexRef.current--
const entry = historyRef.current[historyIndexRef.current]
canvas.loadFromJSON(JSON.parse(entry.canvasJSON), () => {
canvas.renderAll()
})
setCanUndo(historyIndexRef.current > 0)
setCanRedo(true)
}, [canvas])
const redo = useCallback(() => {
if (!canvas || historyIndexRef.current >= historyRef.current.length - 1) return
historyIndexRef.current++
const entry = historyRef.current[historyIndexRef.current]
canvas.loadFromJSON(JSON.parse(entry.canvasJSON), () => {
canvas.renderAll()
})
setCanUndo(true)
setCanRedo(historyIndexRef.current < historyRef.current.length - 1)
}, [canvas])
// Save/Load functions
const saveDocument = useCallback(async () => {
if (!canvas || !document) return
// Save current page state
const currentPage = document.pages[currentPageIndex]
currentPage.canvasJSON = JSON.stringify(canvas.toJSON())
// Get API base URL (use same protocol as page)
const { hostname, protocol } = window.location
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
try {
const response = await fetch(`${apiBase}/api/v1/worksheet/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(document)
})
if (!response.ok) throw new Error('Failed to save')
const result = await response.json()
setDocument({ ...document, id: result.id })
setIsDirty(false)
} catch (error) {
console.error('Save failed:', error)
// Fallback to localStorage
localStorage.setItem(`worksheet_${document.id}`, JSON.stringify(document))
setIsDirty(false)
}
}, [canvas, document, currentPageIndex])
const loadDocument = useCallback(async (id: string) => {
const { hostname, protocol } = window.location
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
try {
const response = await fetch(`${apiBase}/api/v1/worksheet/${id}`)
if (!response.ok) throw new Error('Failed to load')
const doc = await response.json()
setDocument(doc)
setCurrentPageIndex(0)
if (canvas && doc.pages[0]?.canvasJSON) {
canvas.loadFromJSON(JSON.parse(doc.pages[0].canvasJSON), () => {
canvas.renderAll()
})
}
} catch (error) {
console.error('Load failed:', error)
// Try localStorage fallback
const stored = localStorage.getItem(`worksheet_${id}`)
if (stored) {
const doc = JSON.parse(stored)
setDocument(doc)
setCurrentPageIndex(0)
}
}
}, [canvas])
// Export functions
const exportToPDF = useCallback(async (): Promise<Blob> => {
if (!canvas || !document) throw new Error('No canvas or document')
const { PDFDocument, rgb } = await import('pdf-lib')
const pdfDoc = await PDFDocument.create()
for (const page of document.pages) {
const pdfPage = pdfDoc.addPage([
document.pageFormat.width * 2.83465, // mm to points
document.pageFormat.height * 2.83465
])
// Export canvas as PNG and embed
if (page.canvasJSON) {
// Load the page content
if (currentPageIndex !== page.index) {
await new Promise<void>((resolve) => {
canvas.loadFromJSON(JSON.parse(page.canvasJSON), () => {
canvas.renderAll()
resolve()
})
})
}
const dataUrl = canvas.toDataURL({ format: 'png', multiplier: 2 })
const imageBytes = await fetch(dataUrl).then(res => res.arrayBuffer())
const image = await pdfDoc.embedPng(imageBytes)
pdfPage.drawImage(image, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight()
})
}
}
const pdfBytes = await pdfDoc.save()
// Convert Uint8Array to ArrayBuffer for Blob compatibility
const arrayBuffer = pdfBytes.slice().buffer as ArrayBuffer
return new Blob([arrayBuffer], { type: 'application/pdf' })
}, [canvas, document, currentPageIndex])
const exportToImage = useCallback(async (format: 'png' | 'jpg'): Promise<Blob> => {
if (!canvas) throw new Error('No canvas')
// Fabric.js uses 'jpeg' not 'jpg'
const fabricFormat = format === 'jpg' ? 'jpeg' : format
const dataUrl = canvas.toDataURL({
format: fabricFormat as 'png' | 'jpeg',
quality: format === 'jpg' ? 0.9 : undefined,
multiplier: 2
})
const response = await fetch(dataUrl)
return response.blob()
}, [canvas])
const value: WorksheetContextType = {
canvas,
setCanvas,
document,
setDocument,
activeTool,
setActiveTool,
selectedObjects,
setSelectedObjects,
zoom,
setZoom,
zoomIn,
zoomOut,
zoomToFit,
showGrid,
setShowGrid,
snapToGrid,
setSnapToGrid,
gridSize,
setGridSize,
currentPageIndex,
setCurrentPageIndex,
addPage,
deletePage,
canUndo,
canRedo,
undo,
redo,
saveToHistory,
saveDocument,
loadDocument,
exportToPDF,
exportToImage,
isDirty,
setIsDirty
}
return (
<WorksheetContext.Provider value={value}>
{children}
</WorksheetContext.Provider>
)
}
export function useWorksheet() {
const context = useContext(WorksheetContext)
if (!context) {
throw new Error('useWorksheet must be used within a WorksheetProvider')
}
return context
}

View File

@@ -0,0 +1,272 @@
/**
* Worksheet Cleanup Service
*
* API client for the worksheet cleanup endpoints:
* - Handwriting detection
* - Handwriting removal (inpainting)
* - Layout reconstruction
*
* All processing happens on the local Mac Mini server.
*/
export interface CleanupCapabilities {
opencv_available: boolean
lama_available: boolean
paddleocr_available: boolean
}
export interface DetectionResult {
has_handwriting: boolean
confidence: number
handwriting_ratio: number
detection_method: string
mask_base64?: string
}
export interface PreviewResult {
has_handwriting: boolean
confidence: number
handwriting_ratio: number
image_width: number
image_height: number
estimated_times_ms: {
detection: number
inpainting: number
reconstruction: number
total: number
}
capabilities: {
lama_available: boolean
}
}
export interface PipelineResult {
success: boolean
handwriting_detected: boolean
handwriting_removed: boolean
layout_reconstructed: boolean
cleaned_image_base64?: string
fabric_json?: any
metadata: {
detection?: {
confidence: number
handwriting_ratio: number
method: string
}
inpainting?: {
method_used: string
processing_time_ms: number
}
layout?: {
element_count: number
table_count: number
page_width: number
page_height: number
}
}
}
export type InpaintingMethod = 'auto' | 'opencv_telea' | 'opencv_ns' | 'lama'
export interface CleanupOptions {
removeHandwriting: boolean
reconstructLayout: boolean
inpaintingMethod: InpaintingMethod
}
/**
* Get the API base URL for the klausur-service
*/
function getApiUrl(): string {
if (typeof window === 'undefined') return 'http://localhost:8086'
const { hostname, protocol } = window.location
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
}
/**
* Get available cleanup capabilities on the server
*/
export async function getCapabilities(): Promise<CleanupCapabilities> {
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/capabilities`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
}
/**
* Quick preview of cleanup without full processing
*/
export async function previewCleanup(file: File): Promise<PreviewResult> {
const formData = new FormData()
formData.append('image', file)
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
return response.json()
}
/**
* Detect handwriting in an image
*/
export async function detectHandwriting(
file: File,
options: { returnMask?: boolean; minConfidence?: number } = {}
): Promise<DetectionResult> {
const formData = new FormData()
formData.append('image', file)
formData.append('return_mask', String(options.returnMask ?? true))
formData.append('min_confidence', String(options.minConfidence ?? 0.3))
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
return response.json()
}
/**
* Get the handwriting detection mask as an image blob
*/
export async function getHandwritingMask(file: File): Promise<Blob> {
const formData = new FormData()
formData.append('image', file)
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.blob()
}
/**
* Remove handwriting from an image
*/
export async function removeHandwriting(
file: File,
options: {
mask?: File
method?: InpaintingMethod
returnBase64?: boolean
} = {}
): Promise<{ imageBlob?: Blob; imageBase64?: string; metadata: any }> {
const formData = new FormData()
formData.append('image', file)
formData.append('method', options.method ?? 'auto')
formData.append('return_base64', String(options.returnBase64 ?? false))
if (options.mask) {
formData.append('mask', options.mask)
}
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/remove-handwriting`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
if (options.returnBase64) {
const data = await response.json()
return {
imageBase64: data.image_base64,
metadata: data.metadata
}
} else {
const imageBlob = await response.blob()
const methodUsed = response.headers.get('X-Method-Used') || 'unknown'
const processingTime = parseFloat(response.headers.get('X-Processing-Time-Ms') || '0')
return {
imageBlob,
metadata: {
method_used: methodUsed,
processing_time_ms: processingTime
}
}
}
}
/**
* Run the full cleanup pipeline
*/
export async function runCleanupPipeline(
file: File,
options: CleanupOptions = {
removeHandwriting: true,
reconstructLayout: true,
inpaintingMethod: 'auto'
}
): Promise<PipelineResult> {
const formData = new FormData()
formData.append('image', file)
formData.append('remove_handwriting', String(options.removeHandwriting))
formData.append('reconstruct', String(options.reconstructLayout))
formData.append('inpainting_method', options.inpaintingMethod)
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
return response.json()
}
/**
* Helper to convert base64 to blob
*/
export function base64ToBlob(base64: string, mimeType: string = 'image/png'): Blob {
const byteCharacters = atob(base64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, { type: mimeType })
}
/**
* Helper to create object URL from base64
*/
export function base64ToObjectUrl(base64: string, mimeType: string = 'image/png'): string {
const blob = base64ToBlob(base64, mimeType)
return URL.createObjectURL(blob)
}

View File

@@ -0,0 +1,13 @@
/**
* Worksheet Editor Library
*
* Export context and utilities
*/
export { WorksheetProvider, useWorksheet } from './WorksheetContext'
// Cleanup Service for handwriting removal
export * from './cleanup-service'
// OCR Integration utilities
export * from './ocr-integration'

View File

@@ -0,0 +1,466 @@
/**
* Tests for OCR Integration Utility
*
* Tests cover:
* - mm to pixel conversion
* - OCR data export format
* - LocalStorage operations
* - Canvas integration
*/
import {
MM_TO_PX,
A4_WIDTH_MM,
A4_HEIGHT_MM,
A4_WIDTH_PX,
A4_HEIGHT_PX,
mmToPixel,
pixelToMm,
getColumnColor,
createTextProps,
exportOCRData,
saveOCRExportToStorage,
loadLatestOCRExport,
loadOCRExport,
clearOCRExports,
type OCRWord,
type OCRExportData,
type ColumnType,
} from './ocr-integration'
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: jest.fn((key: string) => store[key] || null),
setItem: jest.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: jest.fn((key: string) => {
delete store[key]
}),
clear: jest.fn(() => {
store = {}
}),
keys: () => Object.keys(store),
}
})()
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
describe('Constants', () => {
test('MM_TO_PX is correct for 96 DPI', () => {
// 1 inch = 25.4mm, 96 DPI = 96 pixels per inch
// 96 / 25.4 = 3.7795275591
expect(MM_TO_PX).toBeCloseTo(3.7795275591, 8)
})
test('A4 dimensions in mm are correct', () => {
expect(A4_WIDTH_MM).toBe(210)
expect(A4_HEIGHT_MM).toBe(297)
})
test('A4 dimensions in pixels are calculated correctly', () => {
expect(A4_WIDTH_PX).toBe(Math.round(210 * MM_TO_PX)) // ~794
expect(A4_HEIGHT_PX).toBe(Math.round(297 * MM_TO_PX)) // ~1123
})
})
describe('mmToPixel', () => {
test('converts 0mm to 0px', () => {
expect(mmToPixel(0)).toBe(0)
})
test('converts 1mm correctly', () => {
expect(mmToPixel(1)).toBeCloseTo(3.7795275591, 8)
})
test('converts 100mm correctly', () => {
expect(mmToPixel(100)).toBeCloseTo(377.95275591, 6)
})
test('converts A4 width correctly', () => {
expect(mmToPixel(210)).toBeCloseTo(793.7, 1)
})
})
describe('pixelToMm', () => {
test('converts 0px to 0mm', () => {
expect(pixelToMm(0)).toBe(0)
})
test('converts 100px correctly', () => {
expect(pixelToMm(100)).toBeCloseTo(26.458, 2)
})
test('round-trip conversion is accurate', () => {
const original = 50
const pixels = mmToPixel(original)
const backToMm = pixelToMm(pixels)
expect(backToMm).toBeCloseTo(original, 8)
})
})
describe('getColumnColor', () => {
test('returns blue for english column', () => {
expect(getColumnColor('english')).toBe('#1e40af')
})
test('returns green for german column', () => {
expect(getColumnColor('german')).toBe('#166534')
})
test('returns purple for example column', () => {
expect(getColumnColor('example')).toBe('#6b21a8')
})
test('returns gray for unknown column', () => {
expect(getColumnColor('unknown')).toBe('#374151')
})
test('uses custom colors from options', () => {
const options = { englishColor: '#ff0000' }
expect(getColumnColor('english', options)).toBe('#ff0000')
})
})
describe('createTextProps', () => {
const mockWord: OCRWord = {
text: 'house',
x_mm: 21.0,
y_mm: 44.55,
width_mm: 52.5,
height_mm: 8.91,
column_type: 'english',
logical_row: 0,
}
test('creates correct type', () => {
const props = createTextProps(mockWord)
expect(props.type).toBe('i-text')
})
test('converts mm to pixels for left position', () => {
const props = createTextProps(mockWord)
expect(props.left).toBeCloseTo(21.0 * MM_TO_PX, 2)
})
test('converts mm to pixels for top position', () => {
const props = createTextProps(mockWord)
expect(props.top).toBeCloseTo(44.55 * MM_TO_PX, 2)
})
test('applies offset correctly', () => {
const props = createTextProps(mockWord, { offsetX: 5, offsetY: 10 })
expect(props.left).toBeCloseTo((21.0 + 5) * MM_TO_PX, 2)
expect(props.top).toBeCloseTo((44.55 + 10) * MM_TO_PX, 2)
})
test('sets fill color based on column type', () => {
const props = createTextProps(mockWord)
expect(props.fill).toBe('#1e40af') // English blue
})
test('includes OCR metadata', () => {
const props = createTextProps(mockWord)
expect(props.ocrMetadata).toBeDefined()
expect((props.ocrMetadata as any).x_mm).toBe(21.0)
expect((props.ocrMetadata as any).column_type).toBe('english')
expect((props.ocrMetadata as any).logical_row).toBe(0)
})
test('uses custom font family', () => {
const props = createTextProps(mockWord, { fontFamily: 'Times New Roman' })
expect(props.fontFamily).toBe('Times New Roman')
})
test('uses custom font size', () => {
const props = createTextProps(mockWord, { fontSize: 16 })
expect(props.fontSize).toBe(16)
})
})
describe('exportOCRData', () => {
const mockGridData = {
cells: [
[
{
text: 'house',
x_mm: 21.0,
y_mm: 44.55,
width_mm: 52.5,
height_mm: 8.91,
column_type: 'english' as ColumnType,
logical_row: 0,
status: 'recognized',
},
{
text: 'Haus',
x_mm: 80.0,
y_mm: 44.55,
width_mm: 40.0,
height_mm: 8.91,
column_type: 'german' as ColumnType,
logical_row: 0,
status: 'recognized',
},
],
],
detected_columns: [
{ column_type: 'english', x_start_mm: 20.0, x_end_mm: 73.5 },
{ column_type: 'german', x_start_mm: 74.0, x_end_mm: 140.0 },
],
page_dimensions: {
width_mm: 210,
height_mm: 297,
format: 'A4',
},
}
test('creates correct version', () => {
const result = exportOCRData(mockGridData, 'session-123', 1)
expect(result.version).toBe('1.0')
})
test('sets correct source', () => {
const result = exportOCRData(mockGridData, 'session-123', 1)
expect(result.source).toBe('ocr-compare')
})
test('includes session ID and page number', () => {
const result = exportOCRData(mockGridData, 'session-123', 1)
expect(result.session_id).toBe('session-123')
expect(result.page_number).toBe(1)
})
test('includes page dimensions', () => {
const result = exportOCRData(mockGridData, 'session-123', 1)
expect(result.page_dimensions.width_mm).toBe(210)
expect(result.page_dimensions.height_mm).toBe(297)
expect(result.page_dimensions.format).toBe('A4')
})
test('converts cells to words', () => {
const result = exportOCRData(mockGridData, 'session-123', 1)
expect(result.words).toHaveLength(2)
expect(result.words[0].text).toBe('house')
expect(result.words[0].column_type).toBe('english')
})
test('filters empty cells', () => {
const dataWithEmpty = {
...mockGridData,
cells: [
[
...mockGridData.cells[0],
{ text: '', status: 'empty' }, // Empty cell
],
],
}
const result = exportOCRData(dataWithEmpty, 'session-123', 1)
expect(result.words).toHaveLength(2) // Empty cell excluded
})
test('includes detected columns', () => {
const result = exportOCRData(mockGridData, 'session-123', 1)
expect(result.detected_columns).toHaveLength(2)
expect(result.detected_columns[0].column_type).toBe('english')
})
test('sets exported_at timestamp', () => {
const before = new Date().toISOString()
const result = exportOCRData(mockGridData, 'session-123', 1)
const after = new Date().toISOString()
expect(result.exported_at >= before).toBe(true)
expect(result.exported_at <= after).toBe(true)
})
})
describe('localStorage operations', () => {
beforeEach(() => {
localStorageMock.clear()
})
const mockExportData: OCRExportData = {
version: '1.0',
source: 'ocr-compare',
exported_at: '2026-02-08T12:00:00Z',
session_id: 'session-123',
page_number: 1,
page_dimensions: {
width_mm: 210,
height_mm: 297,
format: 'A4',
},
words: [
{
text: 'house',
x_mm: 21.0,
y_mm: 44.55,
width_mm: 52.5,
height_mm: 8.91,
column_type: 'english',
logical_row: 0,
},
],
detected_columns: [],
}
describe('saveOCRExportToStorage', () => {
test('saves data to localStorage', () => {
saveOCRExportToStorage(mockExportData)
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'ocr_export_session-123_1',
expect.any(String)
)
})
test('sets latest export key', () => {
saveOCRExportToStorage(mockExportData)
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'ocr_export_latest',
'ocr_export_session-123_1'
)
})
})
describe('loadLatestOCRExport', () => {
test('returns null when no export exists', () => {
const result = loadLatestOCRExport()
expect(result).toBeNull()
})
test('loads latest export data', () => {
// Manually set up the mock
localStorageMock.setItem(
'ocr_export_session-123_1',
JSON.stringify(mockExportData)
)
localStorageMock.setItem('ocr_export_latest', 'ocr_export_session-123_1')
// Reset the mock to return correct values
localStorageMock.getItem.mockImplementation((key: string) => {
if (key === 'ocr_export_latest') return 'ocr_export_session-123_1'
if (key === 'ocr_export_session-123_1')
return JSON.stringify(mockExportData)
return null
})
const result = loadLatestOCRExport()
expect(result).not.toBeNull()
expect(result?.session_id).toBe('session-123')
})
})
describe('loadOCRExport', () => {
test('returns null for non-existent session', () => {
const result = loadOCRExport('nonexistent', 1)
expect(result).toBeNull()
})
test('loads specific export by session and page', () => {
localStorageMock.getItem.mockImplementation((key: string) => {
if (key === 'ocr_export_session-123_1')
return JSON.stringify(mockExportData)
return null
})
const result = loadOCRExport('session-123', 1)
expect(result).not.toBeNull()
expect(result?.page_number).toBe(1)
})
test('handles JSON parse errors gracefully', () => {
localStorageMock.getItem.mockImplementation((key: string) => {
if (key === 'ocr_export_session-123_1') return 'invalid json'
return null
})
const result = loadOCRExport('session-123', 1)
expect(result).toBeNull()
})
})
describe('clearOCRExports', () => {
test('removes all OCR export keys', () => {
// Set up mock to return keys
Object.defineProperty(localStorageMock, 'keys', {
value: () => [
'ocr_export_session-1_1',
'ocr_export_session-2_1',
'ocr_export_latest',
'other_key',
],
})
// Mock Object.keys(localStorage)
const originalKeys = Object.keys
Object.keys = jest.fn((obj) => {
if (obj === localStorage) {
return [
'ocr_export_session-1_1',
'ocr_export_session-2_1',
'ocr_export_latest',
'other_key',
]
}
return originalKeys(obj)
})
clearOCRExports()
expect(localStorageMock.removeItem).toHaveBeenCalledWith(
'ocr_export_session-1_1'
)
expect(localStorageMock.removeItem).toHaveBeenCalledWith(
'ocr_export_session-2_1'
)
expect(localStorageMock.removeItem).toHaveBeenCalledWith(
'ocr_export_latest'
)
// Restore Object.keys
Object.keys = originalKeys
})
})
})
describe('Edge Cases', () => {
test('handles negative mm values', () => {
const pixels = mmToPixel(-10)
expect(pixels).toBeCloseTo(-37.795, 2)
})
test('handles very large mm values', () => {
const pixels = mmToPixel(10000)
expect(pixels).toBeCloseTo(37795.275591, 2)
})
test('handles word with missing optional fields', () => {
const word: OCRWord = {
text: 'test',
x_mm: 0,
y_mm: 0,
width_mm: 10,
height_mm: 5,
column_type: 'unknown',
logical_row: 0,
}
const props = createTextProps(word)
expect(props).toBeDefined()
expect(props.text).toBe('test')
})
test('handles empty words array in export', () => {
const gridData = {
cells: [],
detected_columns: [],
page_dimensions: { width_mm: 210, height_mm: 297, format: 'A4' },
}
const result = exportOCRData(gridData, 'session', 1)
expect(result.words).toHaveLength(0)
})
})

View 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()
}