PDF , MD View und Edit eingebaut
This commit is contained in:
parent
9295fe9a6c
commit
a9761ec33b
10 changed files with 1127 additions and 31 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Name=FileBrowser
|
Name=FileBrowser
|
||||||
Comment=Dateimanager mit Vorschau-Funktion
|
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
|
Icon=/mnt/17 - Entwicklungen/20 - Projekte/FileBrowser/resources/icon.png
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
|
|
|
||||||
7
src/dialogs/__init__.py
Normal file
7
src/dialogs/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Dialoge für den FileBrowser."""
|
||||||
|
|
||||||
|
from .base import RenameDialog, MoveDialog, DeleteDialog, SettingsDialog
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'RenameDialog', 'MoveDialog', 'DeleteDialog', 'SettingsDialog'
|
||||||
|
]
|
||||||
|
|
@ -5,9 +5,9 @@ from pathlib import Path
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
QPushButton, QTreeView, QDialogButtonBox, QMessageBox,
|
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
|
from PyQt6.QtGui import QFileSystemModel
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -228,3 +228,113 @@ class DeleteDialog(QDialog):
|
||||||
button_box.addButton(cancel_btn, QDialogButtonBox.ButtonRole.RejectRole)
|
button_box.addButton(cancel_btn, QDialogButtonBox.ButtonRole.RejectRole)
|
||||||
|
|
||||||
layout.addWidget(button_box)
|
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(),
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ from .widgets.folder_tree import FolderTreeWidget
|
||||||
from .widgets.file_list import FileListWidget
|
from .widgets.file_list import FileListWidget
|
||||||
from .widgets.preview_panel import PreviewPanel
|
from .widgets.preview_panel import PreviewPanel
|
||||||
from .widgets.breadcrumb import BreadcrumbWidget
|
from .widgets.breadcrumb import BreadcrumbWidget
|
||||||
from .dialogs import RenameDialog, MoveDialog, DeleteDialog
|
from .dialogs import RenameDialog, MoveDialog, DeleteDialog, SettingsDialog
|
||||||
from .preview_window import PreviewWindow
|
from .preview_window import PreviewWindow
|
||||||
from .utils.themes import ThemeManager
|
from .utils.themes import ThemeManager
|
||||||
|
|
||||||
|
|
@ -74,11 +74,11 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Panel 3: Vorschau
|
# Panel 3: Vorschau
|
||||||
self.preview_panel = PreviewPanel()
|
self.preview_panel = PreviewPanel()
|
||||||
self.preview_panel.setMinimumWidth(200)
|
self.preview_panel.setMinimumWidth(420)
|
||||||
self.main_splitter.addWidget(self.preview_panel)
|
self.main_splitter.addWidget(self.preview_panel)
|
||||||
|
|
||||||
# Standardgrößen
|
# 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(0, 0)
|
||||||
self.main_splitter.setStretchFactor(1, 1)
|
self.main_splitter.setStretchFactor(1, 1)
|
||||||
self.main_splitter.setStretchFactor(2, 0)
|
self.main_splitter.setStretchFactor(2, 0)
|
||||||
|
|
@ -100,6 +100,13 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
file_menu.addSeparator()
|
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 = QAction("Beenden", self)
|
||||||
quit_action.setShortcut(QKeySequence.StandardKey.Quit)
|
quit_action.setShortcut(QKeySequence.StandardKey.Quit)
|
||||||
quit_action.triggered.connect(self.close)
|
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.delete_requested.connect(self._delete_file)
|
||||||
self.preview_panel.open_external_requested.connect(self._open_external)
|
self.preview_panel.open_external_requested.connect(self._open_external)
|
||||||
self.preview_panel.detach_requested.connect(self._detach_preview)
|
self.preview_panel.detach_requested.connect(self._detach_preview)
|
||||||
|
self.preview_panel.content_changed.connect(self._on_preview_content_changed)
|
||||||
|
|
||||||
def _load_settings(self):
|
def _load_settings(self):
|
||||||
"""Lädt gespeicherte Einstellungen."""
|
"""Lädt gespeicherte Einstellungen."""
|
||||||
|
|
@ -316,6 +324,9 @@ class MainWindow(QMainWindow):
|
||||||
if index >= 0:
|
if index >= 0:
|
||||||
self.theme_combo.setCurrentIndex(index)
|
self.theme_combo.setCurrentIndex(index)
|
||||||
|
|
||||||
|
# Preview-Panel beim Start verstecken (hat noch keinen Inhalt)
|
||||||
|
self.preview_panel.setVisible(False)
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
"""Speichert Einstellungen."""
|
"""Speichert Einstellungen."""
|
||||||
self.settings.setValue('window_geometry', self.saveGeometry())
|
self.settings.setValue('window_geometry', self.saveGeometry())
|
||||||
|
|
@ -325,10 +336,18 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
def _apply_theme(self):
|
def _apply_theme(self):
|
||||||
"""Wendet das aktuelle Theme an."""
|
"""Wendet das aktuelle Theme an."""
|
||||||
theme = self.theme_manager.get_current_theme()
|
theme_name = self.theme_manager.get_current_theme()
|
||||||
stylesheet = self.theme_manager.apply_theme(theme)
|
stylesheet = self.theme_manager.apply_theme(theme_name)
|
||||||
self.setStyleSheet(stylesheet)
|
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
|
# Theme-Menü-Aktionen aktualisieren
|
||||||
for action in self.theme_actions:
|
for action in self.theme_actions:
|
||||||
action.setChecked(action.data() == theme)
|
action.setChecked(action.data() == theme)
|
||||||
|
|
@ -384,9 +403,16 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
def _on_file_selected(self, path: str, name: str):
|
def _on_file_selected(self, path: str, name: str):
|
||||||
"""Behandelt Dateiauswahl."""
|
"""Behandelt Dateiauswahl."""
|
||||||
|
# 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)
|
self.preview_panel.load_file(path, name)
|
||||||
|
|
||||||
# Preview-Fenster aktualisieren
|
# Preview-Fenster aktualisieren (falls offen)
|
||||||
if self.preview_window and self.preview_window.isVisible():
|
if self.preview_window and self.preview_window.isVisible():
|
||||||
self.preview_window.load_file(path, name)
|
self.preview_window.load_file(path, name)
|
||||||
|
|
||||||
|
|
@ -560,6 +586,14 @@ class MainWindow(QMainWindow):
|
||||||
"""Behandelt das Schließen des Preview-Fensters."""
|
"""Behandelt das Schließen des Preview-Fensters."""
|
||||||
pass # Preview-Fenster bleibt im Speicher für schnelles Wiedereröffnen
|
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):
|
def _set_pdf_zoom_mode(self, mode_id: str):
|
||||||
"""Setzt den PDF-Zoom-Modus."""
|
"""Setzt den PDF-Zoom-Modus."""
|
||||||
settings = self.preview_panel.pdf_preview.get_settings()
|
settings = self.preview_panel.pdf_preview.get_settings()
|
||||||
|
|
@ -582,6 +616,34 @@ class MainWindow(QMainWindow):
|
||||||
for action in self.pdf_page_actions:
|
for action in self.pdf_page_actions:
|
||||||
action.setChecked(action.data() == settings['page_mode'])
|
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):
|
def _show_about(self):
|
||||||
"""Zeigt den Über-Dialog."""
|
"""Zeigt den Über-Dialog."""
|
||||||
QMessageBox.about(
|
QMessageBox.about(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from PyQt6.QtWidgets import (
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal, QSettings
|
from PyQt6.QtCore import Qt, pyqtSignal, QSettings
|
||||||
from PyQt6.QtGui import QFont
|
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
|
from .utils.file_utils import format_file_size, get_file_type
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ class PreviewWindow(QMainWindow):
|
||||||
self._current_name = ""
|
self._current_name = ""
|
||||||
|
|
||||||
self.setWindowTitle("Vorschau")
|
self.setWindowTitle("Vorschau")
|
||||||
self.setMinimumSize(400, 300)
|
self.setMinimumSize(550, 400)
|
||||||
|
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
self._load_settings()
|
self._load_settings()
|
||||||
|
|
@ -69,6 +69,7 @@ class PreviewWindow(QMainWindow):
|
||||||
self.image_preview = None
|
self.image_preview = None
|
||||||
self.text_preview = None
|
self.text_preview = None
|
||||||
self.pdf_preview = None
|
self.pdf_preview = None
|
||||||
|
self.markdown_preview = None
|
||||||
self.no_preview = None
|
self.no_preview = None
|
||||||
self._current_preview = None
|
self._current_preview = None
|
||||||
|
|
||||||
|
|
@ -78,8 +79,11 @@ class PreviewWindow(QMainWindow):
|
||||||
geometry = settings.value('preview_window_geometry')
|
geometry = settings.value('preview_window_geometry')
|
||||||
if geometry:
|
if geometry:
|
||||||
self.restoreGeometry(geometry)
|
self.restoreGeometry(geometry)
|
||||||
|
# Mindestgröße sicherstellen
|
||||||
|
if self.width() < 550 or self.height() < 400:
|
||||||
|
self.resize(700, 550)
|
||||||
else:
|
else:
|
||||||
self.resize(600, 500)
|
self.resize(700, 550)
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
"""Speichert Fenstereinstellungen."""
|
"""Speichert Fenstereinstellungen."""
|
||||||
|
|
@ -134,6 +138,12 @@ class PreviewWindow(QMainWindow):
|
||||||
self.pdf_preview.load_pdf(path)
|
self.pdf_preview.load_pdf(path)
|
||||||
self._current_preview = self.pdf_preview
|
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:
|
else:
|
||||||
if not self.no_preview:
|
if not self.no_preview:
|
||||||
self.no_preview = NoPreview()
|
self.no_preview = NoPreview()
|
||||||
|
|
|
||||||
|
|
@ -75,13 +75,16 @@ def get_file_type(filename: str) -> str:
|
||||||
ext = Path(filename).suffix.lower()
|
ext = Path(filename).suffix.lower()
|
||||||
|
|
||||||
image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.svg'}
|
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',
|
'.json', '.xml', '.yaml', '.yml', '.sh', '.bash', '.csv', '.ini',
|
||||||
'.conf', '.cfg'}
|
'.conf', '.cfg'}
|
||||||
pdf_exts = {'.pdf'}
|
pdf_exts = {'.pdf'}
|
||||||
|
|
||||||
if ext in image_exts:
|
if ext in image_exts:
|
||||||
return 'image'
|
return 'image'
|
||||||
|
elif ext in markdown_exts:
|
||||||
|
return 'markdown'
|
||||||
elif ext in text_exts:
|
elif ext in text_exts:
|
||||||
return 'text'
|
return 'text'
|
||||||
elif ext in pdf_exts:
|
elif ext in pdf_exts:
|
||||||
|
|
|
||||||
385
src/utils/pdf_tools.py
Normal file
385
src/utils/pdf_tools.py
Normal file
|
|
@ -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)}"
|
||||||
|
|
@ -9,6 +9,10 @@ class ThemeManager:
|
||||||
"""Verwaltet die Themes der Anwendung."""
|
"""Verwaltet die Themes der Anwendung."""
|
||||||
|
|
||||||
THEMES = {
|
THEMES = {
|
||||||
|
'system': {
|
||||||
|
'name': 'System',
|
||||||
|
'is_system': True, # Marker für System-Theme
|
||||||
|
},
|
||||||
'dark': {
|
'dark': {
|
||||||
'name': 'Dark',
|
'name': 'Dark',
|
||||||
'window': '#0f172a',
|
'window': '#0f172a',
|
||||||
|
|
@ -72,6 +76,11 @@ class ThemeManager:
|
||||||
theme = self.THEMES[theme_name]
|
theme = self.THEMES[theme_name]
|
||||||
app = QApplication.instance()
|
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 = QPalette()
|
||||||
palette.setColor(QPalette.ColorRole.Window, QColor(theme['window']))
|
palette.setColor(QPalette.ColorRole.Window, QColor(theme['window']))
|
||||||
palette.setColor(QPalette.ColorRole.WindowText, QColor(theme['window_text']))
|
palette.setColor(QPalette.ColorRole.WindowText, QColor(theme['window_text']))
|
||||||
|
|
@ -103,6 +112,10 @@ class ThemeManager:
|
||||||
|
|
||||||
theme = self.THEMES.get(theme_name, self.THEMES['dark'])
|
theme = self.THEMES.get(theme_name, self.THEMES['dark'])
|
||||||
|
|
||||||
|
# System-Theme: kein Stylesheet
|
||||||
|
if theme.get('is_system'):
|
||||||
|
return ""
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
QMainWindow, QWidget {{
|
QMainWindow, QWidget {{
|
||||||
background-color: {theme['window']};
|
background-color: {theme['window']};
|
||||||
|
|
@ -127,10 +140,15 @@ class ThemeManager:
|
||||||
color: {theme['highlight_text']};
|
color: {theme['highlight_text']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QTreeView::item:hover, QListView::item:hover, QTableView::item:hover {{
|
QTreeView::item:hover, QListView::item:hover {{
|
||||||
background-color: {theme['alternate_base']};
|
background-color: {theme['alternate_base']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
QTreeView::item:selected:hover, QListView::item:selected:hover {{
|
||||||
|
background-color: {theme['highlight']};
|
||||||
|
color: {theme['highlight_text']};
|
||||||
|
}}
|
||||||
|
|
||||||
QHeaderView::section {{
|
QHeaderView::section {{
|
||||||
background-color: {theme['button']};
|
background-color: {theme['button']};
|
||||||
color: {theme['button_text']};
|
color: {theme['button_text']};
|
||||||
|
|
@ -266,6 +284,8 @@ class ThemeManager:
|
||||||
border: 1px solid {theme['border']};
|
border: 1px solid {theme['border']};
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
padding-right: 25px;
|
||||||
|
min-height: 20px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QComboBox:hover {{
|
QComboBox:hover {{
|
||||||
|
|
@ -273,14 +293,30 @@ class ThemeManager:
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QComboBox::drop-down {{
|
QComboBox::drop-down {{
|
||||||
border: none;
|
subcontrol-origin: padding;
|
||||||
|
subcontrol-position: center right;
|
||||||
width: 20px;
|
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 {{
|
QComboBox QAbstractItemView {{
|
||||||
background-color: {theme['base']};
|
background-color: {theme['base']};
|
||||||
color: {theme['text']};
|
color: {theme['text']};
|
||||||
selection-background-color: {theme['highlight']};
|
selection-background-color: {theme['highlight']};
|
||||||
|
border: 1px solid {theme['border']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QTabWidget::pane {{
|
QTabWidget::pane {{
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,19 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QTableView, QAbstractItemView, QMenu, QHeaderView,
|
QTableView, QAbstractItemView, QMenu, QHeaderView
|
||||||
QStyledItemDelegate, QStyle
|
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
Qt, QModelIndex, pyqtSignal, QAbstractTableModel, QMimeData,
|
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
|
from ..utils.file_utils import get_file_icon, format_file_size, natural_sort_key
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class FileItem:
|
class FileItem:
|
||||||
"""Repräsentiert einen Dateieintrag."""
|
"""Repräsentiert einen Dateieintrag."""
|
||||||
|
|
||||||
|
|
@ -32,12 +33,14 @@ class FileItem:
|
||||||
class FileListModel(QAbstractTableModel):
|
class FileListModel(QAbstractTableModel):
|
||||||
"""Model für die Dateiliste."""
|
"""Model für die Dateiliste."""
|
||||||
|
|
||||||
HEADERS = ['', 'Name', 'Größe', 'Geändert']
|
HEADERS = ['📄', 'Name', 'Größe', 'Geändert']
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.items: list[FileItem] = []
|
self.items: list[FileItem] = []
|
||||||
self.current_path = ""
|
self.current_path = ""
|
||||||
|
self._hovered_row = -1
|
||||||
|
self._hover_color = QColor("#334155") # Default, wird vom Theme überschrieben
|
||||||
|
|
||||||
def rowCount(self, parent=None):
|
def rowCount(self, parent=None):
|
||||||
return len(self.items)
|
return len(self.items)
|
||||||
|
|
@ -70,9 +73,33 @@ class FileListModel(QAbstractTableModel):
|
||||||
return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||||
elif role == Qt.ItemDataRole.ToolTipRole:
|
elif role == Qt.ItemDataRole.ToolTipRole:
|
||||||
return item.path
|
return item.path
|
||||||
|
elif role == Qt.ItemDataRole.BackgroundRole:
|
||||||
|
if index.row() == self._hovered_row:
|
||||||
|
return QBrush(self._hover_color)
|
||||||
|
|
||||||
return None
|
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):
|
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
|
||||||
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
|
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
|
||||||
return self.HEADERS[section]
|
return self.HEADERS[section]
|
||||||
|
|
@ -154,9 +181,10 @@ class FileListWidget(QTableView):
|
||||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
self.setShowGrid(False)
|
self.setShowGrid(False)
|
||||||
self.setAlternatingRowColors(True)
|
self.setAlternatingRowColors(False)
|
||||||
self.verticalHeader().setVisible(False)
|
self.verticalHeader().setVisible(False)
|
||||||
self.setWordWrap(False)
|
self.setWordWrap(False)
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
|
||||||
# Spaltenbreiten
|
# Spaltenbreiten
|
||||||
header = self.horizontalHeader()
|
header = self.horizontalHeader()
|
||||||
|
|
@ -164,7 +192,7 @@ class FileListWidget(QTableView):
|
||||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
|
||||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
|
||||||
self.setColumnWidth(0, 30)
|
self.setColumnWidth(0, 36)
|
||||||
self.setColumnWidth(2, 80)
|
self.setColumnWidth(2, 80)
|
||||||
self.setColumnWidth(3, 130)
|
self.setColumnWidth(3, 130)
|
||||||
|
|
||||||
|
|
@ -361,3 +389,19 @@ class FileListWidget(QTableView):
|
||||||
self.folder_entered.emit(parent)
|
self.folder_entered.emit(parent)
|
||||||
else:
|
else:
|
||||||
super().keyPressEvent(event)
|
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))
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,20 @@ from pathlib import Path
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
QScrollArea, QPlainTextEdit, QStackedWidget, QSizePolicy,
|
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.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.QtGui import QPixmap, QImage, QFont, QPainter
|
||||||
from PyQt6.QtPdf import QPdfDocument
|
from PyQt6.QtPdf import QPdfDocument
|
||||||
from PyQt6.QtPdfWidgets import QPdfView
|
from PyQt6.QtPdfWidgets import QPdfView
|
||||||
|
|
||||||
from ..utils.file_utils import get_file_icon, format_file_size, get_file_type
|
from ..utils.file_utils import get_file_icon, format_file_size, get_file_type
|
||||||
|
from ..utils import pdf_tools
|
||||||
|
|
||||||
|
|
||||||
class ImagePreview(QScrollArea):
|
class ImagePreview(QScrollArea):
|
||||||
|
|
@ -103,8 +109,338 @@ class TextPreview(QPlainTextEdit):
|
||||||
return False
|
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"<style>{self.MARKDOWN_CSS}</style>{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("")
|
||||||
|
|
||||||
|
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):
|
class PdfPreview(QWidget):
|
||||||
"""PDF-Vorschau mit nativem Qt PDF-Renderer und Einstellungen."""
|
"""PDF-Vorschau mit nativem Qt PDF-Renderer und Bearbeitungsfunktionen."""
|
||||||
|
|
||||||
# Zoom-Modi
|
# Zoom-Modi
|
||||||
ZOOM_MODES = [
|
ZOOM_MODES = [
|
||||||
|
|
@ -136,9 +472,9 @@ class PdfPreview(QWidget):
|
||||||
toolbar_layout.setSpacing(8)
|
toolbar_layout.setSpacing(8)
|
||||||
|
|
||||||
# Zoom-Modus
|
# Zoom-Modus
|
||||||
toolbar_layout.addWidget(QLabel("Zoom:"))
|
toolbar_layout.addWidget(QLabel("🔍 Zoom:"))
|
||||||
self.zoom_combo = QComboBox()
|
self.zoom_combo = QComboBox()
|
||||||
self.zoom_combo.setFixedWidth(120)
|
self.zoom_combo.setFixedWidth(130)
|
||||||
for key, label, _ in self.ZOOM_MODES:
|
for key, label, _ in self.ZOOM_MODES:
|
||||||
self.zoom_combo.addItem(label, key)
|
self.zoom_combo.addItem(label, key)
|
||||||
self.zoom_combo.currentIndexChanged.connect(self._on_zoom_mode_changed)
|
self.zoom_combo.currentIndexChanged.connect(self._on_zoom_mode_changed)
|
||||||
|
|
@ -157,9 +493,9 @@ class PdfPreview(QWidget):
|
||||||
toolbar_layout.addSpacing(16)
|
toolbar_layout.addSpacing(16)
|
||||||
|
|
||||||
# Seiten-Modus
|
# Seiten-Modus
|
||||||
toolbar_layout.addWidget(QLabel("Ansicht:"))
|
toolbar_layout.addWidget(QLabel("📄 Ansicht:"))
|
||||||
self.page_combo = QComboBox()
|
self.page_combo = QComboBox()
|
||||||
self.page_combo.setFixedWidth(120)
|
self.page_combo.setFixedWidth(140)
|
||||||
for key, label, _ in self.PAGE_MODES:
|
for key, label, _ in self.PAGE_MODES:
|
||||||
self.page_combo.addItem(label, key)
|
self.page_combo.addItem(label, key)
|
||||||
self.page_combo.currentIndexChanged.connect(self._on_page_mode_changed)
|
self.page_combo.currentIndexChanged.connect(self._on_page_mode_changed)
|
||||||
|
|
@ -167,6 +503,35 @@ class PdfPreview(QWidget):
|
||||||
|
|
||||||
toolbar_layout.addStretch()
|
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)
|
layout.addWidget(toolbar)
|
||||||
|
|
||||||
# PDF Document und View
|
# PDF Document und View
|
||||||
|
|
@ -251,11 +616,70 @@ class PdfPreview(QWidget):
|
||||||
|
|
||||||
def load_pdf(self, path: str) -> bool:
|
def load_pdf(self, path: str) -> bool:
|
||||||
"""Lädt eine PDF-Datei."""
|
"""Lädt eine PDF-Datei."""
|
||||||
|
self._current_path = path
|
||||||
error = self.pdf_document.load(path)
|
error = self.pdf_document.load(path)
|
||||||
if error != QPdfDocument.Error.None_:
|
if error != QPdfDocument.Error.None_:
|
||||||
return False
|
return False
|
||||||
return True
|
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):
|
def clear(self):
|
||||||
"""Leert die Vorschau."""
|
"""Leert die Vorschau."""
|
||||||
self.pdf_document.close()
|
self.pdf_document.close()
|
||||||
|
|
@ -332,6 +756,7 @@ class PreviewPanel(QWidget):
|
||||||
delete_requested = pyqtSignal(str)
|
delete_requested = pyqtSignal(str)
|
||||||
open_external_requested = pyqtSignal(str)
|
open_external_requested = pyqtSignal(str)
|
||||||
detach_requested = pyqtSignal()
|
detach_requested = pyqtSignal()
|
||||||
|
content_changed = pyqtSignal(bool) # True = hat Inhalt, False = leer
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
@ -422,7 +847,11 @@ class PreviewPanel(QWidget):
|
||||||
self.pdf_preview = PdfPreview()
|
self.pdf_preview = PdfPreview()
|
||||||
self.preview_stack.addWidget(self.pdf_preview)
|
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 = NoPreview()
|
||||||
self.no_preview.open_external.connect(self.open_external_requested.emit)
|
self.no_preview.open_external.connect(self.open_external_requested.emit)
|
||||||
self.preview_stack.addWidget(self.no_preview)
|
self.preview_stack.addWidget(self.no_preview)
|
||||||
|
|
@ -465,9 +894,15 @@ class PreviewPanel(QWidget):
|
||||||
elif file_type == 'pdf':
|
elif file_type == 'pdf':
|
||||||
self.pdf_preview.load_pdf(path)
|
self.pdf_preview.load_pdf(path)
|
||||||
self.preview_stack.setCurrentIndex(3)
|
self.preview_stack.setCurrentIndex(3)
|
||||||
|
elif file_type == 'markdown':
|
||||||
|
self.markdown_preview.load_markdown(path)
|
||||||
|
self.preview_stack.setCurrentIndex(4)
|
||||||
else:
|
else:
|
||||||
self.no_preview.set_file(path, name)
|
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):
|
def clear(self):
|
||||||
"""Leert die Vorschau."""
|
"""Leert die Vorschau."""
|
||||||
|
|
@ -480,8 +915,12 @@ class PreviewPanel(QWidget):
|
||||||
self.image_preview.clear()
|
self.image_preview.clear()
|
||||||
self.text_preview.clear()
|
self.text_preview.clear()
|
||||||
self.pdf_preview.clear()
|
self.pdf_preview.clear()
|
||||||
|
self.markdown_preview.clear()
|
||||||
self.preview_stack.setCurrentIndex(0)
|
self.preview_stack.setCurrentIndex(0)
|
||||||
|
|
||||||
|
# Signal senden: Ist jetzt leer
|
||||||
|
self.content_changed.emit(False)
|
||||||
|
|
||||||
def get_current_file(self) -> tuple:
|
def get_current_file(self) -> tuple:
|
||||||
"""Gibt den aktuellen Pfad und Namen zurück."""
|
"""Gibt den aktuellen Pfad und Namen zurück."""
|
||||||
return self._current_path, self._current_name
|
return self._current_path, self._current_name
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue