Skip to content

Commit e441309

Browse files
lukischclaude
andcommitted
feat(preview): Excel-Vorschau für .xlsx/.xls (read-only, Blatt-Auswahl, Fallback)
- src/core/xlsx_reader.py: Qt-freier Pure-Logic-Reader; read_workbook_meta (Blattnamen ohne Zelllast) + read_workbook_sheet (≤100×50 via iter_rows). openpyxl/xlrd per Import-Guard; fehlende Lib → WorkbookMeta.error statt Crash. - ExcelPreview-Widget in preview_panel.py: QComboBox für Blatt-Auswahl, QTableWidget (read-only), Statuszeile + „Extern öffnen" als Fallback. - PreviewPanel._show_preview_for_path: .xlsx/.xls → Stack-Index 6. - tests/test_xlsx_preview.py: 15 Tests (Pure-Logic + GUI); 145/145 grün. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 57aaa34 commit e441309

4 files changed

Lines changed: 517 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
66
## [Unreleased]
77

88
### Hinzugefügt / Added
9+
- **Excel-Vorschau** (`.xlsx` / `.xls`, read-only) im Vorschau-Panel:
10+
- `src/core/xlsx_reader.py`: Qt-freier Pure-Logic-Reader mit `read_workbook_meta` (Blattnamen) und `read_workbook_sheet` (erste ≤ 100 Zeilen × 50 Spalten). openpyxl via Import-Guard; fehlende Lib oder Lesefehler → typisierte Fehlerobjekte, kein Crash. `.xls` via xlrd-Guard (optional).
11+
- `ExcelPreview`-Widget in `src/gui/preview/preview_panel.py`: Arbeitsblatt-Dropdown (QComboBox), Datentabelle (QTableWidget, read-only), Statuszeile + „Extern öffnen"-Schaltfläche als Fallback.
12+
- `PreviewPanel._show_preview_for_path` leitet `.xlsx`/`.xls` jetzt an `ExcelPreview` weiter (Stack-Index 6).
13+
- `tests/test_xlsx_preview.py`: 15 neue Tests — Blattnamen (Single/Multi), erste Zeilen/Spalten, leeres Blatt, Zeilen-Limit, Fallback bei korrupter Datei und fehlendem openpyxl, GUI-Integration. Gesamtsuite 145/145 grün.
914
- **Erweitertes Syntax-Highlighting** (`src/modules/editor/syntax_highlighter.py`): 5 neue Highlighter-Klassen für bisher nicht unterstützte Coding-Dateitypen.
1015
- `YAMLHighlighter` für `.yaml` / `.yml` (Dokument-Marker, Keys, Anchors/Aliases, Strings, Zahlen, Booleans, Tags, Kommentare)
1116
- `ShellHighlighter` für `.sh` / `.bash` / `.zsh` / `.fish` (Shebang, Keywords, Variablen, Strings, Kommentare)

