PDF , MD View und Edit eingebaut

This commit is contained in:
Eduard Wisch 2026-02-02 14:44:54 +01:00
parent 9295fe9a6c
commit a9761ec33b
10 changed files with 1127 additions and 31 deletions

View file

@ -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
View file

@ -0,0 +1,7 @@
"""Dialoge für den FileBrowser."""
from .base import RenameDialog, MoveDialog, DeleteDialog, SettingsDialog
__all__ = [
'RenameDialog', 'MoveDialog', 'DeleteDialog', 'SettingsDialog'
]

View file

@ -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(),
}

View file

@ -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(

View file

@ -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()

View file

@ -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
View 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)}"

View file

@ -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 {{

View file

@ -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))

View file

@ -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("")
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("![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): 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