From a9761ec33bc4a4f644e64d3708c916470ced8d10 Mon Sep 17 00:00:00 2001 From: data Date: Mon, 2 Feb 2026 14:44:54 +0100 Subject: [PATCH] PDF , MD View und Edit eingebaut --- filebrowser.desktop | 2 +- src/dialogs/__init__.py | 7 + src/{dialogs.py => dialogs/base.py} | 114 ++++++- src/main_window.py | 76 ++++- src/preview_window.py | 16 +- src/utils/file_utils.py | 5 +- src/utils/pdf_tools.py | 385 +++++++++++++++++++++++ src/utils/themes.py | 40 ++- src/widgets/file_list.py | 58 +++- src/widgets/preview_panel.py | 455 +++++++++++++++++++++++++++- 10 files changed, 1127 insertions(+), 31 deletions(-) create mode 100644 src/dialogs/__init__.py rename src/{dialogs.py => dialogs/base.py} (64%) create mode 100644 src/utils/pdf_tools.py diff --git a/filebrowser.desktop b/filebrowser.desktop index d2c023b..9e81f66 100644 --- a/filebrowser.desktop +++ b/filebrowser.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=FileBrowser Comment=Dateimanager mit Vorschau-Funktion -Exec=python3 /mnt/17 - Entwicklungen/20 - Projekte/FileBrowser/main.py +Exec=python3 "/mnt/17 - Entwicklungen/20 - Projekte/FileBrowser/main.py" Icon=/mnt/17 - Entwicklungen/20 - Projekte/FileBrowser/resources/icon.png Terminal=false Type=Application diff --git a/src/dialogs/__init__.py b/src/dialogs/__init__.py new file mode 100644 index 0000000..89fb131 --- /dev/null +++ b/src/dialogs/__init__.py @@ -0,0 +1,7 @@ +"""Dialoge für den FileBrowser.""" + +from .base import RenameDialog, MoveDialog, DeleteDialog, SettingsDialog + +__all__ = [ + 'RenameDialog', 'MoveDialog', 'DeleteDialog', 'SettingsDialog' +] diff --git a/src/dialogs.py b/src/dialogs/base.py similarity index 64% rename from src/dialogs.py rename to src/dialogs/base.py index 13decf9..f8b6d3d 100644 --- a/src/dialogs.py +++ b/src/dialogs/base.py @@ -5,9 +5,9 @@ from pathlib import Path from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTreeView, QDialogButtonBox, QMessageBox, - QFrame + QFrame, QCheckBox, QGroupBox, QTabWidget, QWidget ) -from PyQt6.QtCore import Qt, QDir +from PyQt6.QtCore import Qt, QDir, QSettings from PyQt6.QtGui import QFileSystemModel @@ -228,3 +228,113 @@ class DeleteDialog(QDialog): button_box.addButton(cancel_btn, QDialogButtonBox.ButtonRole.RejectRole) layout.addWidget(button_box) + + +class SettingsDialog(QDialog): + """Einstellungsdialog.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.settings = QSettings('FileBrowser', 'FileBrowser') + + self.setWindowTitle("Einstellungen") + self.setModal(True) + self.setMinimumSize(450, 350) + + self._setup_ui() + self._load_settings() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Tab-Widget für verschiedene Kategorien + tabs = QTabWidget() + layout.addWidget(tabs) + + # Ansicht-Tab + view_tab = QWidget() + view_layout = QVBoxLayout(view_tab) + + # Preview-Gruppe + preview_group = QGroupBox("Vorschau-Panel") + preview_layout = QVBoxLayout(preview_group) + + self.disable_preview_check = QCheckBox( + "Vorschau-Panel deaktivieren (immer externes Fenster verwenden)" + ) + self.disable_preview_check.setToolTip( + "Wenn aktiviert, wird das Vorschau-Panel im Hauptfenster ausgeblendet " + "und Dateien werden automatisch im externen Vorschau-Fenster geöffnet." + ) + preview_layout.addWidget(self.disable_preview_check) + + view_layout.addWidget(preview_group) + + # Pfad-Gruppe + path_group = QGroupBox("Pfadanzeige") + path_layout = QVBoxLayout(path_group) + + self.path_text_default_check = QCheckBox( + "Pfad-Textfeld standardmäßig anzeigen (statt Breadcrumb)" + ) + self.path_text_default_check.setToolTip( + "Wenn aktiviert, wird der Pfad standardmäßig als editierbares Textfeld " + "angezeigt statt als Breadcrumb-Navigation." + ) + path_layout.addWidget(self.path_text_default_check) + + view_layout.addWidget(path_group) + view_layout.addStretch() + + tabs.addTab(view_tab, "Ansicht") + + # PDF-Tab + pdf_tab = QWidget() + pdf_layout = QVBoxLayout(pdf_tab) + + pdf_info = QLabel( + "PDF-Einstellungen können direkt im Vorschau-Panel oder über " + "das Menü Ansicht → PDF-Vorschau geändert werden." + ) + pdf_info.setWordWrap(True) + pdf_layout.addWidget(pdf_info) + pdf_layout.addStretch() + + tabs.addTab(pdf_tab, "PDF") + + # Buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel | + QDialogButtonBox.StandardButton.Apply + ) + button_box.accepted.connect(self._save_and_accept) + button_box.rejected.connect(self.reject) + button_box.button(QDialogButtonBox.StandardButton.Apply).clicked.connect(self._apply_settings) + layout.addWidget(button_box) + + def _load_settings(self): + """Lädt die aktuellen Einstellungen.""" + self.disable_preview_check.setChecked( + self.settings.value('disable_preview_panel', False, type=bool) + ) + self.path_text_default_check.setChecked( + self.settings.value('breadcrumb_text_mode', False, type=bool) + ) + + def _apply_settings(self): + """Wendet die Einstellungen an ohne zu schließen.""" + self.settings.setValue('disable_preview_panel', self.disable_preview_check.isChecked()) + self.settings.setValue('breadcrumb_text_mode', self.path_text_default_check.isChecked()) + + def _save_and_accept(self): + """Speichert und schließt den Dialog.""" + self._apply_settings() + self.accept() + + def get_settings(self) -> dict: + """Gibt die Einstellungen als Dictionary zurück.""" + return { + 'disable_preview_panel': self.disable_preview_check.isChecked(), + 'path_text_default': self.path_text_default_check.isChecked(), + } diff --git a/src/main_window.py b/src/main_window.py index d319923..70031fb 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -15,7 +15,7 @@ from .widgets.folder_tree import FolderTreeWidget from .widgets.file_list import FileListWidget from .widgets.preview_panel import PreviewPanel from .widgets.breadcrumb import BreadcrumbWidget -from .dialogs import RenameDialog, MoveDialog, DeleteDialog +from .dialogs import RenameDialog, MoveDialog, DeleteDialog, SettingsDialog from .preview_window import PreviewWindow from .utils.themes import ThemeManager @@ -74,11 +74,11 @@ class MainWindow(QMainWindow): # Panel 3: Vorschau self.preview_panel = PreviewPanel() - self.preview_panel.setMinimumWidth(200) + self.preview_panel.setMinimumWidth(420) self.main_splitter.addWidget(self.preview_panel) # Standardgrößen - self.main_splitter.setSizes([250, 500, 350]) + self.main_splitter.setSizes([200, 450, 450]) self.main_splitter.setStretchFactor(0, 0) self.main_splitter.setStretchFactor(1, 1) self.main_splitter.setStretchFactor(2, 0) @@ -100,6 +100,13 @@ class MainWindow(QMainWindow): file_menu.addSeparator() + settings_action = QAction("Einstellungen...", self) + settings_action.setShortcut(QKeySequence("Ctrl+,")) + settings_action.triggered.connect(self._show_settings) + file_menu.addAction(settings_action) + + file_menu.addSeparator() + quit_action = QAction("Beenden", self) quit_action.setShortcut(QKeySequence.StandardKey.Quit) quit_action.triggered.connect(self.close) @@ -288,6 +295,7 @@ class MainWindow(QMainWindow): self.preview_panel.delete_requested.connect(self._delete_file) self.preview_panel.open_external_requested.connect(self._open_external) self.preview_panel.detach_requested.connect(self._detach_preview) + self.preview_panel.content_changed.connect(self._on_preview_content_changed) def _load_settings(self): """Lädt gespeicherte Einstellungen.""" @@ -316,6 +324,9 @@ class MainWindow(QMainWindow): if index >= 0: self.theme_combo.setCurrentIndex(index) + # Preview-Panel beim Start verstecken (hat noch keinen Inhalt) + self.preview_panel.setVisible(False) + def _save_settings(self): """Speichert Einstellungen.""" self.settings.setValue('window_geometry', self.saveGeometry()) @@ -325,10 +336,18 @@ class MainWindow(QMainWindow): def _apply_theme(self): """Wendet das aktuelle Theme an.""" - theme = self.theme_manager.get_current_theme() - stylesheet = self.theme_manager.apply_theme(theme) + theme_name = self.theme_manager.get_current_theme() + stylesheet = self.theme_manager.apply_theme(theme_name) self.setStyleSheet(stylesheet) + # Hover-Farbe für Dateiliste setzen + theme = self.theme_manager.THEMES.get(theme_name, self.theme_manager.THEMES['dark']) + if theme.get('is_system'): + # System-Theme: Standard-Hover-Farbe + self.file_list.set_hover_color('#e0e0e0') + else: + self.file_list.set_hover_color(theme['alternate_base']) + # Theme-Menü-Aktionen aktualisieren for action in self.theme_actions: action.setChecked(action.data() == theme) @@ -384,9 +403,16 @@ class MainWindow(QMainWindow): def _on_file_selected(self, path: str, name: str): """Behandelt Dateiauswahl.""" - self.preview_panel.load_file(path, name) + # Prüfen ob Preview-Panel deaktiviert ist + if self.settings.value('disable_preview_panel', False, type=bool): + # Automatisch externes Fenster öffnen + self._detach_preview() + if self.preview_window: + self.preview_window.load_file(path, name) + else: + self.preview_panel.load_file(path, name) - # Preview-Fenster aktualisieren + # Preview-Fenster aktualisieren (falls offen) if self.preview_window and self.preview_window.isVisible(): self.preview_window.load_file(path, name) @@ -560,6 +586,14 @@ class MainWindow(QMainWindow): """Behandelt das Schließen des Preview-Fensters.""" pass # Preview-Fenster bleibt im Speicher für schnelles Wiedereröffnen + def _on_preview_content_changed(self, has_content: bool): + """Zeigt oder versteckt das Preview-Panel je nach Inhalt.""" + # Wenn Preview-Panel deaktiviert ist, immer verstecken + if self.settings.value('disable_preview_panel', False, type=bool): + self.preview_panel.setVisible(False) + else: + self.preview_panel.setVisible(has_content) + def _set_pdf_zoom_mode(self, mode_id: str): """Setzt den PDF-Zoom-Modus.""" settings = self.preview_panel.pdf_preview.get_settings() @@ -582,6 +616,34 @@ class MainWindow(QMainWindow): for action in self.pdf_page_actions: action.setChecked(action.data() == settings['page_mode']) + def _show_settings(self): + """Zeigt den Einstellungsdialog.""" + dialog = SettingsDialog(self) + if dialog.exec(): + # Einstellungen wurden geändert - UI aktualisieren + self._apply_settings() + + def _apply_settings(self): + """Wendet die aktuellen Einstellungen an.""" + # Preview-Panel aktualisieren + disable_preview = self.settings.value('disable_preview_panel', False, type=bool) + if disable_preview: + self.preview_panel.setVisible(False) + else: + # Nur anzeigen wenn Inhalt vorhanden + path, name = self.preview_panel.get_current_file() + self.preview_panel.setVisible(bool(path)) + + # Breadcrumb aktualisieren + text_mode = self.settings.value('breadcrumb_text_mode', False, type=bool) + self.breadcrumb.toggle_btn.setChecked(text_mode) + if text_mode: + self.breadcrumb.stack.setCurrentIndex(1) + self.breadcrumb.toggle_btn.setText("🗂️") + else: + self.breadcrumb.stack.setCurrentIndex(0) + self.breadcrumb.toggle_btn.setText("📝") + def _show_about(self): """Zeigt den Über-Dialog.""" QMessageBox.about( diff --git a/src/preview_window.py b/src/preview_window.py index 88ba71f..3bfe453 100644 --- a/src/preview_window.py +++ b/src/preview_window.py @@ -8,7 +8,7 @@ from PyQt6.QtWidgets import ( from PyQt6.QtCore import Qt, pyqtSignal, QSettings from PyQt6.QtGui import QFont -from .widgets.preview_panel import ImagePreview, TextPreview, PdfPreview, NoPreview +from .widgets.preview_panel import ImagePreview, TextPreview, PdfPreview, MarkdownPreview, NoPreview from .utils.file_utils import format_file_size, get_file_type @@ -23,7 +23,7 @@ class PreviewWindow(QMainWindow): self._current_name = "" self.setWindowTitle("Vorschau") - self.setMinimumSize(400, 300) + self.setMinimumSize(550, 400) self._setup_ui() self._load_settings() @@ -69,6 +69,7 @@ class PreviewWindow(QMainWindow): self.image_preview = None self.text_preview = None self.pdf_preview = None + self.markdown_preview = None self.no_preview = None self._current_preview = None @@ -78,8 +79,11 @@ class PreviewWindow(QMainWindow): geometry = settings.value('preview_window_geometry') if geometry: self.restoreGeometry(geometry) + # Mindestgröße sicherstellen + if self.width() < 550 or self.height() < 400: + self.resize(700, 550) else: - self.resize(600, 500) + self.resize(700, 550) def _save_settings(self): """Speichert Fenstereinstellungen.""" @@ -134,6 +138,12 @@ class PreviewWindow(QMainWindow): self.pdf_preview.load_pdf(path) self._current_preview = self.pdf_preview + elif file_type == 'markdown': + if not self.markdown_preview: + self.markdown_preview = MarkdownPreview() + self.markdown_preview.load_markdown(path) + self._current_preview = self.markdown_preview + else: if not self.no_preview: self.no_preview = NoPreview() diff --git a/src/utils/file_utils.py b/src/utils/file_utils.py index 63dc8c4..3439754 100644 --- a/src/utils/file_utils.py +++ b/src/utils/file_utils.py @@ -75,13 +75,16 @@ def get_file_type(filename: str) -> str: ext = Path(filename).suffix.lower() image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.svg'} - text_exts = {'.txt', '.md', '.log', '.py', '.js', '.ts', '.html', '.css', + markdown_exts = {'.md', '.markdown'} + text_exts = {'.txt', '.log', '.py', '.js', '.ts', '.html', '.css', '.json', '.xml', '.yaml', '.yml', '.sh', '.bash', '.csv', '.ini', '.conf', '.cfg'} pdf_exts = {'.pdf'} if ext in image_exts: return 'image' + elif ext in markdown_exts: + return 'markdown' elif ext in text_exts: return 'text' elif ext in pdf_exts: diff --git a/src/utils/pdf_tools.py b/src/utils/pdf_tools.py new file mode 100644 index 0000000..0f41fd0 --- /dev/null +++ b/src/utils/pdf_tools.py @@ -0,0 +1,385 @@ +"""PDF-Bearbeitungsfunktionen mit qpdf und ghostscript.""" + +import os +import subprocess +import tempfile +import shutil +from pathlib import Path +from typing import Optional, List, Tuple + + +def check_tools() -> dict: + """Prüft welche PDF-Tools verfügbar sind.""" + tools = { + 'qpdf': shutil.which('qpdf') is not None, + 'gs': shutil.which('gs') is not None, + 'libreoffice': shutil.which('libreoffice') is not None, + } + return tools + + +def get_page_count(pdf_path: str) -> int: + """Gibt die Seitenanzahl einer PDF zurück.""" + try: + result = subprocess.run( + ['qpdf', '--show-npages', pdf_path], + capture_output=True, text=True, timeout=30 + ) + if result.returncode == 0: + return int(result.stdout.strip()) + except Exception: + pass + return 0 + + +def rotate_pages(pdf_path: str, output_path: str, rotation: int, + pages: Optional[str] = None) -> Tuple[bool, str]: + """ + Dreht Seiten in einer PDF. + + Args: + pdf_path: Eingabe-PDF + output_path: Ausgabe-PDF + rotation: Drehung in Grad (90, 180, 270) + pages: Seitenbereiche z.B. "1-3,5,7-9" oder None für alle + + Returns: + (success, message) + """ + if rotation not in [90, 180, 270]: + return False, "Ungültige Drehung. Erlaubt: 90, 180, 270" + + try: + page_spec = pages if pages else "1-z" + # qpdf rotation syntax: +90, +180, +270 oder -90, -180, -270 + cmd = [ + 'qpdf', pdf_path, + f'--rotate=+{rotation}:{page_spec}', + output_path + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode == 0 or (result.returncode == 3 and os.path.exists(output_path)): + return True, f"Seiten erfolgreich um {rotation}° gedreht" + else: + return False, f"Fehler: {result.stderr}" + except subprocess.TimeoutExpired: + return False, "Timeout beim Drehen der Seiten" + except Exception as e: + return False, f"Fehler: {str(e)}" + + +def delete_pages(pdf_path: str, output_path: str, pages_to_delete: str) -> Tuple[bool, str]: + """ + Löscht Seiten aus einer PDF. + + Args: + pdf_path: Eingabe-PDF + output_path: Ausgabe-PDF + pages_to_delete: Seiten zum Löschen z.B. "1,3,5-7" + + Returns: + (success, message) + """ + try: + total_pages = get_page_count(pdf_path) + if total_pages == 0: + return False, "Konnte Seitenanzahl nicht ermitteln" + + # Parse die zu löschenden Seiten + delete_set = set() + for part in pages_to_delete.split(','): + part = part.strip() + if '-' in part: + start, end = part.split('-', 1) + delete_set.update(range(int(start), int(end) + 1)) + else: + delete_set.add(int(part)) + + # Erstelle Liste der zu behaltenden Seiten + keep_pages = [i for i in range(1, total_pages + 1) if i not in delete_set] + + if not keep_pages: + return False, "Kann nicht alle Seiten löschen" + + # qpdf mit --pages Option + page_range = ','.join(str(p) for p in keep_pages) + cmd = [ + 'qpdf', pdf_path, + '--pages', pdf_path, page_range, '--', + output_path + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode == 0 or (result.returncode == 3 and os.path.exists(output_path)): + deleted_count = len(delete_set) + return True, f"{deleted_count} Seite(n) gelöscht" + else: + return False, f"Fehler: {result.stderr}" + except subprocess.TimeoutExpired: + return False, "Timeout beim Löschen der Seiten" + except Exception as e: + return False, f"Fehler: {str(e)}" + + +def extract_pages(pdf_path: str, output_path: str, pages: str) -> Tuple[bool, str]: + """ + Extrahiert Seiten aus einer PDF. + + Args: + pdf_path: Eingabe-PDF + output_path: Ausgabe-PDF + pages: Seitenbereiche z.B. "1-3,5,7-9" + + Returns: + (success, message) + """ + try: + cmd = [ + 'qpdf', pdf_path, + '--pages', pdf_path, pages, '--', + output_path + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode == 0 or (result.returncode == 3 and os.path.exists(output_path)): + return True, f"Seiten {pages} extrahiert" + else: + return False, f"Fehler: {result.stderr}" + except subprocess.TimeoutExpired: + return False, "Timeout beim Extrahieren" + except Exception as e: + return False, f"Fehler: {str(e)}" + + +def merge_pdfs(pdf_paths: List[str], output_path: str) -> Tuple[bool, str]: + """ + Führt mehrere PDFs zusammen. + + Args: + pdf_paths: Liste der PDF-Pfade + output_path: Ausgabe-PDF + + Returns: + (success, message) + """ + if len(pdf_paths) < 2: + return False, "Mindestens 2 PDFs benötigt" + + try: + # qpdf merge syntax + cmd = ['qpdf', '--empty', '--pages'] + for pdf in pdf_paths: + cmd.extend([pdf, '1-z']) + cmd.extend(['--', output_path]) + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + + if result.returncode == 0 or (result.returncode == 3 and os.path.exists(output_path)): + return True, f"{len(pdf_paths)} PDFs zusammengeführt" + else: + return False, f"Fehler: {result.stderr}" + except subprocess.TimeoutExpired: + return False, "Timeout beim Zusammenführen" + except Exception as e: + return False, f"Fehler: {str(e)}" + + +def split_pdf(pdf_path: str, output_dir: str, pages_per_file: int = 1) -> Tuple[bool, str]: + """ + Teilt eine PDF in mehrere Dateien. + + Args: + pdf_path: Eingabe-PDF + output_dir: Ausgabe-Verzeichnis + pages_per_file: Seiten pro Datei + + Returns: + (success, message) + """ + try: + total_pages = get_page_count(pdf_path) + if total_pages == 0: + return False, "Konnte Seitenanzahl nicht ermitteln" + + base_name = Path(pdf_path).stem + created_files = 0 + + for start in range(1, total_pages + 1, pages_per_file): + end = min(start + pages_per_file - 1, total_pages) + output_path = os.path.join(output_dir, f"{base_name}_seiten_{start}-{end}.pdf") + + cmd = [ + 'qpdf', pdf_path, + '--pages', pdf_path, f'{start}-{end}', '--', + output_path + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode == 0 or (result.returncode == 3 and os.path.exists(output_path)): + created_files += 1 + else: + return False, f"Fehler bei Seiten {start}-{end}: {result.stderr}" + + return True, f"PDF in {created_files} Dateien aufgeteilt" + except subprocess.TimeoutExpired: + return False, "Timeout beim Aufteilen" + except Exception as e: + return False, f"Fehler: {str(e)}" + + +def compress_pdf(pdf_path: str, output_path: str) -> Tuple[bool, str]: + """ + Komprimiert eine PDF mit Ghostscript. + + Args: + pdf_path: Eingabe-PDF + output_path: Ausgabe-PDF + + Returns: + (success, message) + """ + try: + original_size = os.path.getsize(pdf_path) + + cmd = [ + 'gs', '-sDEVICE=pdfwrite', + '-dCompatibilityLevel=1.4', + '-dPDFSETTINGS=/ebook', # Gute Qualität, kleinere Größe + '-dNOPAUSE', '-dQUIET', '-dBATCH', + f'-sOutputFile={output_path}', + pdf_path + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + + if result.returncode == 0 and os.path.exists(output_path): + new_size = os.path.getsize(output_path) + reduction = ((original_size - new_size) / original_size) * 100 + return True, f"Komprimiert: {reduction:.1f}% kleiner" + else: + return False, f"Fehler: {result.stderr}" + except subprocess.TimeoutExpired: + return False, "Timeout beim Komprimieren" + except Exception as e: + return False, f"Fehler: {str(e)}" + + +def add_password(pdf_path: str, output_path: str, + user_password: str, owner_password: Optional[str] = None) -> Tuple[bool, str]: + """ + Fügt Passwortschutz zu einer PDF hinzu. + + Args: + pdf_path: Eingabe-PDF + output_path: Ausgabe-PDF + user_password: Passwort zum Öffnen + owner_password: Passwort für Bearbeitungsrechte (optional) + + Returns: + (success, message) + """ + try: + owner_pw = owner_password or user_password + cmd = [ + 'qpdf', '--encrypt', user_password, owner_pw, '256', '--', + pdf_path, output_path + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode == 0 or (result.returncode == 3 and os.path.exists(output_path)): + return True, "Passwortschutz hinzugefügt" + else: + return False, f"Fehler: {result.stderr}" + except subprocess.TimeoutExpired: + return False, "Timeout beim Verschlüsseln" + except Exception as e: + return False, f"Fehler: {str(e)}" + + +def remove_password(pdf_path: str, output_path: str, password: str) -> Tuple[bool, str]: + """ + Entfernt Passwortschutz von einer PDF. + + Args: + pdf_path: Eingabe-PDF (verschlüsselt) + output_path: Ausgabe-PDF (unverschlüsselt) + password: Passwort zum Entschlüsseln + + Returns: + (success, message) + """ + try: + cmd = [ + 'qpdf', '--password=' + password, '--decrypt', + pdf_path, output_path + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode == 0 or (result.returncode == 3 and os.path.exists(output_path)): + return True, "Passwortschutz entfernt" + else: + return False, f"Fehler: {result.stderr}" + except subprocess.TimeoutExpired: + return False, "Timeout beim Entschlüsseln" + except Exception as e: + return False, f"Fehler: {str(e)}" + + +def sign_pdf_libreoffice(pdf_path: str, output_path: str) -> Tuple[bool, str]: + """ + Öffnet PDF in LibreOffice Draw zum Signieren. + + Args: + pdf_path: PDF zum Signieren + output_path: Wird ignoriert - LibreOffice speichert selbst + + Returns: + (success, message) + """ + try: + # LibreOffice Draw öffnen - Benutzer kann dann signieren + cmd = ['libreoffice', '--draw', pdf_path] + subprocess.Popen(cmd) + return True, "PDF in LibreOffice Draw geöffnet.\nNutze Einfügen → Signaturzeile zum Signieren." + except Exception as e: + return False, f"Fehler: {str(e)}" + + +def pdf_to_images(pdf_path: str, output_dir: str, dpi: int = 150, + format: str = 'png') -> Tuple[bool, str]: + """ + Konvertiert PDF-Seiten in Bilder. + + Args: + pdf_path: Eingabe-PDF + output_dir: Ausgabe-Verzeichnis + dpi: Auflösung + format: Bildformat (png, jpg) + + Returns: + (success, message) + """ + try: + base_name = Path(pdf_path).stem + output_pattern = os.path.join(output_dir, f"{base_name}_seite_%d.{format}") + + device = 'png16m' if format == 'png' else 'jpeg' + cmd = [ + 'gs', f'-sDEVICE={device}', + f'-r{dpi}', + '-dNOPAUSE', '-dQUIET', '-dBATCH', + f'-sOutputFile={output_pattern}', + pdf_path + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + + if result.returncode == 0: + # Zähle erstellte Dateien + files = list(Path(output_dir).glob(f"{base_name}_seite_*.{format}")) + return True, f"{len(files)} Bilder erstellt" + else: + return False, f"Fehler: {result.stderr}" + except subprocess.TimeoutExpired: + return False, "Timeout bei der Konvertierung" + except Exception as e: + return False, f"Fehler: {str(e)}" diff --git a/src/utils/themes.py b/src/utils/themes.py index a03c9e0..dd1f74f 100644 --- a/src/utils/themes.py +++ b/src/utils/themes.py @@ -9,6 +9,10 @@ class ThemeManager: """Verwaltet die Themes der Anwendung.""" THEMES = { + 'system': { + 'name': 'System', + 'is_system': True, # Marker für System-Theme + }, 'dark': { 'name': 'Dark', 'window': '#0f172a', @@ -72,6 +76,11 @@ class ThemeManager: theme = self.THEMES[theme_name] app = QApplication.instance() + # System-Theme: Standardpalette und leeres Stylesheet verwenden + if theme.get('is_system'): + app.setPalette(app.style().standardPalette()) + return "" # Leeres Stylesheet = System-Styling + palette = QPalette() palette.setColor(QPalette.ColorRole.Window, QColor(theme['window'])) palette.setColor(QPalette.ColorRole.WindowText, QColor(theme['window_text'])) @@ -103,6 +112,10 @@ class ThemeManager: theme = self.THEMES.get(theme_name, self.THEMES['dark']) + # System-Theme: kein Stylesheet + if theme.get('is_system'): + return "" + return f""" QMainWindow, QWidget {{ background-color: {theme['window']}; @@ -127,10 +140,15 @@ class ThemeManager: color: {theme['highlight_text']}; }} - QTreeView::item:hover, QListView::item:hover, QTableView::item:hover {{ + QTreeView::item:hover, QListView::item:hover {{ background-color: {theme['alternate_base']}; }} + QTreeView::item:selected:hover, QListView::item:selected:hover {{ + background-color: {theme['highlight']}; + color: {theme['highlight_text']}; + }} + QHeaderView::section {{ background-color: {theme['button']}; color: {theme['button_text']}; @@ -266,6 +284,8 @@ class ThemeManager: border: 1px solid {theme['border']}; border-radius: 4px; padding: 4px 8px; + padding-right: 25px; + min-height: 20px; }} QComboBox:hover {{ @@ -273,14 +293,30 @@ class ThemeManager: }} QComboBox::drop-down {{ - border: none; + subcontrol-origin: padding; + subcontrol-position: center right; width: 20px; + border: none; + background: transparent; + }} + + QComboBox::down-arrow {{ + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid {theme['button_text']}; + }} + + QComboBox::down-arrow:hover {{ + border-top-color: {theme['highlight']}; }} QComboBox QAbstractItemView {{ background-color: {theme['base']}; color: {theme['text']}; selection-background-color: {theme['highlight']}; + border: 1px solid {theme['border']}; }} QTabWidget::pane {{ diff --git a/src/widgets/file_list.py b/src/widgets/file_list.py index 029883a..132f162 100644 --- a/src/widgets/file_list.py +++ b/src/widgets/file_list.py @@ -4,18 +4,19 @@ import os from pathlib import Path from datetime import datetime from PyQt6.QtWidgets import ( - QTableView, QAbstractItemView, QMenu, QHeaderView, - QStyledItemDelegate, QStyle + QTableView, QAbstractItemView, QMenu, QHeaderView ) from PyQt6.QtCore import ( Qt, QModelIndex, pyqtSignal, QAbstractTableModel, QMimeData, - QUrl, QVariant, QSize + QUrl ) -from PyQt6.QtGui import QAction, QDrag, QIcon +from PyQt6.QtGui import QAction, QDrag, QColor, QBrush from ..utils.file_utils import get_file_icon, format_file_size, natural_sort_key + + class FileItem: """Repräsentiert einen Dateieintrag.""" @@ -32,12 +33,14 @@ class FileItem: class FileListModel(QAbstractTableModel): """Model für die Dateiliste.""" - HEADERS = ['', 'Name', 'Größe', 'Geändert'] + HEADERS = ['📄', 'Name', 'Größe', 'Geändert'] def __init__(self, parent=None): super().__init__(parent) self.items: list[FileItem] = [] self.current_path = "" + self._hovered_row = -1 + self._hover_color = QColor("#334155") # Default, wird vom Theme überschrieben def rowCount(self, parent=None): return len(self.items) @@ -70,9 +73,33 @@ class FileListModel(QAbstractTableModel): return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter elif role == Qt.ItemDataRole.ToolTipRole: return item.path + elif role == Qt.ItemDataRole.BackgroundRole: + if index.row() == self._hovered_row: + return QBrush(self._hover_color) return None + def set_hovered_row(self, row: int): + """Setzt die aktuell gehoverte Zeile.""" + if self._hovered_row != row: + old_row = self._hovered_row + self._hovered_row = row + # Alte und neue Zeile aktualisieren + if old_row >= 0 and old_row < len(self.items): + self.dataChanged.emit( + self.index(old_row, 0), + self.index(old_row, self.columnCount() - 1) + ) + if row >= 0 and row < len(self.items): + self.dataChanged.emit( + self.index(row, 0), + self.index(row, self.columnCount() - 1) + ) + + def set_hover_color(self, color: QColor): + """Setzt die Hover-Farbe.""" + self._hover_color = color + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: return self.HEADERS[section] @@ -154,9 +181,10 @@ class FileListWidget(QTableView): self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setShowGrid(False) - self.setAlternatingRowColors(True) + self.setAlternatingRowColors(False) self.verticalHeader().setVisible(False) self.setWordWrap(False) + self.setMouseTracking(True) # Spaltenbreiten header = self.horizontalHeader() @@ -164,7 +192,7 @@ class FileListWidget(QTableView): header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) - self.setColumnWidth(0, 30) + self.setColumnWidth(0, 36) self.setColumnWidth(2, 80) self.setColumnWidth(3, 130) @@ -361,3 +389,19 @@ class FileListWidget(QTableView): self.folder_entered.emit(parent) else: super().keyPressEvent(event) + + def mouseMoveEvent(self, event): + """Verfolgt die Zeile unter dem Mauszeiger für Hover-Effekt.""" + index = self.indexAt(event.pos()) + row = index.row() if index.isValid() else -1 + self.list_model.set_hovered_row(row) + super().mouseMoveEvent(event) + + def leaveEvent(self, event): + """Entfernt den Hover-Effekt wenn die Maus das Widget verlässt.""" + self.list_model.set_hovered_row(-1) + super().leaveEvent(event) + + def set_hover_color(self, color: str): + """Setzt die Hover-Farbe (für Theme-Unterstützung).""" + self.list_model.set_hover_color(QColor(color)) diff --git a/src/widgets/preview_panel.py b/src/widgets/preview_panel.py index e79afc2..c4d8afe 100644 --- a/src/widgets/preview_panel.py +++ b/src/widgets/preview_panel.py @@ -6,14 +6,20 @@ from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QPlainTextEdit, QStackedWidget, QSizePolicy, - QFrame, QApplication, QComboBox, QSpinBox + QFrame, QApplication, QComboBox, QSpinBox, QMenu, QInputDialog, + QFileDialog, QMessageBox, QTextEdit ) +import markdown from PyQt6.QtCore import Qt, pyqtSignal, QUrl, QSize, QTimer, QSettings +from PyQt6.QtGui import QAction +import tempfile +import shutil from PyQt6.QtGui import QPixmap, QImage, QFont, QPainter from PyQt6.QtPdf import QPdfDocument from PyQt6.QtPdfWidgets import QPdfView from ..utils.file_utils import get_file_icon, format_file_size, get_file_type +from ..utils import pdf_tools class ImagePreview(QScrollArea): @@ -103,8 +109,338 @@ class TextPreview(QPlainTextEdit): return False +class MarkdownPreview(QWidget): + """Markdown-Vorschau mit WYSIWYG-Ansicht und Bearbeitungsmodus.""" + + MAX_SIZE = 1024 * 1024 # 1 MB + + # CSS für Markdown-Rendering + MARKDOWN_CSS = """ + body { font-family: sans-serif; padding: 10px; line-height: 1.6; } + h1, h2, h3 { color: #2563eb; margin-top: 1em; } + code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-family: monospace; } + pre { background: #1e293b; color: #f1f5f9; padding: 12px; border-radius: 6px; overflow-x: auto; } + pre code { background: transparent; padding: 0; } + blockquote { border-left: 4px solid #3b82f6; margin: 0; padding-left: 16px; color: #64748b; } + table { border-collapse: collapse; width: 100%; } + th, td { border: 1px solid #cbd5e1; padding: 8px; text-align: left; } + th { background: #f1f5f9; } + a { color: #3b82f6; } + ul, ol { padding-left: 24px; } + hr { border: none; border-top: 1px solid #e2e8f0; margin: 20px 0; } + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._current_path = "" + self._edit_mode = False + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + # Haupt-Toolbar + toolbar = QWidget() + toolbar.setFixedHeight(32) + toolbar_layout = QHBoxLayout(toolbar) + toolbar_layout.setContentsMargins(4, 0, 4, 0) + toolbar_layout.setSpacing(8) + + toolbar_layout.addWidget(QLabel("📝 Markdown")) + toolbar_layout.addStretch() + + # Bearbeiten/Speichern Button + self.edit_btn = QPushButton("✏️ Bearbeiten") + self.edit_btn.setFixedWidth(100) + self.edit_btn.clicked.connect(self._toggle_edit_mode) + toolbar_layout.addWidget(self.edit_btn) + + layout.addWidget(toolbar) + + # Formatierungs-Toolbar (nur im Edit-Modus sichtbar) + self.format_toolbar = QWidget() + self.format_toolbar.setFixedHeight(32) + self.format_toolbar.setVisible(False) + fmt_layout = QHBoxLayout(self.format_toolbar) + fmt_layout.setContentsMargins(4, 0, 4, 0) + fmt_layout.setSpacing(2) + + # Überschriften + h1_btn = QPushButton("H1") + h1_btn.setFixedSize(28, 26) + h1_btn.setToolTip("Überschrift 1") + h1_btn.clicked.connect(lambda: self._insert_format("# ", "")) + fmt_layout.addWidget(h1_btn) + + h2_btn = QPushButton("H2") + h2_btn.setFixedSize(28, 26) + h2_btn.setToolTip("Überschrift 2") + h2_btn.clicked.connect(lambda: self._insert_format("## ", "")) + fmt_layout.addWidget(h2_btn) + + h3_btn = QPushButton("H3") + h3_btn.setFixedSize(28, 26) + h3_btn.setToolTip("Überschrift 3") + h3_btn.clicked.connect(lambda: self._insert_format("### ", "")) + fmt_layout.addWidget(h3_btn) + + fmt_layout.addSpacing(8) + + # Text-Formatierung + bold_btn = QPushButton("B") + bold_btn.setFixedSize(28, 26) + bold_btn.setToolTip("Fett (Ctrl+B)") + bold_btn.setStyleSheet("font-weight: bold;") + bold_btn.clicked.connect(lambda: self._wrap_selection("**", "**")) + fmt_layout.addWidget(bold_btn) + + italic_btn = QPushButton("I") + italic_btn.setFixedSize(28, 26) + italic_btn.setToolTip("Kursiv (Ctrl+I)") + italic_btn.setStyleSheet("font-style: italic;") + italic_btn.clicked.connect(lambda: self._wrap_selection("*", "*")) + fmt_layout.addWidget(italic_btn) + + strike_btn = QPushButton("S̶") + strike_btn.setFixedSize(28, 26) + strike_btn.setToolTip("Durchgestrichen") + strike_btn.clicked.connect(lambda: self._wrap_selection("~~", "~~")) + fmt_layout.addWidget(strike_btn) + + code_btn = QPushButton("") + code_btn.setFixedSize(32, 26) + code_btn.setToolTip("Code") + code_btn.clicked.connect(lambda: self._wrap_selection("`", "`")) + fmt_layout.addWidget(code_btn) + + fmt_layout.addSpacing(8) + + # Listen + ul_btn = QPushButton("•") + ul_btn.setFixedSize(28, 26) + ul_btn.setToolTip("Aufzählung") + ul_btn.clicked.connect(lambda: self._insert_format("- ", "")) + fmt_layout.addWidget(ul_btn) + + ol_btn = QPushButton("1.") + ol_btn.setFixedSize(28, 26) + ol_btn.setToolTip("Nummerierte Liste") + ol_btn.clicked.connect(lambda: self._insert_format("1. ", "")) + fmt_layout.addWidget(ol_btn) + + task_btn = QPushButton("☑") + task_btn.setFixedSize(28, 26) + task_btn.setToolTip("Aufgabe") + task_btn.clicked.connect(lambda: self._insert_format("- [ ] ", "")) + fmt_layout.addWidget(task_btn) + + fmt_layout.addSpacing(8) + + # Blöcke + quote_btn = QPushButton("❝") + quote_btn.setFixedSize(28, 26) + quote_btn.setToolTip("Zitat") + quote_btn.clicked.connect(lambda: self._insert_format("> ", "")) + fmt_layout.addWidget(quote_btn) + + codeblock_btn = QPushButton("```") + codeblock_btn.setFixedSize(32, 26) + codeblock_btn.setToolTip("Code-Block") + codeblock_btn.clicked.connect(self._insert_codeblock) + fmt_layout.addWidget(codeblock_btn) + + hr_btn = QPushButton("―") + hr_btn.setFixedSize(28, 26) + hr_btn.setToolTip("Horizontale Linie") + hr_btn.clicked.connect(lambda: self._insert_format("\n---\n", "")) + fmt_layout.addWidget(hr_btn) + + fmt_layout.addSpacing(8) + + # Links und Bilder + link_btn = QPushButton("🔗") + link_btn.setFixedSize(28, 26) + link_btn.setToolTip("Link einfügen") + link_btn.clicked.connect(self._insert_link) + fmt_layout.addWidget(link_btn) + + img_btn = QPushButton("🖼") + img_btn.setFixedSize(28, 26) + img_btn.setToolTip("Bild einfügen") + img_btn.clicked.connect(self._insert_image) + fmt_layout.addWidget(img_btn) + + table_btn = QPushButton("▦") + table_btn.setFixedSize(28, 26) + table_btn.setToolTip("Tabelle einfügen") + table_btn.clicked.connect(self._insert_table) + fmt_layout.addWidget(table_btn) + + fmt_layout.addStretch() + + layout.addWidget(self.format_toolbar) + + # Stacked Widget für Preview/Edit + self.stack = QStackedWidget() + + # Preview (gerendert) + self.preview = QTextEdit() + self.preview.setReadOnly(True) + self.stack.addWidget(self.preview) + + # Editor (Quelltext) + self.editor = QPlainTextEdit() + font = QFont("Monospace", 10) + font.setStyleHint(QFont.StyleHint.Monospace) + self.editor.setFont(font) + self.stack.addWidget(self.editor) + + layout.addWidget(self.stack, 1) + + # Shortcuts für Editor + from PyQt6.QtGui import QShortcut, QKeySequence + self._bold_shortcut = QShortcut(QKeySequence("Ctrl+B"), self.editor) + self._bold_shortcut.activated.connect(lambda: self._wrap_selection("**", "**")) + self._italic_shortcut = QShortcut(QKeySequence("Ctrl+I"), self.editor) + self._italic_shortcut.activated.connect(lambda: self._wrap_selection("*", "*")) + + def _render_markdown(self, content: str) -> str: + """Rendert Markdown zu HTML mit Styling.""" + html = markdown.markdown( + content, + extensions=['tables', 'fenced_code', 'codehilite', 'toc'] + ) + return f"{html}" + + def load_markdown(self, path: str) -> bool: + """Lädt eine Markdown-Datei.""" + self._current_path = path + try: + size = os.path.getsize(path) + if size > self.MAX_SIZE: + self.preview.setPlainText(f"Datei zu groß ({format_file_size(size)})") + return False + + with open(path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + + self.preview.setHtml(self._render_markdown(content)) + self.editor.setPlainText(content) + + # Zurück zur Vorschau + self._edit_mode = False + self.format_toolbar.setVisible(False) + self.stack.setCurrentIndex(0) + self.edit_btn.setText("✏️ Bearbeiten") + + return True + + except Exception as e: + self.preview.setPlainText(f"Fehler beim Laden: {e}") + return False + + def _toggle_edit_mode(self): + """Wechselt zwischen Vorschau und Bearbeiten.""" + if self._edit_mode: + self._save_and_render() + else: + self._edit_mode = True + self.format_toolbar.setVisible(True) + self.stack.setCurrentIndex(1) + self.edit_btn.setText("💾 Speichern") + + def _save_and_render(self): + """Speichert die Änderungen und rendert neu.""" + if not self._current_path: + return + + try: + content = self.editor.toPlainText() + + with open(self._current_path, 'w', encoding='utf-8') as f: + f.write(content) + + self.preview.setHtml(self._render_markdown(content)) + + self._edit_mode = False + self.format_toolbar.setVisible(False) + self.stack.setCurrentIndex(0) + self.edit_btn.setText("✏️ Bearbeiten") + + except Exception as e: + QMessageBox.warning(self, "Fehler", f"Speichern fehlgeschlagen: {e}") + + def _wrap_selection(self, prefix: str, suffix: str): + """Umschließt die Auswahl mit Prefix und Suffix.""" + cursor = self.editor.textCursor() + selected = cursor.selectedText() + if selected: + cursor.insertText(f"{prefix}{selected}{suffix}") + else: + pos = cursor.position() + cursor.insertText(f"{prefix}{suffix}") + cursor.setPosition(pos + len(prefix)) + self.editor.setTextCursor(cursor) + + def _insert_format(self, prefix: str, suffix: str): + """Fügt Formatierung am Zeilenanfang ein.""" + cursor = self.editor.textCursor() + selected = cursor.selectedText() + if selected: + # Jede Zeile formatieren + lines = selected.split('\u2029') # Paragraph separator + formatted = '\n'.join(f"{prefix}{line}{suffix}" for line in lines) + cursor.insertText(formatted) + else: + cursor.insertText(f"{prefix}{suffix}") + + def _insert_codeblock(self): + """Fügt einen Code-Block ein.""" + cursor = self.editor.textCursor() + selected = cursor.selectedText() + if selected: + cursor.insertText(f"```\n{selected}\n```") + else: + cursor.insertText("```\n\n```") + cursor.movePosition(cursor.MoveOperation.Up) + self.editor.setTextCursor(cursor) + + def _insert_link(self): + """Fügt einen Link ein.""" + cursor = self.editor.textCursor() + selected = cursor.selectedText() + if selected: + cursor.insertText(f"[{selected}](url)") + else: + cursor.insertText("[Text](url)") + + def _insert_image(self): + """Fügt ein Bild ein.""" + cursor = self.editor.textCursor() + cursor.insertText("![Alt-Text](bild.png)") + + def _insert_table(self): + """Fügt eine Tabelle ein.""" + table = """| Spalte 1 | Spalte 2 | Spalte 3 | +|----------|----------|----------| +| Zelle 1 | Zelle 2 | Zelle 3 | +| Zelle 4 | Zelle 5 | Zelle 6 |""" + cursor = self.editor.textCursor() + cursor.insertText(table) + + def clear(self): + """Leert die Vorschau.""" + self._current_path = "" + self.preview.clear() + self.editor.clear() + self._edit_mode = False + self.format_toolbar.setVisible(False) + self.stack.setCurrentIndex(0) + self.edit_btn.setText("✏️ Bearbeiten") + + class PdfPreview(QWidget): - """PDF-Vorschau mit nativem Qt PDF-Renderer und Einstellungen.""" + """PDF-Vorschau mit nativem Qt PDF-Renderer und Bearbeitungsfunktionen.""" # Zoom-Modi ZOOM_MODES = [ @@ -136,9 +472,9 @@ class PdfPreview(QWidget): toolbar_layout.setSpacing(8) # Zoom-Modus - toolbar_layout.addWidget(QLabel("Zoom:")) + toolbar_layout.addWidget(QLabel("🔍 Zoom:")) self.zoom_combo = QComboBox() - self.zoom_combo.setFixedWidth(120) + self.zoom_combo.setFixedWidth(130) for key, label, _ in self.ZOOM_MODES: self.zoom_combo.addItem(label, key) self.zoom_combo.currentIndexChanged.connect(self._on_zoom_mode_changed) @@ -157,9 +493,9 @@ class PdfPreview(QWidget): toolbar_layout.addSpacing(16) # Seiten-Modus - toolbar_layout.addWidget(QLabel("Ansicht:")) + toolbar_layout.addWidget(QLabel("📄 Ansicht:")) self.page_combo = QComboBox() - self.page_combo.setFixedWidth(120) + self.page_combo.setFixedWidth(140) for key, label, _ in self.PAGE_MODES: self.page_combo.addItem(label, key) self.page_combo.currentIndexChanged.connect(self._on_page_mode_changed) @@ -167,6 +503,35 @@ class PdfPreview(QWidget): toolbar_layout.addStretch() + # Schnell-Rotation Buttons + self.rotate_left_btn = QPushButton("↶") + self.rotate_left_btn.setToolTip("90° nach links drehen") + self.rotate_left_btn.setFixedSize(32, 28) + self.rotate_left_btn.clicked.connect(lambda: self._quick_rotate(270)) + toolbar_layout.addWidget(self.rotate_left_btn) + + self.rotate_right_btn = QPushButton("↷") + self.rotate_right_btn.setToolTip("90° nach rechts drehen") + self.rotate_right_btn.setFixedSize(32, 28) + self.rotate_right_btn.clicked.connect(lambda: self._quick_rotate(90)) + toolbar_layout.addWidget(self.rotate_right_btn) + + toolbar_layout.addSpacing(8) + + # Komprimieren Button + self.compress_btn = QPushButton("📦") + self.compress_btn.setToolTip("PDF komprimieren") + self.compress_btn.setFixedSize(32, 28) + self.compress_btn.clicked.connect(self._compress_pdf) + toolbar_layout.addWidget(self.compress_btn) + + # LibreOffice Draw Button (für Annotationen) + self.draw_btn = QPushButton("🖊️") + self.draw_btn.setToolTip("In LibreOffice Draw öffnen (Annotationen)") + self.draw_btn.setFixedSize(32, 28) + self.draw_btn.clicked.connect(self._open_in_draw) + toolbar_layout.addWidget(self.draw_btn) + layout.addWidget(toolbar) # PDF Document und View @@ -251,11 +616,70 @@ class PdfPreview(QWidget): def load_pdf(self, path: str) -> bool: """Lädt eine PDF-Datei.""" + self._current_path = path error = self.pdf_document.load(path) if error != QPdfDocument.Error.None_: return False return True + def _open_in_draw(self): + """Öffnet PDF in LibreOffice Draw für Annotationen.""" + if not hasattr(self, '_current_path') or not self._current_path: + return + try: + subprocess.Popen(['libreoffice', '--draw', self._current_path]) + except Exception as e: + QMessageBox.warning(self, "Fehler", f"LibreOffice konnte nicht gestartet werden: {e}") + + def _quick_rotate(self, degrees: int): + """Dreht die PDF direkt und speichert.""" + if not hasattr(self, '_current_path') or not self._current_path: + return + + path = self._current_path + fd, temp_path = tempfile.mkstemp(suffix='.pdf') + os.close(fd) + + try: + success, msg = pdf_tools.rotate_pages(path, temp_path, degrees) + if success: + self.pdf_document.close() + shutil.move(temp_path, path) + self.load_pdf(path) + else: + QMessageBox.warning(self, "Fehler", f"Rotation fehlgeschlagen: {msg}") + if os.path.exists(temp_path): + os.remove(temp_path) + except Exception as e: + QMessageBox.warning(self, "Fehler", f"Fehler bei Rotation: {e}") + if os.path.exists(temp_path): + os.remove(temp_path) + + def _compress_pdf(self): + """Komprimiert die PDF.""" + if not hasattr(self, '_current_path') or not self._current_path: + return + + path = self._current_path + fd, temp_path = tempfile.mkstemp(suffix='.pdf') + os.close(fd) + + try: + success, msg = pdf_tools.compress_pdf(path, temp_path) + if success: + self.pdf_document.close() + shutil.move(temp_path, path) + self.load_pdf(path) + QMessageBox.information(self, "Komprimiert", msg) + else: + QMessageBox.warning(self, "Fehler", f"Komprimierung fehlgeschlagen: {msg}") + if os.path.exists(temp_path): + os.remove(temp_path) + except Exception as e: + QMessageBox.warning(self, "Fehler", f"Fehler bei Komprimierung: {e}") + if os.path.exists(temp_path): + os.remove(temp_path) + def clear(self): """Leert die Vorschau.""" self.pdf_document.close() @@ -332,6 +756,7 @@ class PreviewPanel(QWidget): delete_requested = pyqtSignal(str) open_external_requested = pyqtSignal(str) detach_requested = pyqtSignal() + content_changed = pyqtSignal(bool) # True = hat Inhalt, False = leer def __init__(self, parent=None): super().__init__(parent) @@ -422,7 +847,11 @@ class PreviewPanel(QWidget): self.pdf_preview = PdfPreview() self.preview_stack.addWidget(self.pdf_preview) - # Keine Vorschau (Index 4) + # Markdown-Vorschau (Index 4) + self.markdown_preview = MarkdownPreview() + self.preview_stack.addWidget(self.markdown_preview) + + # Keine Vorschau (Index 5) self.no_preview = NoPreview() self.no_preview.open_external.connect(self.open_external_requested.emit) self.preview_stack.addWidget(self.no_preview) @@ -465,9 +894,15 @@ class PreviewPanel(QWidget): elif file_type == 'pdf': self.pdf_preview.load_pdf(path) self.preview_stack.setCurrentIndex(3) + elif file_type == 'markdown': + self.markdown_preview.load_markdown(path) + self.preview_stack.setCurrentIndex(4) else: self.no_preview.set_file(path, name) - self.preview_stack.setCurrentIndex(4) + self.preview_stack.setCurrentIndex(5) + + # Signal senden: Hat jetzt Inhalt + self.content_changed.emit(True) def clear(self): """Leert die Vorschau.""" @@ -480,8 +915,12 @@ class PreviewPanel(QWidget): self.image_preview.clear() self.text_preview.clear() self.pdf_preview.clear() + self.markdown_preview.clear() self.preview_stack.setCurrentIndex(0) + # Signal senden: Ist jetzt leer + self.content_changed.emit(False) + def get_current_file(self) -> tuple: """Gibt den aktuellen Pfad und Namen zurück.""" return self._current_path, self._current_name