src/core/xlsx_reader.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
xlsx_reader.py — Read-only-Zugriff auf Excel-Tabellen (.xlsx / .xls)
5+
6+
Keine Qt-Abhängigkeiten; vollständig ohne GUI testbar.
7+
Gibt bei Leseproblemen klar typisierte Fehlerobjekte zurück (kein Crash).
8+
"""
9+
from __future__ import annotations
10+
11+
import os
12+
from dataclasses import dataclass, field
13+
from typing import Any
14+
15+
# Optionale Imports — fehlendes Paket führt zum Fallback, nicht zum Abbruch
16+
try:
17+
import openpyxl
18+
HAS_OPENPYXL = True
19+
except ImportError:
20+
openpyxl = None # type: ignore[assignment]
21+
HAS_OPENPYXL = False
22+
23+
try:
24+
import xlrd
25+
HAS_XLRD = True
26+
except ImportError:
27+
xlrd = None # type: ignore[assignment]
28+
HAS_XLRD = False
29+
30+
31+
# Maximale Zeilen/Spalten für die Vorschau
32+
MAX_ROWS: int = 100
33+
MAX_COLS: int = 50
34+
35+
36+
class XlsxReadError(Exception):
37+
"""Wird geworfen, wenn die Datei nicht gelesen werden kann."""
38+
39+
40+
@dataclass
41+
class WorkbookMeta:
42+
"""Ergebnis des Metadaten-Lesevorgangs (Blattnamen, kein Zelleninhalt)."""
43+
sheets: list[str]
44+
active_sheet: str
45+
error: str | None = None
46+
47+
48+
def read_workbook_meta(path: str) -> WorkbookMeta:
49+
"""
50+
Liest nur die Arbeitsblatt-Namen einer Excel-Datei.
51+
Lädt keine Zellinhalte — sehr schnell, auch bei großen Dateien.
52+
Gibt bei Fehler ein WorkbookMeta mit gesetztem `error`-Feld zurück.
53+
"""
54+
ext = os.path.splitext(path)[1].lower()
55+
56+
if ext == ".xls":
57+
return _read_xls_meta(path)
58+
59+
if not HAS_OPENPYXL:
60+
return WorkbookMeta(sheets=[], active_sheet="", error="openpyxl nicht installiert")
61+
62+
try:
63+
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
64+
try:
65+
sheets = list(wb.sheetnames)
66+
active = sheets[0] if sheets else ""
67+
return WorkbookMeta(sheets=sheets, active_sheet=active)
68+
finally:
69+
wb.close()
70+
except Exception as exc:
71+
return WorkbookMeta(sheets=[], active_sheet="", error=str(exc))
72+
73+
74+
def read_workbook_sheet(
75+
path: str,
76+
sheet_name: str,
77+
max_rows: int = MAX_ROWS,
78+
max_cols: int = MAX_COLS,
79+
) -> list[list[Any]]:
80+
"""
81+
Liest ein einzelnes Arbeitsblatt und gibt die Zellen als Zeilenliste zurück.
82+
Maximal `max_rows` Zeilen und `max_cols` Spalten werden geladen.
83+
Bei Fehler wird XlsxReadError geworfen.
84+
"""
85+
ext = os.path.splitext(path)[1].lower()
86+
87+
if ext == ".xls":
88+
return _read_xls_sheet(path, sheet_name, max_rows, max_cols)
89+
90+
if not HAS_OPENPYXL:
91+
raise XlsxReadError("openpyxl nicht installiert")
92+
93+
try:
94+
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
95+
try:
96+
ws = wb[sheet_name]
97+
rows: list[list[Any]] = []
98+
for row_tuple in ws.iter_rows(
99+
max_row=max_rows, max_col=max_cols, values_only=True
100+
):
101+
rows.append(list(row_tuple))
102+
if len(rows) >= max_rows:
103+
break
104+
return rows
105+
finally:
106+
wb.close()
107+
except XlsxReadError:
108+
raise
109+
except Exception as exc:
110+
raise XlsxReadError(f"Lesefehler: {exc}") from exc
111+
112+
113+
# ── .xls-Fallback (xlrd) ────────────────────────────────────────────────────
114+
115+
def _read_xls_meta(path: str) -> WorkbookMeta:
116+
if not HAS_XLRD:
117+
return WorkbookMeta(
118+
sheets=[], active_sheet="",
119+
error=".xls-Vorschau benötigt xlrd (nicht installiert)"
120+
)
121+
try:
122+
wb = xlrd.open_workbook(path)
123+
sheets = wb.sheet_names()
124+
active = sheets[0] if sheets else ""
125+
return WorkbookMeta(sheets=list(sheets), active_sheet=active)
126+
except Exception as exc:
127+
return WorkbookMeta(sheets=[], active_sheet="", error=str(exc))
128+
129+
130+
def _read_xls_sheet(
131+
path: str,
132+
sheet_name: str,
133+
max_rows: int,
134+
max_cols: int,
135+
) -> list[list[Any]]:
136+
if not HAS_XLRD:
137+
raise XlsxReadError(".xls-Vorschau benötigt xlrd (nicht installiert)")
138+
try:
139+
wb = xlrd.open_workbook(path)
140+
ws = wb.sheet_by_name(sheet_name)
141+
rows: list[list[Any]] = []
142+
for r in range(min(ws.nrows, max_rows)):
143+
row = [ws.cell_value(r, c) for c in range(min(ws.ncols, max_cols))]
144+
rows.append(row)
145+
return rows
146+
except XlsxReadError:
147+
raise
148+
except Exception as exc:
149+
raise XlsxReadError(f"Lesefehler .xls: {exc}") from exc

src/gui/preview/preview_panel.py

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
"""
66

