Files
breakpilot-lehrer/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx
Benjamin Admin f2bc62b4f5
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 46s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 2m43s
CI / test-python-agent-core (push) Successful in 35s
CI / test-nodejs-website (push) Successful in 1m4s
SpreadsheetView: bullet indentation, expanded rows, box borders
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) <noreply@anthropic.com>
2026-04-15 16:15:43 +02:00

281 lines
8.7 KiB
TypeScript

'use client'
/**
* SpreadsheetView — Fortune Sheet with multi-sheet support.
*
* Each zone (content + boxes) becomes its own Excel sheet tab,
* so each can have independent column widths optimized for its content.
*/
import { useMemo } from 'react'
import dynamic from 'next/dynamic'
const Workbook = dynamic(
() => import('@fortune-sheet/react').then((m) => m.Workbook),
{ ssr: false, loading: () => <div className="py-8 text-center text-sm text-gray-400">Spreadsheet wird geladen...</div> },
)
import '@fortune-sheet/react/dist/index.css'
import type { GridZone } from '@/components/grid-editor/types'
interface SpreadsheetViewProps {
gridData: any
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 || ''
// Sheet name
let name: string
if (!isBox) {
name = 'Vokabeln'
} else {
const firstText = zone.cells?.[0]?.text ?? `Box ${sheetIndex}`
const cleaned = firstText.replace(/[^\w\s\u00C0-\u024F„"]/g, '').trim()
name = cleaned.length > 25 ? cleaned.slice(0, 25) + '…' : cleaned || `Box ${sheetIndex}`
}
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<string, any> = {}
for (const cell of expandedCells) {
const r = cell.row_index
const c = cell.col_index
const text = cell.text ?? ''
const meta = rowMeta[r] || {}
// 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 isLargerFont = avgWbH > 0 && medianWordH > 0 && avgWbH > medianWordH * 1.3
const v: any = { v: text, m: text }
// Bold: headers, is_bold, larger font, first bullet line with •
if (cell.is_bold || meta.isHeader || isLargerFont) {
v.bl = 1
}
// Larger font for box titles
if (isLargerFont && isBox) {
v.fs = 12
}
// 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: light tinted background
if (isBox && !meta.isHeader && boxColor) {
v.bg = `${boxColor}08`
}
// 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
celldata.push({ r, c, v })
// Colspan → merge (only on non-expanded rows)
const colspan = cell.colspan || 0
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
const columnlen: Record<string, number> = {}
for (const col of (zone.columns || [])) {
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 len = (c.text ?? '').length
if (len > maxTextLen) maxTextLen = len
}
const autoWidth = Math.max(60, maxTextLen * 7.5 + 16)
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)))
columnlen[String(col.index)] = Math.round(Math.max(autoWidth, scaledPxW))
}
// Row heights
const rowlen: Record<string, number> = {}
for (let ri = 0; ri < numRows; ri++) {
rowlen[String(ri)] = 24
}
// Border info
const borderInfo: any[] = []
// Box: colored outside border
if (isBox && boxColor && numRows > 0 && numCols > 0) {
borderInfo.push({
rangeType: 'range',
borderType: 'border-outside',
color: boxColor,
style: 5,
range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }],
})
borderInfo.push({
rangeType: 'range',
borderType: 'border-inside',
color: `${boxColor}40`,
style: 1,
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] }],
})
}
return {
name,
id: `zone_${zone.zone_index}`,
celldata,
row: numRows,
column: Math.max(numCols, 1),
status: isFirst ? 1 : 0,
color: isBox ? boxColor : undefined,
config: {
merge: Object.keys(merges).length > 0 ? merges : undefined,
columnlen,
rowlen,
borderInfo: borderInfo.length > 0 ? borderInfo : undefined,
},
}
}
export function SpreadsheetView({ gridData, height = 600 }: SpreadsheetViewProps) {
const sheets = useMemo(() => {
if (!gridData?.zones) return []
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
return (a.bbox_px?.y ?? 0) - (b.bbox_px?.y ?? 0)
})
return sorted
.filter((z: GridZone) => z.cells && z.cells.length > 0)
.map((z: GridZone, i: number) => zoneToSheet(z, i, i === 0))
}, [gridData])
const maxRows = Math.max(0, ...sheets.map((s: any) => s.row || 0))
const estimatedHeight = Math.max(height, maxRows * 26 + 80)
if (sheets.length === 0) {
return <div className="p-4 text-center text-gray-400">Keine Daten für Spreadsheet.</div>
}
return (
<div style={{ width: '100%', height: `${estimatedHeight}px` }}>
<Workbook
data={sheets}
lang="en"
showToolbar
showFormulaBar={false}
showSheetTabs
toolbarItems={[
'undo', 'redo', '|',
'font-bold', 'font-italic', 'font-strikethrough', '|',
'font-color', 'background', '|',
'font-size', '|',
'horizontal-align', 'vertical-align', '|',
'text-wrap', 'merge-cell', '|',
'border',
]}
/>
</div>
)
}