ui-nyla/src/components/ContentPreview/renderers/ExcelRenderer.tsx

281 lines
8.1 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import * as XLSX from 'xlsx';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css';
interface ExcelRendererProps {
blob: Blob;
fileName: string;
onError: (message: string) => void;
}
const EXCEL_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'text/csv',
'application/csv',
]);
export function isExcelMimeType(mimeType?: string, fileName?: string): boolean {
if (mimeType && EXCEL_MIME_TYPES.has(mimeType)) return true;
if (fileName && /\.(xlsx|xls|xlsm|xlsb|ods|csv)$/i.test(fileName)) return true;
return false;
}
interface RenderedCell {
display: string;
rawType: 'n' | 's' | 'b' | 'd' | 'e' | 'z' | string;
rowspan: number;
colspan: number;
skip: boolean;
}
interface RenderedSheet {
name: string;
cols: number;
rows: number;
colWidthsPx: number[];
rowHeightsPx: (number | null)[];
cells: RenderedCell[][];
}
function renderSheet(ws: XLSX.WorkSheet, name: string): RenderedSheet {
const ref = ws['!ref'];
if (!ref) {
return { name, cols: 0, rows: 0, colWidthsPx: [], rowHeightsPx: [], cells: [] };
}
const range = XLSX.utils.decode_range(ref);
const rows = range.e.r - range.s.r + 1;
const cols = range.e.c - range.s.c + 1;
const cells: RenderedCell[][] = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => ({
display: '',
rawType: 'z',
rowspan: 1,
colspan: 1,
skip: false,
})),
);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const address = XLSX.utils.encode_cell({ r: r + range.s.r, c: c + range.s.c });
const cell = ws[address] as XLSX.CellObject | undefined;
if (!cell) continue;
const display = cell.w ?? (cell.v !== undefined && cell.v !== null ? String(cell.v) : '');
cells[r][c].display = display;
cells[r][c].rawType = cell.t ?? 'z';
}
}
const merges = ws['!merges'] ?? [];
for (const merge of merges) {
const rs = merge.s.r - range.s.r;
const cs = merge.s.c - range.s.c;
const re = merge.e.r - range.s.r;
const ce = merge.e.c - range.s.c;
if (rs < 0 || cs < 0 || re >= rows || ce >= cols) continue;
cells[rs][cs].rowspan = re - rs + 1;
cells[rs][cs].colspan = ce - cs + 1;
for (let r = rs; r <= re; r++) {
for (let c = cs; c <= ce; c++) {
if (r === rs && c === cs) continue;
cells[r][c].skip = true;
}
}
}
const colWidthsPx: number[] = [];
const colsMeta = ws['!cols'] ?? [];
for (let c = 0; c < cols; c++) {
const meta = colsMeta[c + range.s.c];
if (meta?.wpx) {
colWidthsPx.push(meta.wpx);
} else if (meta?.wch) {
colWidthsPx.push(Math.round(meta.wch * 7 + 8));
} else if (meta?.width) {
colWidthsPx.push(Math.round(meta.width * 7 + 8));
} else {
colWidthsPx.push(80);
}
}
const rowHeightsPx: (number | null)[] = [];
const rowsMeta = ws['!rows'] ?? [];
for (let r = 0; r < rows; r++) {
const meta = rowsMeta[r + range.s.r];
if (meta?.hpx) {
rowHeightsPx.push(meta.hpx);
} else if (meta?.hpt) {
rowHeightsPx.push(Math.round((meta.hpt * 4) / 3));
} else {
rowHeightsPx.push(null);
}
}
return { name, cols, rows, colWidthsPx, rowHeightsPx, cells };
}
function alignmentForCell(cell: RenderedCell): 'left' | 'right' | 'center' {
if (cell.rawType === 'n' || cell.rawType === 'd') return 'right';
if (cell.rawType === 'b') return 'center';
return 'left';
}
function colLabel(index: number): string {
let result = '';
let n = index;
do {
result = String.fromCharCode(65 + (n % 26)) + result;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return result;
}
export function ExcelRenderer({ blob, fileName, onError }: ExcelRendererProps) {
const { t } = useLanguage();
const [sheets, setSheets] = useState<RenderedSheet[]>([]);
const [activeSheet, setActiveSheet] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setLocalError(null);
blob
.arrayBuffer()
.then(buffer => {
const workbook = XLSX.read(buffer, {
type: 'array',
cellDates: true,
cellNF: true,
cellStyles: true,
});
const parsed = workbook.SheetNames.map(name =>
renderSheet(workbook.Sheets[name], name),
);
if (cancelled) return;
setSheets(parsed);
setActiveSheet(parsed[0]?.name ?? null);
setLoading(false);
})
.catch(err => {
if (cancelled) return;
const msg = err?.message ?? t('Tabelle konnte nicht gerendert werden.');
setLocalError(msg);
setLoading(false);
onError(msg);
});
return () => {
cancelled = true;
};
}, [blob, onError, t]);
const current = useMemo(
() => sheets.find(s => s.name === activeSheet) ?? null,
[sheets, activeSheet],
);
if (loading) {
return (
<div className={styles.textContainer}>
<div className={styles.textHeader}>
<span className={styles.textTitle}>{t('Tabelle wird geladen...')}</span>
</div>
</div>
);
}
if (localError) {
return (
<div className={styles.textContainer}>
<div className={styles.textHeader}>
<span className={styles.textTitle}>{fileName}</span>
</div>
<div style={{ padding: '1rem', color: 'var(--color-error)' }}>{localError}</div>
</div>
);
}
return (
<div className={styles.textContainer}>
<div className={styles.textHeader}>
<span className={styles.textTitle}>{fileName}</span>
{sheets.length > 1 && (
<div className={styles.excelTabs}>
{sheets.map(sheet => (
<button
key={sheet.name}
onClick={() => setActiveSheet(sheet.name)}
className={`${styles.excelTab} ${
sheet.name === activeSheet ? styles.excelTabActive : ''
}`}
>
{sheet.name}
</button>
))}
</div>
)}
</div>
<div className={styles.excelSheet}>
{current && current.rows > 0 ? (
<table className={styles.excelTable}>
<colgroup>
<col style={{ width: 40 }} />
{current.colWidthsPx.map((w, i) => (
<col key={i} style={{ width: w }} />
))}
</colgroup>
<thead>
<tr>
<th className={styles.excelCorner} />
{current.colWidthsPx.map((_, i) => (
<th key={i} className={styles.excelColHeader}>
{colLabel(i)}
</th>
))}
</tr>
</thead>
<tbody>
{current.cells.map((row, rIdx) => (
<tr
key={rIdx}
style={{
height: current.rowHeightsPx[rIdx] ?? undefined,
}}
>
<th className={styles.excelRowHeader}>{rIdx + 1}</th>
{row.map((cell, cIdx) => {
if (cell.skip) return null;
return (
<td
key={cIdx}
rowSpan={cell.rowspan}
colSpan={cell.colspan}
className={styles.excelCell}
style={{ textAlign: alignmentForCell(cell) }}
>
{cell.display}
</td>
);
})}
</tr>
))}
</tbody>
</table>
) : (
<div style={{ padding: '1rem', color: 'var(--color-text-secondary)' }}>
{t('Dieses Arbeitsblatt ist leer.')}
</div>
)}
</div>
</div>
);
}