From b1cdb2531c0f5f16216a80bf1c71015b018c8421 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 17 Mar 2026 13:48:47 +0100 Subject: [PATCH] feat: CSS Grid editor with OCR-measured column widths and row heights Backend: add layout_metrics (avg_row_height_px, font_size_suggestion_px) to build-grid response for faithful grid reconstruction. Frontend: rewrite GridTable from HTML to CSS Grid layout. Column widths are now proportional to the OCR-measured x_min/x_max positions. Row heights use the average content row height from the scan. Column and row resize via drag handles (Excel-like). Font: add Noto Sans (supports IPA characters) via next/font/google. Co-Authored-By: Claude Opus 4.6 --- admin-lehrer/app/layout.tsx | 9 +- .../components/grid-editor/GridEditor.tsx | 1 + .../components/grid-editor/GridTable.tsx | 383 ++++++++++++++---- admin-lehrer/components/grid-editor/types.ts | 9 + klausur-service/backend/grid_editor_api.py | 20 + 5 files changed, 343 insertions(+), 79 deletions(-) diff --git a/admin-lehrer/app/layout.tsx b/admin-lehrer/app/layout.tsx index 831affd..72a2033 100644 --- a/admin-lehrer/app/layout.tsx +++ b/admin-lehrer/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' import localFont from 'next/font/local' +import { Noto_Sans } from 'next/font/google' import './globals.css' const inter = localFont({ @@ -8,6 +9,12 @@ const inter = localFont({ display: 'swap', }) +const notoSans = Noto_Sans({ + subsets: ['latin', 'latin-ext'], + variable: '--font-noto-sans', + display: 'swap', +}) + export const metadata: Metadata = { title: 'BreakPilot Admin Lehrer KI', description: 'Neues Admin-Frontend mit verbesserter Navigation und Rollen-System', @@ -20,7 +27,7 @@ export default function RootLayout({ }) { return ( - {children} + {children} ) } diff --git a/admin-lehrer/components/grid-editor/GridEditor.tsx b/admin-lehrer/components/grid-editor/GridEditor.tsx index e159e9a..680489a 100644 --- a/admin-lehrer/components/grid-editor/GridEditor.tsx +++ b/admin-lehrer/components/grid-editor/GridEditor.tsx @@ -195,6 +195,7 @@ export function GridEditor({ sessionId, onNext }: GridEditorProps) { > void onCellTextChange: (cellId: string, text: string) => void @@ -13,8 +14,18 @@ interface GridTableProps { onNavigate: (cellId: string, direction: 'up' | 'down' | 'left' | 'right') => void } +/** Gutter width for row numbers (px). */ +const ROW_NUM_WIDTH = 36 + +/** Minimum column width in px so columns remain usable. */ +const MIN_COL_WIDTH = 40 + +/** Minimum row height in px. */ +const MIN_ROW_HEIGHT = 26 + export function GridTable({ zone, + layoutMetrics, selectedCell, onSelectCell, onCellTextChange, @@ -22,8 +33,69 @@ export function GridTable({ onToggleRowHeader, onNavigate, }: GridTableProps) { - const tableRef = useRef(null) + const containerRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(0) + // ---------------------------------------------------------------- + // Observe container width for scaling + // ---------------------------------------------------------------- + useEffect(() => { + const el = containerRef.current + if (!el) return + const ro = new ResizeObserver(([entry]) => { + setContainerWidth(entry.contentRect.width) + }) + ro.observe(el) + return () => ro.disconnect() + }, []) + + // ---------------------------------------------------------------- + // Compute column widths from OCR measurements + // ---------------------------------------------------------------- + const zoneWidthPx = zone.bbox_px.w || layoutMetrics?.page_width_px || 1 + const scale = containerWidth > 0 ? (containerWidth - ROW_NUM_WIDTH) / zoneWidthPx : 1 + + // Column widths in original pixels, then scaled to container + const [colWidthOverrides, setColWidthOverrides] = useState(null) + + const columnWidthsPx = zone.columns.map((col) => col.x_max_px - col.x_min_px) + + const effectiveColWidths = (colWidthOverrides ?? columnWidthsPx).map( + (w) => Math.max(MIN_COL_WIDTH, w * scale), + ) + + // ---------------------------------------------------------------- + // Compute row heights from OCR measurements + // ---------------------------------------------------------------- + const avgRowHeightPx = layoutMetrics?.avg_row_height_px ?? 30 + const [rowHeightOverrides, setRowHeightOverrides] = useState>(new Map()) + + const getRowHeight = (rowIndex: number, isHeader: boolean): number => { + if (rowHeightOverrides.has(rowIndex)) { + return rowHeightOverrides.get(rowIndex)! + } + const row = zone.rows.find((r) => r.index === rowIndex) + if (!row) return Math.max(MIN_ROW_HEIGHT, avgRowHeightPx * scale) + + if (isHeader) { + // Headers keep their measured height + const measuredH = row.y_max_px - row.y_min_px + return Math.max(MIN_ROW_HEIGHT, measuredH * scale) + } + // Content rows use average for uniformity + return Math.max(MIN_ROW_HEIGHT, avgRowHeightPx * scale) + } + + // ---------------------------------------------------------------- + // Font size from layout metrics + // ---------------------------------------------------------------- + const baseFontSize = layoutMetrics?.font_size_suggestion_px + ? Math.max(11, layoutMetrics.font_size_suggestion_px * scale) + : 13 + + // ---------------------------------------------------------------- + // Keyboard navigation + // ---------------------------------------------------------------- const handleKeyDown = useCallback( (e: React.KeyboardEvent, cellId: string) => { if (e.key === 'Tab') { @@ -45,7 +117,9 @@ export function GridTable({ [onNavigate], ) - // Build row→col cell lookup + // ---------------------------------------------------------------- + // Cell lookup + // ---------------------------------------------------------------- const cellMap = new Map() for (const cell of zone.cells) { cellMap.set(`${cell.row_index}_${cell.col_index}`, cell) @@ -62,100 +136,253 @@ export function GridTable({ return null } + // ---------------------------------------------------------------- + // Column resize (drag) + // ---------------------------------------------------------------- + const handleColResizeStart = useCallback( + (colIndex: number, startX: number) => { + const baseWidths = colWidthOverrides ?? [...columnWidthsPx] + + const handleMouseMove = (e: MouseEvent) => { + const deltaPx = (e.clientX - startX) / scale + const newWidths = [...baseWidths] + newWidths[colIndex] = Math.max(20, baseWidths[colIndex] + deltaPx) + // Steal from next column to keep total constant + if (colIndex + 1 < newWidths.length) { + newWidths[colIndex + 1] = Math.max(20, baseWidths[colIndex + 1] - deltaPx) + } + setColWidthOverrides(newWidths) + } + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, + [colWidthOverrides, columnWidthsPx, scale], + ) + + // ---------------------------------------------------------------- + // Row resize (drag) + // ---------------------------------------------------------------- + const handleRowResizeStart = useCallback( + (rowIndex: number, startY: number, currentHeight: number) => { + const handleMouseMove = (e: MouseEvent) => { + const delta = e.clientY - startY + const newH = Math.max(MIN_ROW_HEIGHT, currentHeight + delta) + setRowHeightOverrides((prev) => { + const next = new Map(prev) + next.set(rowIndex, newH) + return next + }) + } + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, + [], + ) + const isBoxZone = zone.zone_type === 'box' + const numCols = zone.columns.length + + // CSS Grid template for columns: row-number gutter + proportional columns + const gridTemplateCols = `${ROW_NUM_WIDTH}px ${effectiveColWidths.map((w) => `${w.toFixed(1)}px`).join(' ')}` return ( -
+
{/* Zone label */}
- + {isBoxZone ? 'Box' : 'Inhalt'} Zone {zone.zone_index} - {zone.columns.length} Spalten, {zone.rows.length} Zeilen, {zone.cells.length} Zellen + + {zone.columns.length} Spalten, {zone.rows.length} Zeilen, {zone.cells.length} Zellen +
-
- {/* Column headers */} - - - {/* Row number header */} - - ))} - - + {/* ============================================================ */} + {/* CSS Grid — column headers */} + {/* ============================================================ */} +
+ {/* Header: row-number corner */} +
-
- {zone.rows.map((row) => ( - - {/* Row number */} - + {/* Bottom-edge resize handle */} +
{ + e.stopPropagation() + handleRowResizeStart(row.index, e.clientY, rowH) + }} + /> +
- {/* Cells */} - {zone.columns.map((col) => { - const cell = cellMap.get(`${row.index}_${col.index}`) - const cellId = cell?.cell_id ?? `Z${zone.zone_index}_R${String(row.index).padStart(2, '0')}_C${col.index}` - const isSelected = selectedCell === cellId - const isBold = col.bold || cell?.is_bold - const isLowConf = cell && cell.confidence > 0 && cell.confidence < 60 - const cellColor = getCellColor(cell) - const hasColoredWords = cell?.word_boxes?.some( - (wb) => wb.color_name && wb.color_name !== 'black', - ) ?? false + {/* Cells — spanning header or normal columns */} + {isSpanning ? ( +
+ {(() => { + const spanCell = zone.cells.find( + (c) => c.row_index === row.index && c.col_type === 'spanning_header', + ) + if (!spanCell) return null + const cellId = spanCell.cell_id + const isSelected = selectedCell === cellId + const cellColor = getCellColor(spanCell) + return ( +
+ {cellColor && ( + + )} + onCellTextChange(cellId, e.target.value)} + onFocus={() => onSelectCell(cellId)} + onKeyDown={(e) => handleKeyDown(e, cellId)} + className={`w-full px-3 py-1 bg-transparent border-0 outline-none text-center ${ + isSelected ? 'ring-2 ring-teal-500 ring-inset rounded' : '' + }`} + style={{ color: cellColor || undefined }} + spellCheck={false} + /> +
+ ) + })()} +
+ ) : ( + zone.columns.map((col) => { + const cell = cellMap.get(`${row.index}_${col.index}`) + const cellId = + cell?.cell_id ?? + `Z${zone.zone_index}_R${String(row.index).padStart(2, '0')}_C${col.index}` + const isSelected = selectedCell === cellId + const isBold = col.bold || cell?.is_bold + const isLowConf = cell && cell.confidence > 0 && cell.confidence < 60 + const cellColor = getCellColor(cell) + const hasColoredWords = + cell?.word_boxes?.some( + (wb) => wb.color_name && wb.color_name !== 'black', + ) ?? false - return ( - - ) - })} - - ))} - -
- {zone.columns.map((col) => ( - onToggleColumnBold(zone.zone_index, col.index)} - title={`Spalte ${col.index + 1} — Klick fuer Fett-Toggle`} - > -
- {col.label} - {col.bold && ( - - B - - )} -
-
( +
onToggleColumnBold(zone.zone_index, col.index)} + title={`Spalte ${col.index + 1} — Klick fuer Fett-Toggle`} + > +
+ {col.label} + {col.bold && ( + + B + + )} +
+ {/* Right-edge resize handle */} + {ci < numCols - 1 && ( +
{ + e.stopPropagation() + handleColResizeStart(ci, e.clientX) + }} + /> + )} +
+ ))} + + {/* ============================================================ */} + {/* Data rows */} + {/* ============================================================ */} + {zone.rows.map((row) => { + const rowH = getRowHeight(row.index, row.is_header) + const isSpanning = zone.cells.some( + (c) => c.row_index === row.index && c.col_type === 'spanning_header', + ) + + return ( +
+ {/* Row number cell */} +
onToggleRowHeader(zone.zone_index, row.index)} title={`Zeile ${row.index + 1} — Klick fuer Header-Toggle`} > {row.index + 1} {row.is_header && H} -
-
+ return ( +
{cellColor && ( wb.color_name !== 'black')?.color_name}`} + title={`Farbe: ${cell?.word_boxes?.find((wb) => wb.color_name !== 'black')?.color_name}`} /> )} {/* Per-word colored display when not editing */} {hasColoredWords && !isSelected ? (
{ onSelectCell(cellId) setTimeout(() => document.getElementById(`cell-${cellId}`)?.focus(), 0) @@ -185,20 +412,20 @@ export function GridTable({ }} onFocus={() => onSelectCell(cellId)} onKeyDown={(e) => handleKeyDown(e, cellId)} - className={`w-full px-2 py-1.5 bg-transparent border-0 outline-none ${ + className={`w-full px-2 bg-transparent border-0 outline-none ${ isBold ? 'font-bold' : 'font-normal' - } ${row.is_header ? 'text-base' : 'text-sm'}`} + }`} spellCheck={false} /> )}
-
+ ) + }) + )} + + ) + })} + ) } diff --git a/admin-lehrer/components/grid-editor/types.ts b/admin-lehrer/components/grid-editor/types.ts index 4ebcc89..6c16f2e 100644 --- a/admin-lehrer/components/grid-editor/types.ts +++ b/admin-lehrer/components/grid-editor/types.ts @@ -3,6 +3,14 @@ import type { OcrWordBox } from '@/app/(admin)/ai/ocr-pipeline/types' // Re-export for convenience export type { OcrWordBox } +/** Layout metrics derived from OCR word positions for faithful grid reconstruction. */ +export interface LayoutMetrics { + page_width_px: number + page_height_px: number + avg_row_height_px: number + font_size_suggestion_px: number +} + /** A complete structured grid with zones, ready for the Excel-like editor. */ export interface StructuredGrid { session_id: string @@ -12,6 +20,7 @@ export interface StructuredGrid { boxes_detected: number summary: GridSummary formatting: GridFormatting + layout_metrics?: LayoutMetrics duration_seconds: number edited?: boolean } diff --git a/klausur-service/backend/grid_editor_api.py b/klausur-service/backend/grid_editor_api.py index ff8722a..fdf189b 100644 --- a/klausur-service/backend/grid_editor_api.py +++ b/klausur-service/backend/grid_editor_api.py @@ -991,6 +991,20 @@ async def build_grid(session_id: str): cn = wb.get("color_name", "black") color_stats[cn] = color_stats.get(cn, 0) + 1 + # Compute layout metrics for faithful grid reconstruction + all_content_row_heights: List[float] = [] + for z in zones_data: + for row in z.get("rows", []): + if not row.get("is_header", False): + h = row.get("y_max_px", 0) - row.get("y_min_px", 0) + if h > 0: + all_content_row_heights.append(h) + avg_row_height = ( + sum(all_content_row_heights) / len(all_content_row_heights) + if all_content_row_heights else 30.0 + ) + font_size_suggestion = max(10, int(avg_row_height * 0.6)) + result = { "session_id": session_id, "image_width": img_w, @@ -1010,6 +1024,12 @@ async def build_grid(session_id: str): "bold_columns": [], "header_rows": [], }, + "layout_metrics": { + "page_width_px": img_w, + "page_height_px": img_h, + "avg_row_height_px": round(avg_row_height, 1), + "font_size_suggestion_px": font_size_suggestion, + }, "duration_seconds": round(duration, 2), }