From f2bc62b4f5117d420e1a0140bd8343e75acf1021 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 15 Apr 2026 16:15:43 +0200 Subject: [PATCH] SpreadsheetView: bullet indentation, expanded rows, box borders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-line cells (\n): expanded into separate rows so each line gets its own cell. Continuation lines (after •) indented with leading spaces. Bullet marker lines (•) are bold. Font-size detection: cells with word_box height >1.3x median get bold and larger font (fs=12) for box titles. Headers: is_header rows always bold with light background tint. Box borders: thick colored outside border + thin inner grid lines. Content zone: light gray grid borders. Auto-fit column widths from longest text per column. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ocr-kombi/SpreadsheetView.tsx | 175 ++++++++++-------- 1 file changed, 102 insertions(+), 73 deletions(-) diff --git a/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx b/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx index abd791a..5954d43 100644 --- a/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx +++ b/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx @@ -24,11 +24,53 @@ interface SpreadsheetViewProps { height?: number } +/** Expand multi-line cells (\n) into separate rows for better display. */ +function expandMultiLineCells(zone: GridZone): { cells: any[]; numRows: number; rowMeta: any[] } { + const expandedCells: any[] = [] + const rowMeta: any[] = [] // metadata per expanded row + let expandedRow = 0 + + for (const row of (zone.rows || [])) { + const rowCells = (zone.cells || []).filter((c) => c.row_index === row.index) + const isHeader = row.is_header ?? false + + // Check if any cell in this row has \n + const maxLines = Math.max(1, ...rowCells.map((c) => (c.text ?? '').split('\n').length)) + + if (maxLines > 1) { + // Expand: each line becomes its own row + for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { + const isFirstLine = lineIdx === 0 + rowMeta.push({ isHeader, isIndented: !isFirstLine, originalRow: row.index }) + for (const cell of rowCells) { + const lines = (cell.text ?? '').split('\n') + const lineText = lineIdx < lines.length ? lines[lineIdx] : '' + expandedCells.push({ + ...cell, + row_index: expandedRow, + text: lineText, + _isIndented: !isFirstLine, + _isFirstBulletLine: isFirstLine, + }) + } + expandedRow++ + } + } else { + // Single line — keep as is + rowMeta.push({ isHeader, isIndented: false, originalRow: row.index }) + for (const cell of rowCells) { + expandedCells.push({ ...cell, row_index: expandedRow }) + } + expandedRow++ + } + } + return { cells: expandedCells, numRows: expandedRow, rowMeta } +} + /** Convert a single zone to a Fortune Sheet sheet object. */ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any { const isBox = zone.zone_type === 'box' const boxColor = (zone as any).box_bg_hex || '' - const layoutType = (zone as any).box_layout_type || '' // Sheet name let name: string @@ -40,137 +82,128 @@ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any name = cleaned.length > 25 ? cleaned.slice(0, 25) + '…' : cleaned || `Box ${sheetIndex}` } - const numRows = zone.rows?.length || 0 const numCols = zone.columns?.length || 1 + // Expand multi-line cells into separate rows + const { cells: expandedCells, numRows, rowMeta } = expandMultiLineCells(zone) + + // Compute zone-wide median word height for font-size detection + const allWordHeights = zone.cells + .flatMap((c: any) => (c.word_boxes || []).map((wb: any) => wb.height || 0)) + .filter((h: number) => h > 0) + const medianWordH = allWordHeights.length + ? [...allWordHeights].sort((a, b) => a - b)[Math.floor(allWordHeights.length / 2)] + : 0 + // Build celldata const celldata: any[] = [] const merges: Record = {} - for (const cell of (zone.cells || [])) { + for (const cell of expandedCells) { const r = cell.row_index const c = cell.col_index - let text = cell.text ?? '' + const text = cell.text ?? '' + const meta = rowMeta[r] || {} - // 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 + // Font size detection from word_boxes + const avgWbH = 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 isLargerFont = avgWbH > 0 && medianWordH > 0 && avgWbH > medianWordH * 1.3 - const v: any = { - v: text, - m: text, - } + const v: any = { v: text, m: text } - // Bold: header rows, is_bold cells, or cells with detectably larger font - if (cell.is_bold || isHeader || isLargerFont) { + // Bold: headers, is_bold, larger font, first bullet line with • + if (cell.is_bold || meta.isHeader || isLargerFont) { v.bl = 1 } - // Larger font for detected large text (e.g., box titles) + // Larger font for box titles if (isLargerFont && isBox) { - v.fs = 12 // slightly larger + v.fs = 12 } - // Header row light background - if (isHeader) { + // Indentation for bullet continuation lines + if (cell._isIndented) { + v.ht = 0 // left-align + // Add visual indent via leading spaces (Fortune Sheet has no padding-left) + if (text && !text.startsWith(' ')) { + v.v = ' ' + text + v.m = ' ' + text + } + } + + // Bullet line: keep • visible + if (cell._isFirstBulletLine && text.startsWith('•')) { + v.bl = 1 // bold bullet marker line + } + + // Header row background + if (meta.isHeader) { v.bg = isBox ? `${boxColor || '#2563eb'}18` : '#f0f4ff' } - // Box cells get light tinted background - if (isBox && !isHeader && boxColor) { + // Box cells: light tinted background + if (isBox && !meta.isHeader && boxColor) { v.bg = `${boxColor}08` } - // Text color from OCR word_boxes + // Text color from OCR 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: enable text wrap and preserve formatting - if (text.includes('\n')) { - v.tb = '2' // text wrap enabled - } - celldata.push({ r, c, v }) - // Colspan → merge + // Colspan → merge (only on non-expanded rows) const colspan = cell.colspan || 0 - if (colspan > 1 || cell.col_type === 'spanning_header') { + if ((colspan > 1 || cell.col_type === 'spanning_header') && !cell._isIndented) { const cs = colspan || numCols merges[`${r}_${c}`] = { r, c, rs: 1, cs } } } - // Column widths — auto-fit based on longest text in each column + // Column widths — auto-fit based on longest text const columnlen: Record = {} for (const col of (zone.columns || [])) { - // Find the longest text in this column (considering only non-spanning cells) - const colCells = (zone.cells || []).filter( + const colCells = expandedCells.filter( (c: any) => c.col_index === col.index && c.col_type !== 'spanning_header' ) let maxTextLen = 0 for (const c of colCells) { - const text = c.text ?? '' - // For multi-line cells, use the longest line - const lines = text.split('\n') - const longestLine = Math.max(0, ...lines.map((l: string) => l.length)) - if (longestLine > maxTextLen) maxTextLen = longestLine + const len = (c.text ?? '').length + if (len > maxTextLen) maxTextLen = len } - // ~7px per character as a rough estimate, minimum 60px const autoWidth = Math.max(60, maxTextLen * 7.5 + 16) - // Also consider original pixel width (scaled down) const pxW = (col.x_max_px ?? 0) - (col.x_min_px ?? 0) const scaledPxW = Math.max(60, Math.round(pxW * (numCols <= 2 ? 0.6 : 0.4))) - // Use the larger of auto-fit or scaled pixel width columnlen[String(col.index)] = Math.round(Math.max(autoWidth, scaledPxW)) } - // Row heights — taller for multi-line cells + // Row heights 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)) - const baseH = isBox ? 22 : 24 - rowlen[String(row.index)] = Math.max(baseH, baseH * maxLines) + for (let ri = 0; ri < numRows; ri++) { + rowlen[String(ri)] = 24 } // Border info const borderInfo: any[] = [] - // Box: colored outside border around entire sheet content + // Box: colored outside border if (isBox && boxColor && numRows > 0 && numCols > 0) { borderInfo.push({ rangeType: 'range', borderType: 'border-outside', color: boxColor, - style: 5, // thick (style 5 = thick in Fortune Sheet) - range: [{ - row: [0, numRows - 1], - column: [0, numCols - 1], - }], + style: 5, + 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], - }], + style: 1, + range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }], }) } @@ -181,10 +214,7 @@ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any borderType: 'border-all', color: '#e5e7eb', style: 1, - range: [{ - row: [0, numRows - 1], - column: [0, numCols - 1], - }], + range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }], }) } @@ -192,7 +222,7 @@ function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any name, id: `zone_${zone.zone_index}`, celldata, - row: numRows, // exact row count — no padding + row: numRows, column: Math.max(numCols, 1), status: isFirst ? 1 : 0, color: isBox ? boxColor : undefined, @@ -220,9 +250,8 @@ 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 + const estimatedHeight = Math.max(height, maxRows * 26 + 80) if (sheets.length === 0) { return
Keine Daten für Spreadsheet.