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:
419
studio-v2/lib/worksheet-editor/WorksheetContext.tsx
Normal file
419
studio-v2/lib/worksheet-editor/WorksheetContext.tsx
Normal 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
|
||||
}
|
||||
272
studio-v2/lib/worksheet-editor/cleanup-service.ts
Normal file
272
studio-v2/lib/worksheet-editor/cleanup-service.ts
Normal 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)
|
||||
}
|
||||
13
studio-v2/lib/worksheet-editor/index.ts
Normal file
13
studio-v2/lib/worksheet-editor/index.ts
Normal 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'
|
||||
466
studio-v2/lib/worksheet-editor/ocr-integration.test.ts
Normal file
466
studio-v2/lib/worksheet-editor/ocr-integration.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
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