Add Ansicht step (Step 12) — read-only page layout preview
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 39s
CI / test-go-edu-search (push) Successful in 49s
CI / test-python-klausur (push) Failing after 2m33s
CI / test-python-agent-core (push) Successful in 31s
CI / test-nodejs-website (push) Successful in 36s

New pipeline step showing the reconstructed page with all zones
positioned at their original coordinates:
- Content zones with vocabulary grid cells
- Box zones with colored borders (from structure detection)
- Colspan cells rendered across multiple columns
- Multi-line cells (bullets) with pre-wrap whitespace
- Toggle to overlay original scan image at 15% opacity
- Proportionally scaled to viewport width
- Pure CSS positioning (no canvas/Fabric.js)

Pipeline: 14 steps (0-13), Ground Truth moved to Step 13.
Added colspan field to GridEditorCell type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-13 22:42:33 +02:00
parent 2c2bdf903a
commit cd8eb6ce46
4 changed files with 285 additions and 2 deletions

View File

@@ -17,6 +17,7 @@ import { StepGridBuild } from '@/components/ocr-kombi/StepGridBuild'
import { StepGridReview } from '@/components/ocr-kombi/StepGridReview' import { StepGridReview } from '@/components/ocr-kombi/StepGridReview'
import { StepGutterRepair } from '@/components/ocr-kombi/StepGutterRepair' import { StepGutterRepair } from '@/components/ocr-kombi/StepGutterRepair'
import { StepBoxGridReview } from '@/components/ocr-kombi/StepBoxGridReview' import { StepBoxGridReview } from '@/components/ocr-kombi/StepBoxGridReview'
import { StepAnsicht } from '@/components/ocr-kombi/StepAnsicht'
import { StepGroundTruth } from '@/components/ocr-kombi/StepGroundTruth' import { StepGroundTruth } from '@/components/ocr-kombi/StepGroundTruth'
import { useKombiPipeline } from './useKombiPipeline' import { useKombiPipeline } from './useKombiPipeline'
@@ -100,6 +101,8 @@ function OcrKombiContent() {
case 11: case 11:
return <StepBoxGridReview sessionId={sessionId} onNext={handleNext} /> return <StepBoxGridReview sessionId={sessionId} onNext={handleNext} />
case 12: case 12:
return <StepAnsicht sessionId={sessionId} onNext={handleNext} />
case 13:
return ( return (
<StepGroundTruth <StepGroundTruth
sessionId={sessionId} sessionId={sessionId}

View File

@@ -41,6 +41,7 @@ export const KOMBI_V2_STEPS: PipelineStep[] = [
{ id: 'grid-review', name: 'Grid-Review', icon: '📊', status: 'pending' }, { id: 'grid-review', name: 'Grid-Review', icon: '📊', status: 'pending' },
{ id: 'gutter-repair', name: 'Wortkorrektur', icon: '🩹', status: 'pending' }, { id: 'gutter-repair', name: 'Wortkorrektur', icon: '🩹', status: 'pending' },
{ id: 'box-review', name: 'Box-Review', icon: '📦', status: 'pending' }, { id: 'box-review', name: 'Box-Review', icon: '📦', status: 'pending' },
{ id: 'ansicht', name: 'Ansicht', icon: '👁️', status: 'pending' },
{ id: 'ground-truth', name: 'Ground Truth', icon: '✅', status: 'pending' }, { id: 'ground-truth', name: 'Ground Truth', icon: '✅', status: 'pending' },
] ]
@@ -58,7 +59,8 @@ export const KOMBI_V2_UI_TO_DB: Record<number, number> = {
9: 11, // grid-review 9: 11, // grid-review
10: 11, // gutter-repair (shares DB step with grid-review) 10: 11, // gutter-repair (shares DB step with grid-review)
11: 11, // box-review (shares DB step with grid-review) 11: 11, // box-review (shares DB step with grid-review)
12: 12, // ground-truth 12: 11, // ansicht (shares DB step with grid-review)
13: 12, // ground-truth
} }
/** Map from DB step to Kombi V2 UI step index */ /** Map from DB step to Kombi V2 UI step index */
@@ -72,7 +74,7 @@ export function dbStepToKombiV2Ui(dbStep: number): number {
if (dbStep === 9) return 7 // structure if (dbStep === 9) return 7 // structure
if (dbStep === 10) return 8 // grid-build if (dbStep === 10) return 8 // grid-build
if (dbStep === 11) return 9 // grid-review if (dbStep === 11) return 9 // grid-review
return 12 // ground-truth return 13 // ground-truth
} }
/** Document group: groups multiple sessions from a multi-page upload */ /** Document group: groups multiple sessions from a multi-page upload */