77
from PySide6.QtWidgets import (
8-
QWidget, QVBoxLayout, QStackedWidget,
8+
QWidget, QVBoxLayout, QHBoxLayout, QStackedWidget,
99
QLabel, QScrollArea, QGroupBox, QFormLayout, QLineEdit,
10-
QPlainTextEdit, QFrame
10+
QPlainTextEdit, QFrame,
11+
QComboBox, QPushButton, QTableWidget, QTableWidgetItem,
1112
)
1213
from PySide6.QtCore import Qt
1314
from PySide6.QtGui import QPixmap, QImage, QFont, QSyntaxHighlighter, QTextCharFormat, QColor
@@ -316,6 +317,134 @@ def show_metadata(self, path: str):
316317
self.created_label.setText(created.strftime("%d.%m.%Y %H:%M"))
317318

318319

320+
class ExcelPreview(QWidget):
321+
"""Read-only-Vorschau für .xlsx- und .xls-Dateien.
322+
323+
Zeigt eine Arbeitsblatt-Auswahl (Dropdown) und die ersten Zeilen/Spalten
324+
in einer Tabelle. Bei fehlender Bibliothek oder Lesefehler erscheint
325+
ein klar beschrifteter Fallback mit „Extern öffnen"-Schaltfläche.
326+
"""
327+
328+
def __init__(self, parent=None):
329+
super().__init__(parent)
330+
self._path: str | None = None
331+
self._setup_ui()
332+
333+
def _setup_ui(self):
334+
layout = QVBoxLayout(self)
335+
layout.setContentsMargins(4, 4, 4, 4)
336+
337+
# Kopfzeile: Arbeitsblatt-Auswahl
338+
header = QHBoxLayout()
339+
header.addWidget(QLabel("Arbeitsblatt:"))
340+
341+
self.sheet_combo = QComboBox()
342+
self.sheet_combo.setMinimumWidth(120)
343+
self.sheet_combo.currentTextChanged.connect(self._on_sheet_changed)
344+
header.addWidget(self.sheet_combo, 1)
345+
346+
self.open_extern_btn = QPushButton("Extern öffnen")
347+
self.open_extern_btn.setVisible(False)
348+
self.open_extern_btn.clicked.connect(self._open_extern)
349+
header.addWidget(self.open_extern_btn)
350+
351+
layout.addLayout(header)
352+
353+
# Statuszeile (Fehler / Fallback-Hinweis)
354+
self.status_label = QLabel()
355+
self.status_label.setWordWrap(True)
356+
self.status_label.setVisible(False)
357+
layout.addWidget(self.status_label)
358+
359+
# Datentabelle
360+
self.table = QTableWidget()
361+
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
362+
self.table.setAlternatingRowColors(True)
363+
layout.addWidget(self.table, 1)
364+
365+
def load_file(self, path: str):
366+
"""Lädt eine Excel-Datei und zeigt das erste Arbeitsblatt an."""
367+
from core.xlsx_reader import read_workbook_meta
368+
369+
self._path = path
370+
self.sheet_combo.blockSignals(True)
371+
self.sheet_combo.clear()
372+
373+
meta = read_workbook_meta(path)
374+
if meta.error:
375+
self._show_fallback(meta.error)
376+
self.sheet_combo.blockSignals(False)
377+
return
378+
379+
self.sheet_combo.addItems(meta.sheets)
380+
self.sheet_combo.blockSignals(False)
381+
self.status_label.setVisible(False)
382+
self.open_extern_btn.setVisible(False)
383+
384+
if meta.active_sheet:
385+
self._load_sheet(meta.active_sheet)
386+
387+
def _on_sheet_changed(self, sheet_name: str):
388+
if sheet_name:
389+
self._load_sheet(sheet_name)
390+
391+
def _load_sheet(self, sheet_name: str):
392+
"""Füllt die Tabelle mit den Zellinhalten des gewählten Arbeitsblatts."""
393+
from core.xlsx_reader import read_workbook_sheet, XlsxReadError
394+
395+
if not self._path:
396+
return
397+
398+
try:
399+
rows = read_workbook_sheet(self._path, sheet_name)
400+
except XlsxReadError as exc:
401+
self._show_fallback(str(exc))
402+
return
403+
404+
if not rows:
405+
self.table.setRowCount(0)
406+
self.table.setColumnCount(0)
407+
return
408+
409+
col_count = max(len(r) for r in rows)
410+
self.table.setRowCount(len(rows))
411+
self.table.setColumnCount(col_count)
412+
413+
# Erste Zeile als Spaltenköpfe
414+
header_row = rows[0]
415+
self.table.setHorizontalHeaderLabels(
416+
[str(v) if v is not None else "" for v in header_row]
417+
)
418+
419+
for r_idx, row in enumerate(rows):
420+
for c_idx, val in enumerate(row):
421+
item = QTableWidgetItem(str(val) if val is not None else "")
422+
self.table.setItem(r_idx, c_idx, item)
423+
424+
def _show_fallback(self, reason: str):
425+
self.table.setRowCount(0)
426+
self.table.setColumnCount(0)
427+
self.status_label.setText(
428+
f"Vorschau nicht verfügbar: {reason}\n→ Datei extern öffnen"
429+
)
430+
self.status_label.setVisible(True)
431+
self.open_extern_btn.setVisible(True)
432+
433+
def _open_extern(self):
434+
"""Öffnet die Datei mit der systemseitig zugeordneten Anwendung."""
435+
if not self._path:
436+
return
437+
import subprocess
438+
import sys as _sys
439+
440+
if _sys.platform == "win32":
441+
os.startfile(self._path) # type: ignore[attr-defined]
442+
elif _sys.platform == "darwin":
443+
subprocess.Popen(["open", self._path])
444+
else:
445+
subprocess.Popen(["xdg-open", self._path])
446+
447+
319448
class PreviewPanel(QWidget):
320449
"""
321450
Haupt-Vorschau-Panel mit:
@@ -362,6 +491,10 @@ def _setup_ui(self):
362491
self.unsupported_label = QLabel("Vorschau nicht verfügbar\nfür diesen Dateityp")
363492
self.unsupported_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
364493
self.preview_stack.addWidget(self.unsupported_label)
494+
495+
# Excel-Vorschau (.xlsx / .xls) — Index 6
496+
self.excel_preview = ExcelPreview()
497+
self.preview_stack.addWidget(self.excel_preview)
365498

366499
layout.addWidget(self.preview_stack, 2)
367500

@@ -433,7 +566,12 @@ def _show_preview_for_path(self, path: str, heading: str | None = None):
433566
elif ext == '.pdf':
434567
self.pdf_preview.load_pdf(path)
435568
self.preview_stack.setCurrentIndex(3)
436-
569+
570+
# Excel-Vorschau
571+
elif ext in ['.xlsx', '.xls']:
572+
self.excel_preview.load_file(path)
573+
self.preview_stack.setCurrentIndex(6)
574+
437575
# Nicht unterstützt
438576
else:
439577
self.preview_stack.setCurrentIndex(5)

0 commit comments

Comments
 (0)