281 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|