View File

@@ -126,6 +126,8 @@ export interface GridEditorCell {
is_bold: boolean is_bold: boolean
/** Manual color override: hex string or null to clear. */ /** Manual color override: hex string or null to clear. */
color_override?: string | null color_override?: string | null
/** Number of columns this cell spans (merged cell). Default 1. */
colspan?: number
} }
/** Layout dividers for the visual column/margin editor on the original image. */ /** Layout dividers for the visual column/margin editor on the original image. */

View File

@@ -0,0 +1,276 @@
'use client'
/**
* StepAnsicht — Read-only page layout preview.
*
* Shows the reconstructed page with all zones (content grid + embedded boxes)
* positioned at their original coordinates. Pure CSS positioning, no canvas.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useGridEditor } from '@/components/grid-editor/useGridEditor'
import type { GridZone, GridEditorCell } from '@/components/grid-editor/types'
const KLAUSUR_API = '/klausur-api'
interface StepAnsichtProps {
sessionId: string | null
onNext: () => void
}
/** Get dominant color from a cell's word_boxes or color_override. */
function getCellColor(cell: GridEditorCell | undefined): string | null {
if (!cell) return null
if ((cell as any).color_override) return (cell as any).color_override
const colored = cell.word_boxes?.find((wb) => wb.color_name && wb.color_name !== 'black')
return colored?.color ?? null
}
export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
const {
grid,
loading,
error,
loadGrid,
} = useGridEditor(sessionId)
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const [showOriginal, setShowOriginal] = useState(false)
// Load grid on mount
useEffect(() => {
if (sessionId) loadGrid()
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
// Track container width
useEffect(() => {
if (!containerRef.current) return
const ro = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width)
})
ro.observe(containerRef.current)
return () => ro.disconnect()
}, [])
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-4 border-teal-500 border-t-transparent rounded-full animate-spin" />
<span className="ml-3 text-gray-500">Lade Vorschau...</span>
</div>
)
}
if (error || !grid) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-8 text-center">
<p className="text-red-500 mb-4">{error || 'Keine Grid-Daten vorhanden.'}</p>
<button onClick={onNext} className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium">
Weiter
</button>
</div>
)
}
const imgW = grid.image_width || 1
const imgH = grid.image_height || 1
const scale = containerWidth > 0 ? containerWidth / imgW : 1
const containerHeight = imgH * scale
// Font size: scale from original, with minimum
const baseFontPx = (grid as any).layout_metrics?.font_size_suggestion_px || 14
const scaledFont = Math.max(7, baseFontPx * scale * 0.85)
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Ansicht Seitenrekonstruktion
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Vorschau der rekonstruierten Seite mit allen Zonen und Boxen an Originalpositionen.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowOriginal(!showOriginal)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
showOriginal
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{showOriginal ? 'Original ausblenden' : 'Original einblenden'}
</button>
<button
onClick={onNext}
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium"
>
Weiter
</button>
</div>
</div>
{/* Page container */}
<div
ref={containerRef}
className="relative bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden shadow-lg"
style={{ height: containerHeight > 0 ? `${containerHeight}px` : 'auto' }}
>
{/* Original image background (toggleable) */}
{showOriginal && sessionId && (
<img
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`}
alt="Original"
className="absolute inset-0 w-full h-full object-contain opacity-15"
/>
)}
{/* Render zones */}
{grid.zones.map((zone) => (
<ZoneRenderer
key={zone.zone_index}
zone={zone}
scale={scale}
fontSize={scaledFont}
/>
))}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Zone renderer
// ---------------------------------------------------------------------------
function ZoneRenderer({ zone, scale, fontSize }: {
zone: GridZone
scale: number
fontSize: number
}) {
const isBox = zone.zone_type === 'box'
const boxColor = (zone as any).box_bg_hex || '#6b7280'
if (!zone.cells || zone.cells.length === 0) return null
const left = zone.bbox_px.x * scale
const top = zone.bbox_px.y * scale
const width = zone.bbox_px.w * scale
const height = zone.bbox_px.h * scale
// Build cell map
const cellMap = new Map<string, GridEditorCell>()
for (const cell of zone.cells) {
cellMap.set(`${cell.row_index}_${cell.col_index}`, cell)
}
// Column widths (relative to zone)
const colWidths = zone.columns.map((col) => {
const w = (col.x_max_px ?? 0) - (col.x_min_px ?? 0)
return Math.max(10, w * scale)
})
const totalColW = colWidths.reduce((s, w) => s + w, 0)
// Scale columns to fit zone width
const colScale = totalColW > 0 ? width / totalColW : 1
const scaledColWidths = colWidths.map((w) => w * colScale)
const gridTemplateCols = scaledColWidths.map((w) => `${w.toFixed(1)}px`).join(' ')
const numCols = zone.columns.length
return (
<div
className="absolute overflow-hidden"
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
minHeight: `${height}px`,
border: isBox ? `${Math.max(2, 3 * scale)}px solid ${boxColor}` : undefined,
backgroundColor: isBox ? `${boxColor}08` : undefined,
borderRadius: isBox ? `${Math.max(2, 4 * scale)}px` : undefined,
fontSize: `${fontSize}px`,
lineHeight: '1.3',
}}
>
<div
className="w-full"
style={{
display: 'grid',
gridTemplateColumns: gridTemplateCols,
}}
>
{zone.rows.map((row) => {
const isSpanning = zone.cells.some(
(c) => c.row_index === row.index && c.col_type === 'spanning_header',
)
// Row height from measured px
const measuredH = (row.y_max_px ?? 0) - (row.y_min_px ?? 0)
const rowH = Math.max(fontSize * 1.4, measuredH * scale)
return (
<div key={row.index} style={{ display: 'contents' }}>
{isSpanning ? (
// Render spanning cells
zone.cells
.filter((c) => c.row_index === row.index && c.col_type === 'spanning_header')
.sort((a, b) => a.col_index - b.col_index)
.map((cell) => {
const colspan = (cell as any).colspan || numCols
const gridColStart = cell.col_index + 1
const gridColEnd = gridColStart + colspan
const color = getCellColor(cell)
return (
<div
key={cell.cell_id}
className={`px-1 overflow-hidden ${row.is_header ? 'font-bold' : ''}`}
style={{
gridColumn: `${gridColStart} / ${gridColEnd}`,
minHeight: `${rowH}px`,
color: color || undefined,
whiteSpace: 'pre-wrap',
}}
>
{cell.text}
</div>
)
})
) : (
// Render normal columns
zone.columns.map((col) => {
const cell = cellMap.get(`${row.index}_${col.index}`)
if (!cell) {
return <div key={col.index} style={{ minHeight: `${rowH}px` }} />
}
const color = getCellColor(cell)
const isBold = col.bold || cell.is_bold || row.is_header
const text = cell.text ?? ''
const isMultiLine = text.includes('\n')
return (
<div
key={col.index}
className={`px-1 overflow-hidden ${isBold ? 'font-bold' : ''}`}
style={{
minHeight: `${rowH}px`,
color: color || undefined,
whiteSpace: isMultiLine ? 'pre-wrap' : 'nowrap',
textOverflow: isMultiLine ? undefined : 'ellipsis',
}}
>
{text}
</div>
)
})
)}
</div>
)
})}
</div>
</div>
)
}