From e131aa719ed982acbca81be3e5f5ed11169e994c Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 15 Apr 2026 09:29:50 +0200 Subject: [PATCH] SpreadsheetView: formatting improvements for Excel-like display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Height: sheet height auto-calculated from row count (26px/row + toolbar), no more cutoff at 21 rows. Row count set to exact (no padding). Box borders: thick colored outside border + thin inner grid lines. Content zone: light gray grid lines on all cells. Headers: bold (bl=1) for is_header rows. Larger font detected via word_box height comparison (>1.3x median → fs=12 + bold). Box cells: light tinted background from box_bg_hex. Header cells in boxes: slightly stronger tint. Multi-line cells: text wrap enabled (tb='2'), \n preserved. Bullet points (•) and indentation preserved in cell text. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ocr-kombi/SpreadsheetView.tsx | 106 +++++++++++++----- .../components/ocr-kombi/StepAnsicht.tsx | 4 +- 2 files changed, 83 insertions(+), 27 deletions(-) diff --git a/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx b/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx index b38db0f..430c338 100644 --- a/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx +++ b/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx @@ -17,10 +17,9 @@ const Workbook = dynamic( import '@fortune-sheet/react/dist/index.css' -import type { GridZone, GridEditorCell } from '@/components/grid-editor/types' +import type { GridZone } from '@/components/grid-editor/types' interface SpreadsheetViewProps { - /** Multi-zone grid data (grid_editor_result) */ gridData: any height?: number } @@ -36,10 +35,9 @@ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any if (!isBox) { name = 'Vokabeln' } else { - // Use first cell text as name (truncated) const firstText = zone.cells?.[0]?.text ?? `Box ${sheetIndex}` - const cleaned = firstText.replace(/[^\w\s\u00C0-\u024F]/g, '').trim() - name = cleaned.length > 20 ? cleaned.slice(0, 20) + '...' : cleaned || `Box ${sheetIndex}` + const cleaned = firstText.replace(/[^\w\s\u00C0-\u024F„"]/g, '').trim() + name = cleaned.length > 25 ? cleaned.slice(0, 25) + '…' : cleaned || `Box ${sheetIndex}` } const numRows = zone.rows?.length || 0 @@ -52,30 +50,58 @@ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any for (const cell of (zone.cells || [])) { const r = cell.row_index const c = cell.col_index - const text = cell.text ?? '' + let text = cell.text ?? '' + + // Detect if this is a header row + const row = zone.rows?.find((rr) => rr.index === r) + const isHeader = row?.is_header ?? false + + // Detect if word_boxes indicate larger font (header detection) + const avgWordHeight = cell.word_boxes?.length + ? cell.word_boxes.reduce((s: number, wb: any) => s + (wb.height || 0), 0) / cell.word_boxes.length + : 0 + const zoneAvgHeight = zone.cells + .flatMap((c: any) => (c.word_boxes || []).map((wb: any) => wb.height || 0)) + .filter((h: number) => h > 0) + const medianHeight = zoneAvgHeight.length + ? [...zoneAvgHeight].sort((a, b) => a - b)[Math.floor(zoneAvgHeight.length / 2)] + : 0 + const isLargerFont = avgWordHeight > 0 && medianHeight > 0 && avgWordHeight > medianHeight * 1.3 const v: any = { v: text, m: text, } - // Bold - if (cell.is_bold) v.bl = 1 - - // Header row styling - const row = zone.rows?.find((rr) => rr.index === r) - if (row?.is_header) { + // Bold: header rows, is_bold cells, or cells with detectably larger font + if (cell.is_bold || isHeader || isLargerFont) { v.bl = 1 - v.bg = '#f0f4ff' } - // Text color + // Larger font for detected large text (e.g., box titles) + if (isLargerFont && isBox) { + v.fs = 12 // slightly larger + } + + // Header row light background + if (isHeader) { + v.bg = isBox ? `${boxColor || '#2563eb'}18` : '#f0f4ff' + } + + // Box cells get light tinted background + if (isBox && !isHeader && boxColor) { + v.bg = `${boxColor}08` + } + + // Text color from OCR word_boxes const color = cell.color_override ?? cell.word_boxes?.find((wb: any) => wb.color_name && wb.color_name !== 'black')?.color if (color) v.fc = color - // Multi-line: text wrap - if (text.includes('\n')) v.tb = '2' + // Multi-line text: enable text wrap and preserve formatting + if (text.includes('\n')) { + v.tb = '2' // text wrap enabled + } celldata.push({ r, c, v }) @@ -91,27 +117,54 @@ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any const columnlen: Record = {} for (const col of (zone.columns || [])) { const pxW = (col.x_max_px ?? 0) - (col.x_min_px ?? 0) - // Scale: wider for small column counts (boxes), narrower for many columns const scaleFactor = numCols <= 2 ? 0.6 : 0.4 columnlen[String(col.index)] = Math.max(80, Math.round(pxW * scaleFactor)) } - // Row heights + // Row heights — taller for multi-line cells const rowlen: Record = {} for (const row of (zone.rows || [])) { const rowCells = (zone.cells || []).filter((c: any) => c.row_index === row.index) const maxLines = Math.max(1, ...rowCells.map((c: any) => (c.text ?? '').split('\n').length)) - rowlen[String(row.index)] = Math.max(24, 24 * maxLines) + const baseH = isBox ? 22 : 24 + rowlen[String(row.index)] = Math.max(baseH, baseH * maxLines) } - // Box border around entire content + // Border info const borderInfo: any[] = [] + + // Box: colored outside border around entire sheet content if (isBox && boxColor && numRows > 0 && numCols > 0) { borderInfo.push({ rangeType: 'range', borderType: 'border-outside', color: boxColor, - style: 2, // thick + style: 5, // thick (style 5 = thick in Fortune Sheet) + range: [{ + row: [0, numRows - 1], + column: [0, numCols - 1], + }], + }) + // Also add light inner borders for box cells + borderInfo.push({ + rangeType: 'range', + borderType: 'border-inside', + color: `${boxColor}40`, + style: 1, // thin + range: [{ + row: [0, numRows - 1], + column: [0, numCols - 1], + }], + }) + } + + // Content zone: light grid lines + if (!isBox && numRows > 0 && numCols > 0) { + borderInfo.push({ + rangeType: 'range', + borderType: 'border-all', + color: '#e5e7eb', + style: 1, range: [{ row: [0, numRows - 1], column: [0, numCols - 1], @@ -123,9 +176,9 @@ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any name, id: `zone_${zone.zone_index}`, celldata, - row: Math.max(numRows, 3), // minimum 3 rows + row: numRows, // exact row count — no padding column: Math.max(numCols, 1), - status: isFirst ? 1 : 0, // first sheet is active + status: isFirst ? 1 : 0, color: isBox ? boxColor : undefined, config: { merge: Object.keys(merges).length > 0 ? merges : undefined, @@ -140,7 +193,6 @@ export function SpreadsheetView({ gridData, height = 600 }: SpreadsheetViewProps const sheets = useMemo(() => { if (!gridData?.zones) return [] - // Sort: content zones first, then boxes by y-position const sorted = [...gridData.zones].sort((a: GridZone, b: GridZone) => { if (a.zone_type === 'content' && b.zone_type !== 'content') return -1 if (a.zone_type !== 'content' && b.zone_type === 'content') return 1 @@ -152,12 +204,16 @@ export function SpreadsheetView({ gridData, height = 600 }: SpreadsheetViewProps .map((z: GridZone, i: number) => zoneToSheet(z, i, i === 0)) }, [gridData]) + // Calculate minimum height to show all rows of the largest sheet + const maxRows = Math.max(0, ...sheets.map((s: any) => s.row || 0)) + const estimatedHeight = Math.max(height, maxRows * 26 + 80) // 26px per row + toolbar + if (sheets.length === 0) { return
Keine Daten für Spreadsheet.
} return ( -
+
- {/* RIGHT: Fortune Sheet */} + {/* RIGHT: Fortune Sheet — height adapts to content */}
- +