Refactor: MainWindow in 7 Mixins aufgeteilt (80% Code-Reduktion)

MainWindow.py von 5025 auf 983 Zeilen reduziert durch Extraktion in:
- TreeManagerMixin: Baumstruktur-Verwaltung (~1136 Zeilen)
- PdfViewerMixin: PDF-Anzeige und Rendering
- WorkerPoolMixin: Saxon/FOP Worker-Pool-Verwaltung
- DatabaseMixin: PostgreSQL-Operationen
- DragDropMixin: Drag-and-Drop für XML-Dateien
- HashCalculationMixin: blake2b Hash-Berechnung
- TransformationMixin: XSL-Transformationen

Zusätzlich Thread-Klassen in threads.py ausgelagert.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 18:23:55 +01:00
parent 5a2da7f264
commit 3acdfbb5c8
10 changed files with 4353 additions and 4066 deletions
+24 -4066
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
"""
Mixins für das MainWindow.
Dieses Paket enthält Mixins, die Funktionalität in separate Module auslagern,
um die MainWindow-Klasse übersichtlicher zu gestalten.
"""
from ui.mixins.tree_manager import TreeManagerMixin
from ui.mixins.pdf_viewer import PdfViewerMixin
from ui.mixins.worker_pool import WorkerPoolMixin
from ui.mixins.database import DatabaseMixin
from ui.mixins.drag_drop import DragDropMixin
from ui.mixins.hash_calculation import HashCalculationMixin
from ui.mixins.transformation import TransformationMixin
__all__ = [
"TreeManagerMixin",
"PdfViewerMixin",
"WorkerPoolMixin",
"DatabaseMixin",
"DragDropMixin",
"HashCalculationMixin",
"TransformationMixin",
]
+288
View File
@@ -0,0 +1,288 @@
"""
DatabaseMixin - Mixin für Datenbank-Operationen.
Dieses Mixin enthält alle Methoden zur PostgreSQL-Datenbankanbindung
und Datenverarbeitung für das MainWindow.
"""
import time
import logging
from pathlib import Path
import polars as pl
from PySide6.QtWidgets import QMessageBox
from conf import app_settings, TreeNode, XslFile
logger = logging.getLogger(__name__)
class DatabaseMixin:
"""
Mixin für Datenbank-Operationen.
Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute:
- self.project: Das aktuelle Projekt
- self.pdf_project: Die Projekt-Daten (ProjectData)
- self._load_nodes_to_tree(): Methode zum Laden der Nodes in den Tree
- self._save_project_settings(): Methode zum Speichern der Projekt-Einstellungen
"""
def on_load_from_fn2_clicked(self):
"""
Wird ausgeführt, wenn der Button "lade aus FN2" geklickt wird.
Führt SQL-Abfrage aus und aktualisiert die Projekt-Nodes.
"""
logger.debug("Button 'lade aus FN2' wurde geklickt!")
try:
# Prüfe ob ein Projekt geladen ist
if not hasattr(self, "project") or not self.project:
QMessageBox.warning(self, "Warnung", "Kein Projekt geladen. Bitte öffnen Sie zuerst ein Projekt.")
return
# Hole das aktuelle Projekt aus app_settings
if not self.project:
QMessageBox.warning(self, "Warnung", "Aktuelles Projekt nicht in den Einstellungen gefunden.")
return
# Hole die PostgreSQL-Datenbank-Konfiguration
db_config = self._get_database_config(self.project.postgre_sql_db_id)
if not db_config:
QMessageBox.warning(self, "Warnung", "PostgreSQL-Datenbank-Konfiguration nicht gefunden.")
return
# Führe SQL-Abfrage aus
df = self._execute_sql_query(db_config)
if df is None:
return # Fehler bereits angezeigt
# Verarbeite die Daten wie in readCsv.py
new_nodes = self._process_sql_data(df)
# Merge mit vorhandenen Nodes
self._merge_nodes_with_existing(new_nodes)
# Speichere die aktualisierten Projekt-Einstellungen
self._save_project_settings()
# Lade das Projekt neu
self._load_nodes_to_tree()
# QMessageBox.information(self, "Erfolg", "Daten erfolgreich aus FN2 geladen und Projekt aktualisiert!")
except Exception as e:
logger.error(f"Fehler beim Laden aus FN2: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Laden aus FN2:\n{str(e)}")
def _get_database_config(self, db_id):
"""
Holt die Datenbank-Konfiguration anhand der ID.
Args:
db_id: ID der PostgreSQL-Datenbank
Returns:
PostgreSqlDb|None: Die Datenbank-Konfiguration oder None
"""
for db in app_settings.postgresql_dbs:
if db.id == db_id:
return db
return None
def _execute_sql_query(self, db_config):
"""
Führt die SQL-Abfrage aus der data.sql Datei aus.
Args:
db_config: PostgreSQL-Datenbank-Konfiguration
Returns:
pl.DataFrame|None: Die Abfrageergebnisse oder None bei Fehler
"""
try:
# Lade SQL-Abfrage aus Datei
sql_file_path = Path("src/res/data.sql")
if not sql_file_path.exists():
QMessageBox.critical(self, "Fehler", f"SQL-Datei nicht gefunden: {sql_file_path}")
return None
with open(sql_file_path, "r", encoding="utf-8") as f:
sql_query = f.read()
logger.debug(f"SQL-Abfrage geladen: {len(sql_query)} Zeichen")
# Verbindung zur PostgreSQL-Datenbank herstellen
connection_string = (
"postgresql://"
f"{db_config.username}:"
f"{db_config.password}@"
f"{db_config.host}:"
f"{db_config.port}/"
f"{db_config.database}?"
f"sslmode={db_config.ssl_mode.value}"
)
logger.info(f"Verbinde zu PostgreSQL: {db_config.host}:{db_config.port}/{db_config.database}")
df = pl.read_database_uri(sql_query, connection_string, engine="connectorx").sort(
["reporttyp_bez", "report_bez", "repfile_bez"]
)
return df
except Exception as e:
error_msg = f"Fehler beim Ausführen der SQL-Abfrage: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
return None
def _process_sql_data(self, df):
"""
Verarbeitet die SQL-Daten wie in readCsv.py und erstellt Node-Struktur.
Args:
df: Polars DataFrame mit den SQL-Ergebnissen
Returns:
list[TreeNode]: Liste der erstellten Root-Nodes
"""
try:
start_time = time.time()
# Gruppiere die Daten wie in readCsv.py
ebene_1 = df.group_by(["reporttyp", "reporttyp_bez"]).len()
ebene_2 = df.group_by(["reporttyp", "report", "report_bez"]).len()
ebene_3 = df.group_by(["reporttyp", "report", "repfile", "repfile_bez", "xsl_datei"]).len()
group_time = time.time() - start_time
logger.debug(f"Performance: Gruppierung in {group_time:.3f}s")
new_nodes = []
start_time = time.time()
# Erstelle Node-Struktur wie in readCsv.py
for r1 in ebene_1.rows(named=True):
tn_1 = TreeNode(id=(r1["reporttyp"],), bez=r1["reporttyp_bez"], children=[])
r1_children = ebene_2.filter(pl.col("reporttyp") == r1["reporttyp"])
for r2 in r1_children.rows(named=True):
tn_2 = TreeNode(id=(r2["reporttyp"], r2["report"]), bez=r2["report_bez"], children=[])
r2_children = ebene_3.filter(
(pl.col("reporttyp") == r1["reporttyp"]) & (pl.col("report") == r2["report"])
)
for r3 in r2_children.rows(named=True):
x = XslFile(
id=(r3["reporttyp"], r3["report"], r3["repfile"]),
bez=r3["repfile_bez"],
xsl_file=Path(r3["xsl_datei"]),
xmls=[],
)
tn_2.children.append(x)
tn_1.children.append(tn_2)
new_nodes.append(tn_1)
nodes_time = time.time() - start_time
logger.debug(f"Performance: Node-Erstellung in {nodes_time:.3f}s")
logger.info(f"Erstellt: {len(new_nodes)} Root-Nodes")
return new_nodes
except Exception as e:
logger.error(f"Fehler beim Verarbeiten der SQL-Daten: {e}")
raise
def _merge_nodes_with_existing(self, new_nodes):
"""
Merged neue Nodes mit vorhandenen Nodes basierend auf IDs.
Überschreibt nur einzelne Eigenschaften, nicht ganze Nodes.
Args:
new_nodes: Liste der neuen Nodes
"""
try:
logger.info("Merge neue Nodes mit vorhandenen...")
# Erstelle ein Dictionary der neuen Nodes für schnellen Zugriff
new_nodes_dict = {}
self._build_nodes_dict(new_nodes, new_nodes_dict)
logger.debug(f"Neue Nodes Dictionary erstellt: {len(new_nodes_dict)} Einträge")
# Merge mit vorhandenen Nodes
if self.pdf_project and self.pdf_project.nodes:
self._merge_nodes_recursive(self.pdf_project.nodes, new_nodes_dict)
# Füge komplett neue Root-Nodes hinzu
if self.pdf_project and self.pdf_project.nodes:
existing_root_ids = {node.id for node in self.pdf_project.nodes}
for new_node in new_nodes:
if new_node.id not in existing_root_ids:
self.pdf_project.nodes.append(new_node)
logger.info(f"Neue Root-Node hinzugefügt: {new_node.bez}")
elif self.pdf_project:
# Wenn keine Nodes vorhanden sind, füge alle neuen Nodes hinzu
self.pdf_project.nodes = new_nodes
logger.info(f"Alle {len(new_nodes)} Root-Nodes hinzugefügt (keine vorhandenen Nodes)")
logger.info("Merge abgeschlossen")
except Exception as e:
logger.error(f"Fehler beim Mergen der Nodes: {e}")
raise
def _build_nodes_dict(self, nodes, nodes_dict):
"""
Erstellt rekursiv ein Dictionary aller Nodes für schnellen ID-basierten Zugriff.
Args:
nodes: Liste der Nodes
nodes_dict: Dictionary zum Füllen
"""
for node in nodes:
nodes_dict[node.id] = node
if isinstance(node, TreeNode) and node.children:
self._build_nodes_dict(node.children, nodes_dict)
def _merge_nodes_recursive(self, existing_nodes, new_nodes_dict):
"""
Merged rekursiv vorhandene Nodes mit neuen Nodes.
Args:
existing_nodes: Liste der vorhandenen Nodes
new_nodes_dict: Dictionary der neuen Nodes
"""
for existing_node in existing_nodes:
if existing_node.id in new_nodes_dict:
new_node = new_nodes_dict[existing_node.id]
# Aktualisiere nur die Bezeichnung, falls sie sich geändert hat
if existing_node.bez != new_node.bez:
logger.info(
f"Aktualisiere Bezeichnung für Node {existing_node.id}: '{existing_node.bez}' -> '{new_node.bez}'"
)
existing_node.bez = new_node.bez
# Für XslFile: Aktualisiere xsl_file Pfad
if isinstance(existing_node, XslFile) and isinstance(new_node, XslFile):
if existing_node.xsl_file != new_node.xsl_file:
logger.info(
f"Aktualisiere XSL-Datei für Node {existing_node.id}: '{existing_node.xsl_file}' -> '{new_node.xsl_file}'"
)
existing_node.xsl_file = new_node.xsl_file
# Rekursiv für Knoten (nur bei TreeNode)
if isinstance(existing_node, TreeNode) and existing_node.children:
self._merge_nodes_recursive(existing_node.children, new_nodes_dict)
# Füge neue Knoten hinzu, die noch nicht existieren
if existing_node.id in new_nodes_dict:
new_node = new_nodes_dict[existing_node.id]
if isinstance(new_node, TreeNode) and new_node.children:
existing_child_ids = {child.id for child in existing_node.children}
for new_child in new_node.children:
if new_child.id not in existing_child_ids:
existing_node.children.append(new_child)
logger.info(f"Neues Kind hinzugefügt zu Node {existing_node.id}: {new_child.bez}")
+346
View File
@@ -0,0 +1,346 @@
"""
DragDropMixin - Mixin für Drag-and-Drop-Funktionalität.
Dieses Mixin enthält alle Methoden zur Verarbeitung von Drag-and-Drop-Operationen
für das MainWindow.
"""
import logging
from pathlib import Path
from PySide6.QtGui import QDragEnterEvent, QDropEvent
from PySide6.QtWidgets import QMessageBox, QProgressBar
from ui.XmlToXslAssignDialog import XmlToXslAssignDialog
from ui.threads import XmlBatchProcessingThread
logger = logging.getLogger(__name__)
class DragDropMixin:
"""
Mixin für Drag-and-Drop-Funktionalität.
Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute:
- self.ui: Das UI-Objekt mit treeWidget
- self.project: Das aktuelle Projekt
- self.pdf_project: Die Projekt-Daten (ProjectData)
- self.batch_processing_thread: Thread für Batch-Verarbeitung
- self.batch_progress_bar: QProgressBar für Batch-Fortschritt
- self._save_project_settings(): Methode zum Speichern der Projekt-Einstellungen
- self._load_nodes_to_tree(): Methode zum Laden der Nodes in den Tree
"""
def _setup_drag_drop(self):
"""Aktiviert Drag&Drop für das TreeWidget."""
try:
# Aktiviere Drag&Drop für das TreeWidget
self.ui.treeWidget.setAcceptDrops(True)
self.ui.treeWidget.setDragDropMode(self.ui.treeWidget.DragDropMode.DropOnly)
# Überschreibe die Drag&Drop-Events
self.ui.treeWidget.dragEnterEvent = self.tree_drag_enter_event
self.ui.treeWidget.dragMoveEvent = self.tree_drag_move_event
self.ui.treeWidget.dropEvent = self.tree_drop_event
logger.debug("Drag&Drop für TreeWidget aktiviert")
except Exception as e:
logger.error(f"Fehler beim Aktivieren von Drag&Drop: {e}")
def tree_drag_enter_event(self, event: QDragEnterEvent):
"""
Wird ausgeführt, wenn ein Drag-Vorgang über das TreeWidget beginnt.
Args:
event: Das Drag-Enter-Event
"""
try:
# Prüfe ob URLs (Dateien) gedraggt werden
if event.mimeData().hasUrls():
urls = event.mimeData().urls()
# Prüfe ob mindestens eine XML-Datei dabei ist
xml_files = [url.toLocalFile() for url in urls if url.toLocalFile().lower().endswith(".xml")]
if xml_files:
event.acceptProposedAction()
logger.debug(f"Drag-Enter akzeptiert: {len(xml_files)} XML-Dateien")
else:
event.ignore()
logger.debug("Drag-Enter ignoriert: Keine XML-Dateien")
else:
event.ignore()
logger.debug("Drag-Enter ignoriert: Keine URLs")
except Exception as e:
logger.error(f"Fehler in tree_drag_enter_event: {e}")
event.ignore()
def tree_drag_move_event(self, event):
"""
Wird ausgeführt, wenn ein Drag-Vorgang über das TreeWidget bewegt wird.
Args:
event: Das Drag-Move-Event
"""
try:
# Prüfe ob URLs (Dateien) gedraggt werden
if event.mimeData().hasUrls():
urls = event.mimeData().urls()
# Prüfe ob mindestens eine XML-Datei dabei ist
xml_files = [url.toLocalFile() for url in urls if url.toLocalFile().lower().endswith(".xml")]
if xml_files:
event.acceptProposedAction()
else:
event.ignore()
else:
event.ignore()
except Exception as e:
logger.error(f"Fehler in tree_drag_move_event: {e}")
event.ignore()
def tree_drop_event(self, event: QDropEvent):
"""
Wird ausgeführt, wenn Dateien auf das TreeWidget gedroppt werden.
Args:
event: Das Drop-Event
"""
try:
# Prüfe ob ein Projekt geladen ist
if not hasattr(self, "project") or not self.project:
QMessageBox.warning(self, "Warnung", "Kein Projekt geladen. Bitte öffnen Sie zuerst ein Projekt.")
event.ignore()
return
if not hasattr(self, "pdf_project") or not self.pdf_project:
QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen geladen.")
event.ignore()
return
# Hole die URLs aus dem Drop-Event
if not event.mimeData().hasUrls():
event.ignore()
return
urls = event.mimeData().urls()
xml_files = []
# Sammle alle XML-Dateien
for url in urls:
file_path = url.toLocalFile()
if file_path.lower().endswith(".xml"):
xml_files.append(Path(file_path))
if not xml_files:
QMessageBox.information(self, "Information", "Keine XML-Dateien zum Hinzufügen gefunden.")
event.ignore()
return
logger.info(f"Drop-Event: {len(xml_files)} XML-Dateien erkannt")
# Verarbeite alle XML-Dateien mit optionalem "Alle zuordnen" Feature
self._handle_multiple_xml_files_drop(xml_files)
event.acceptProposedAction()
except Exception as e:
error_msg = f"Fehler beim Verarbeiten des Drop-Events: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
event.ignore()
def _handle_multiple_xml_files_drop(self, xml_files: list):
"""
Verarbeitet mehrere XML-Dateien asynchron per Drag&Drop.
Zeigt einen Dialog zur Auswahl der XSL-Knoten und startet dann die Batch-Verarbeitung im Hintergrund.
Args:
xml_files: Liste von Pfaden zu XML-Dateien
"""
if not xml_files:
return
try:
# Prüfe ob Projekt-Nodes verfügbar sind
if not self.pdf_project or not self.pdf_project.nodes:
QMessageBox.warning(self, "Warnung", "Keine Projekt-Nodes verfügbar für die Zuordnung.")
return
# Zeige Dialog für die erste Datei
dialog = XmlToXslAssignDialog(parent=self, xml_file_path=xml_files[0], project_nodes=self.pdf_project.nodes)
if dialog.exec() != XmlToXslAssignDialog.DialogCode.Accepted:
logger.debug("Dialog abgebrochen - keine Dateien verarbeitet")
return
# Hole die ausgewählten XSL-Knoten
selected_xsl_nodes = dialog.get_selected_xsl_nodes()
if not selected_xsl_nodes:
logger.warning("Keine XSL-Knoten ausgewählt")
return
# Prüfe ob "Alle zuordnen" aktiviert wurde
apply_to_all = dialog.is_apply_to_all()
# Bestimme welche Dateien verarbeitet werden sollen
files_to_process = xml_files if apply_to_all else [xml_files[0]]
# Stoppe vorherigen Batch-Thread falls noch aktiv
if self.batch_processing_thread and self.batch_processing_thread.isRunning():
self.batch_processing_thread.quit()
self.batch_processing_thread.wait()
# Zusätzliche Sicherheitsprüfung für project_dir
if not self.project or not self.project.project_dir:
QMessageBox.warning(self, "Fehler", "Projekt-Verzeichnis ist nicht verfügbar")
return
# Erstelle und starte neuen Batch-Verarbeitungs-Thread
self.batch_processing_thread = XmlBatchProcessingThread(
xml_files=files_to_process,
selected_xsl_nodes=selected_xsl_nodes,
project_dir=Path(self.project.project_dir),
pdf_project=self.pdf_project,
)
# Verbinde Signale
self.batch_processing_thread.progress_update.connect(self._on_batch_progress_update)
self.batch_processing_thread.processing_finished.connect(self._on_batch_processing_finished)
# Zeige Progressbar
self._show_batch_progress_bar(len(files_to_process))
# Starte Thread
self.batch_processing_thread.start()
logger.info(
f"Batch-Verarbeitung von {len(files_to_process)} Datei(en) gestartet (apply_to_all={apply_to_all})"
)
except Exception as e:
error_msg = f"Fehler beim Starten der Batch-Verarbeitung: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
def _show_batch_progress_bar(self, total_files: int):
"""
Zeigt einen Progressbar in der Statusbar für die Batch-Verarbeitung.
Args:
total_files: Gesamtanzahl der zu verarbeitenden Dateien
"""
if self.batch_progress_bar is None:
self.batch_progress_bar = QProgressBar()
self.batch_progress_bar.setMaximumHeight(20)
self.batch_progress_bar.setMaximumWidth(300)
self.batch_progress_bar.setMinimum(0)
self.batch_progress_bar.setMaximum(total_files)
self.batch_progress_bar.setValue(0)
self.batch_progress_bar.setFormat("%v/%m Dateien")
# Füge Progressbar zur Statusbar hinzu
self.statusBar().addPermanentWidget(self.batch_progress_bar)
self.batch_progress_bar.show()
def _hide_batch_progress_bar(self):
"""Versteckt und entfernt den Progressbar aus der Statusbar."""
if self.batch_progress_bar:
self.statusBar().removeWidget(self.batch_progress_bar)
self.batch_progress_bar.hide()
def _on_batch_progress_update(self, current: int, total: int, current_file: str):
"""
Wird aufgerufen wenn der Batch-Thread einen Fortschritt meldet.
Args:
current: Aktuelle Dateinummer
total: Gesamtanzahl der Dateien
current_file: Name der aktuellen Datei
"""
if self.batch_progress_bar:
self.batch_progress_bar.setValue(current)
self.statusBar().showMessage(f"Verarbeite: {current_file} ({current}/{total})")
def _on_batch_processing_finished(self, stats: dict):
"""
Wird aufgerufen wenn die Batch-Verarbeitung abgeschlossen ist.
Args:
stats: Statistik-Dictionary mit Verarbeitungsergebnissen
"""
try:
# Verstecke Progressbar
self._hide_batch_progress_bar()
# Speichere Projekt-Einstellungen
if stats["processed"] > 0:
self._save_project_settings()
# Aktualisiere Tree
self._load_nodes_to_tree()
# Zeige Zusammenfassungsdialog
self._show_drop_summary_dialog(stats)
# Statusbar-Nachricht
self.statusBar().showMessage(
f"Batch-Verarbeitung abgeschlossen: {stats['processed']}/{stats['total']} Dateien", 5000
)
except Exception as e:
logger.error(f"Fehler beim Abschließen der Batch-Verarbeitung: {e}")
def _show_drop_summary_dialog(self, stats: dict):
"""
Zeigt einen Zusammenfassungsdialog über die verarbeiteten XML-Dateien.
Args:
stats: Statistik-Dictionary mit Verarbeitungsergebnissen
"""
# Erstelle Zusammenfassungstext
summary_lines = []
summary_lines.append("Verarbeitung abgeschlossen:\n")
summary_lines.append(f"Gesamt: {stats['total']} Datei(en)")
summary_lines.append(f"Verarbeitet: {stats['processed']} Datei(en)")
if stats["new_added"] > 0:
summary_lines.append(f"Neu hinzugefuegt: {stats['new_added']} Datei(en)")
if stats["existing_added"] > 0:
summary_lines.append(f"Vorhandene zugeordnet: {stats['existing_added']} Datei(en)")
if stats["already_assigned"] > 0:
summary_lines.append(f"Bereits zugeordnet: {stats['already_assigned']} Datei(en)")
if stats["cancelled"] > 0:
summary_lines.append(f"Abgebrochen: {stats['cancelled']} Datei(en)")
if stats["renamed_files"]:
summary_lines.append("\nUmbenannte Dateien:")
for renamed in stats["renamed_files"]:
summary_lines.append(f" - {renamed}")
if stats["errors"] > 0:
summary_lines.append(f"\nFehler: {stats['errors']}")
for error_msg in stats["error_messages"][:5]: # Zeige max. 5 Fehler
summary_lines.append(f" - {error_msg}")
if len(stats["error_messages"]) > 5:
summary_lines.append(f" ... und {len(stats['error_messages']) - 5} weitere Fehler")
summary_text = "\n".join(summary_lines)
# Wähle Icon basierend auf Erfolg
if stats["errors"] > 0:
QMessageBox.warning(self, "Verarbeitung mit Fehlern abgeschlossen", summary_text)
elif stats["cancelled"] > 0:
QMessageBox.information(self, "Verarbeitung abgebrochen", summary_text)
else:
QMessageBox.information(self, "Verarbeitung erfolgreich", summary_text)
+655
View File
@@ -0,0 +1,655 @@
"""
HashCalculationMixin - Mixin für Hash-Berechnung und XML-Dateiverwaltung.
Dieses Mixin enthält alle Methoden zur blake2b-Hash-Berechnung,
XML-Datei-Zuordnung und Duplikatserkennung für das MainWindow.
"""
import time
import hashlib
import shutil
import logging
from pathlib import Path
from typing import List
from PySide6.QtWidgets import QMessageBox
from conf import TreeNode, XslFile, XmlFile
from ui.XmlToXslAssignDialog import XmlToXslAssignDialog
from ui.threads import XmlHashCalculatorThread
logger = logging.getLogger(__name__)
class HashCalculationMixin:
"""
Mixin für Hash-Berechnung und XML-Dateiverwaltung.
Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute:
- self.project: Das aktuelle Projekt
- self.pdf_project: Die Projekt-Daten (ProjectData)
- self.hash_calculator_thread: Thread für Hash-Berechnung
- self._save_project_settings(): Methode zum Speichern der Projekt-Einstellungen
- self._load_nodes_to_tree(): Methode zum Laden der Nodes in den Tree
"""
def _handle_xml_file_drop(self, xml_file_path: Path):
"""
Verarbeitet eine einzelne XML-Datei, die per Drag&Drop hinzugefügt wurde.
DEPRECATED: Diese Methode wird durch _handle_multiple_xml_files_drop ersetzt.
Args:
xml_file_path: Pfad zur XML-Datei
"""
try:
logger.debug(f"Verarbeite XML-Datei: {xml_file_path}")
# Prüfe ob die Datei existiert
if not xml_file_path.exists():
QMessageBox.critical(self, "Fehler", f"Die XML-Datei existiert nicht:\n{xml_file_path}")
return
# Prüfe ob Projekt-Nodes verfügbar sind
if not self.pdf_project or not self.pdf_project.nodes:
QMessageBox.warning(self, "Warnung", "Keine Projekt-Nodes verfügbar für die Zuordnung.")
return
# Öffne den Dialog zur Zuordnung zu XSL-Knoten
dialog = XmlToXslAssignDialog(
parent=self, xml_file_path=xml_file_path, project_nodes=self.pdf_project.nodes
)
if dialog.exec() == XmlToXslAssignDialog.DialogCode.Accepted:
# Hole die ausgewählten XSL-Knoten
selected_xsl_nodes = dialog.get_selected_xsl_nodes()
if selected_xsl_nodes:
# Verarbeite die Zuordnung
self._assign_xml_to_xsl_nodes(xml_file_path, selected_xsl_nodes)
else:
logger.warning("Keine XSL-Knoten ausgewählt")
else:
logger.debug("Dialog abgebrochen")
except Exception as e:
error_msg = f"Fehler beim Verarbeiten der XML-Datei '{xml_file_path}': {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
def _assign_xml_to_xsl_nodes(self, xml_file_path: Path, selected_xsl_nodes: list):
"""
Ordnet eine XML-Datei den ausgewählten XSL-Knoten zu.
Implementiert Hash-basierte Duplikatserkennung und intelligente Dateinamen-Verwaltung.
Args:
xml_file_path: Pfad zur XML-Datei
selected_xsl_nodes: Liste der ausgewählten XSL-Knoten
Returns:
dict: Statistiken über die Verarbeitung
"""
try:
logger.info(f"Ordne XML-Datei '{xml_file_path.name}' zu {len(selected_xsl_nodes)} XSL-Knoten zu")
# 1. Hash für die neue XML-Datei berechnen
file_hash = self._calculate_hash_for_file(xml_file_path)
if not file_hash:
logger.warning(f"Hash-Berechnung für {xml_file_path} fehlgeschlagen")
# 2. Prüfe ob eine XML-Datei mit gleichem Hash bereits im Projekt existiert
existing_xml = self._find_xml_file_by_hash(file_hash) if file_hash else None
if existing_xml:
# 3. Hash-Match gefunden: Ordne vorhandene XML-Datei zu
logger.info(f"Hash-Duplikat gefunden: {existing_xml.xml} hat gleichen Hash wie {xml_file_path.name}")
return self._assign_existing_xml_to_nodes(existing_xml, selected_xsl_nodes)
else:
# 4. Kein Hash-Match: Verarbeite als neue XML-Datei
logger.info(f"Keine Hash-Duplikate gefunden für {xml_file_path.name}, verarbeite als neue Datei")
return self._process_new_xml_file(xml_file_path, selected_xsl_nodes, file_hash)
except Exception as e:
error_msg = f"Fehler beim Zuordnen der XML-Datei: {str(e)}"
logger.error(error_msg)
return {"status": "error", "error_msg": error_msg}
def _start_xml_hash_calculation(self):
"""
Startet die asynchrone Hash-Berechnung für alle XML-Dateien im Projekt.
"""
try:
if not hasattr(self, "pdf_project") or not self.pdf_project:
logger.debug("Keine Projekt-Einstellungen verfügbar für Hash-Berechnung")
return
# Sammle alle XML-Dateien aus dem Projekt
xml_files = self._collect_all_xml_files()
if not xml_files:
logger.debug("Keine XML-Dateien für Hash-Berechnung gefunden")
return
logger.info(f"Starte Hash-Berechnung für {len(xml_files)} XML-Dateien")
# Prüfe ob Projekt verfügbar ist
if not self.project or not self.project.project_dir:
logger.warning("Kein Projekt-Verzeichnis für Hash-Berechnung verfügbar")
return
# Stoppe vorherigen Thread falls noch aktiv
if self.hash_calculator_thread and self.hash_calculator_thread.isRunning():
self.hash_calculator_thread.quit()
self.hash_calculator_thread.wait()
# Erstelle und starte neuen Hash-Berechnungs-Thread
self.hash_calculator_thread = XmlHashCalculatorThread(
project_dir=Path(self.project.project_dir), xml_files=xml_files
)
# Verbinde Signale
self.hash_calculator_thread.hash_calculated.connect(self._on_hash_calculated)
self.hash_calculator_thread.calculation_finished.connect(self._on_hash_calculation_finished)
self.hash_calculator_thread.error_occurred.connect(self._on_hash_calculation_error)
# Starte Thread
self.hash_calculator_thread.start()
except Exception as e:
logger.error(f"Fehler beim Starten der Hash-Berechnung: {e}")
def _collect_all_xml_files(self) -> List[XmlFile]:
"""
Sammelt alle XmlFile-Objekte aus der Projektstruktur.
Returns:
List[XmlFile]: Liste aller gefundenen XML-Dateien
"""
xml_files = []
try:
if self.pdf_project and self.pdf_project.nodes:
self._collect_xml_files_recursive(self.pdf_project.nodes, xml_files)
logger.debug(f"Gesammelt: {len(xml_files)} XML-Dateien")
return xml_files
except Exception as e:
logger.error(f"Fehler beim Sammeln der XML-Dateien: {e}")
return []
def _collect_xml_files_recursive(self, nodes, xml_files: List[XmlFile]):
"""
Sammelt rekursiv alle XML-Dateien aus den Nodes.
Args:
nodes: Liste der zu durchsuchenden Nodes
xml_files: Liste zum Sammeln der XML-Dateien
"""
for node in nodes:
if isinstance(node, XslFile) and node.xmls:
# Füge alle XML-Dateien dieser XSL-Datei hinzu
for xml_file in node.xmls:
if xml_file not in xml_files: # Vermeide Duplikate
xml_files.append(xml_file)
elif isinstance(node, TreeNode) and node.children:
# Rekursiv in Kinder-Nodes suchen
self._collect_xml_files_recursive(node.children, xml_files)
def _on_hash_calculated(self, xml_file: XmlFile, hash_value: str):
"""
Wird aufgerufen, wenn ein Hash-Wert berechnet wurde.
Args:
xml_file: Das XmlFile-Objekt
hash_value: Der berechnete Hash-Wert mit Präfix
"""
try:
# Setze den Hash-Wert
xml_file.hashsum = hash_value
logger.debug(f"Hash gesetzt für {xml_file.xml}: {hash_value}")
except Exception as e:
logger.error(f"Fehler beim Setzen des Hash-Werts: {e}")
def _on_hash_calculation_finished(self, processed_count: int, total_count: int):
"""
Wird aufgerufen, wenn die Hash-Berechnung abgeschlossen ist.
Args:
processed_count: Anzahl der verarbeiteten Dateien
total_count: Gesamtanzahl der Dateien
"""
try:
logger.info(f"Hash-Berechnung abgeschlossen: {processed_count}/{total_count} Dateien verarbeitet")
# Speichere die aktualisierten Projekt-Einstellungen
if processed_count > 0:
self._save_project_settings()
logger.info("Projekt-Einstellungen mit neuen Hash-Werten gespeichert")
except Exception as e:
logger.error(f"Fehler beim Abschließen der Hash-Berechnung: {e}")
def _on_hash_calculation_error(self, xml_file_path: str, error_message: str):
"""
Wird aufgerufen, wenn ein Fehler bei der Hash-Berechnung auftritt.
Args:
xml_file_path: Pfad zur XML-Datei
error_message: Fehlermeldung
"""
logger.warning(f"Hash-Berechnungsfehler für {xml_file_path}: {error_message}")
def _calculate_hash_for_xml_file(self, xml_file: XmlFile):
"""
Berechnet synchron den Hash für eine einzelne XML-Datei.
Wird verwendet beim Hinzufügen neuer XML-Dateien.
Args:
xml_file: Das XmlFile-Objekt
"""
try:
if xml_file.hashsum:
logger.debug(f"Hash bereits vorhanden für {xml_file.xml}: {xml_file.hashsum}")
return
# Prüfe ob Projekt verfügbar ist
if not self.project or not self.project.project_dir:
logger.warning("Kein Projekt-Verzeichnis für Hash-Berechnung verfügbar")
return
xml_file_path = Path(self.project.project_dir) / xml_file.xml
if not xml_file_path.exists():
logger.warning(f"XML-Datei nicht gefunden: {xml_file_path}")
return
# Datei binär lesen und Hash berechnen
with open(xml_file_path, "rb") as f:
file_content = f.read()
hash_obj = hashlib.blake2b(file_content)
hash_hex = hash_obj.hexdigest()
# Hash mit Präfix setzen
xml_file.hashsum = f"blake2b:{hash_hex}"
logger.debug(f"Hash berechnet für {xml_file.xml}: {xml_file.hashsum}")
except Exception as e:
logger.error(f"Fehler beim Berechnen des Hash für {xml_file.xml}: {e}")
def _get_all_project_xml_files(self) -> List[XmlFile]:
"""
Sammelt alle XmlFile-Objekte aus dem gesamten Projekt für Hash-Vergleiche.
Returns:
List[XmlFile]: Liste aller XML-Dateien im Projekt
"""
xml_files = []
try:
if self.pdf_project and self.pdf_project.nodes:
self._collect_xml_files_for_hash_comparison(self.pdf_project.nodes, xml_files)
logger.debug(f"Hash-Vergleich: {len(xml_files)} XML-Dateien im Projekt gefunden")
return xml_files
except Exception as e:
logger.error(f"Fehler beim Sammeln der XML-Dateien für Hash-Vergleich: {e}")
return []
def _collect_xml_files_for_hash_comparison(self, nodes, xml_files: List[XmlFile]):
"""
Sammelt rekursiv alle XML-Dateien aus den Nodes für Hash-Vergleiche.
Args:
nodes: Liste der zu durchsuchenden Nodes
xml_files: Liste zum Sammeln der XML-Dateien
"""
for node in nodes:
if isinstance(node, XslFile) and node.xmls:
# Füge alle XML-Dateien dieser XSL-Datei hinzu
for xml_file in node.xmls:
# Vermeide Duplikate basierend auf Pfad
if not any(existing.xml == xml_file.xml for existing in xml_files):
xml_files.append(xml_file)
elif isinstance(node, TreeNode) and node.children:
# Rekursiv in Kinder-Nodes suchen
self._collect_xml_files_for_hash_comparison(node.children, xml_files)
def _find_xml_file_by_hash(self, target_hash: str) -> XmlFile | None:
"""
Sucht eine XML-Datei mit dem angegebenen Hash im gesamten Projekt.
Args:
target_hash: Der zu suchende Hash-Wert (mit blake2b: Präfix)
Returns:
XmlFile|None: Die gefundene XML-Datei oder None
"""
try:
if not target_hash:
return None
all_xml_files = self._get_all_project_xml_files()
for xml_file in all_xml_files:
if xml_file.hashsum == target_hash:
logger.debug(f"Hash-Match gefunden: {xml_file.xml} hat Hash {target_hash}")
return xml_file
logger.debug(f"Kein Hash-Match für {target_hash} gefunden")
return None
except Exception as e:
logger.error(f"Fehler bei Hash-Suche für {target_hash}: {e}")
return None
def _generate_alternative_filename(self, original_path: Path, xml_dir: Path) -> Path:
"""
Generiert alternative Dateinamen im Format: datei_1.xml, datei_2.xml, ...
Args:
original_path: Ursprünglicher Dateipfad
xml_dir: Ziel-XML-Verzeichnis
Returns:
Path: Pfad mit alternativem Dateinamen
"""
try:
base_name = original_path.stem # "datei"
extension = original_path.suffix # ".xml"
# Sammle einmalig alle verwendeten Dateinamen (Performance-Optimierung)
all_xml_files = self._get_all_project_xml_files()
used_names = {xml_file.xml.name for xml_file in all_xml_files}
counter = 1
while True:
new_name = f"{base_name}_{counter}{extension}"
new_path = xml_dir / new_name
# Prüfe sowohl physische Existenz als auch Verwendung im Projekt (optimierter Set-Lookup)
if not new_path.exists() and new_name not in used_names:
logger.debug(f"Alternativer Dateiname generiert: {new_name}")
return new_path
counter += 1
# Sicherheitsgrenze um Endlosschleifen zu vermeiden
if counter > 1000:
raise Exception("Zu viele alternative Dateinamen generiert")
except Exception as e:
logger.error(f"Fehler beim Generieren alternativer Dateinamen für {original_path}: {e}")
# Fallback: Zeitstempel verwenden
timestamp = int(time.time())
fallback_name = f"{original_path.stem}_{timestamp}{original_path.suffix}"
return xml_dir / fallback_name
def _is_filename_used_in_project(self, relative_xml_path: Path) -> bool:
"""
Prüft ob ein relativer XML-Dateipfad bereits im Projekt verwendet wird.
Args:
relative_xml_path: Relativer Pfad zur XML-Datei (z.B. xml/datei_1.xml)
Returns:
bool: True wenn der Dateiname bereits verwendet wird
"""
try:
all_xml_files = self._get_all_project_xml_files()
for xml_file in all_xml_files:
if xml_file.xml == relative_xml_path:
return True
return False
except Exception as e:
logger.error(f"Fehler beim Prüfen der Dateiname-Verwendung für {relative_xml_path}: {e}")
return True # Im Zweifelsfall annehmen, dass der Name verwendet wird
def _calculate_hash_for_file(self, file_path: Path) -> str | None:
"""
Berechnet synchron den blake2b-Hash für eine Datei.
Args:
file_path: Pfad zur Datei
Returns:
str|None: Hash-Wert mit blake2b: Präfix oder None bei Fehler
"""
try:
if not file_path.exists():
logger.warning(f"Datei für Hash-Berechnung nicht gefunden: {file_path}")
return None
# Datei binär lesen und Hash berechnen
with open(file_path, "rb") as f:
file_content = f.read()
hash_obj = hashlib.blake2b(file_content)
hash_hex = hash_obj.hexdigest()
# Hash mit Präfix zurückgeben
hash_value = f"blake2b:{hash_hex}"
logger.debug(f"Hash berechnet für {file_path}: {hash_value}")
return hash_value
except Exception as e:
logger.error(f"Fehler beim Berechnen des Hash für {file_path}: {e}")
return None
def _assign_existing_xml_to_nodes(self, existing_xml: XmlFile, selected_xsl_nodes: list):
"""
Ordnet eine bereits vorhandene XML-Datei (basierend auf Hash-Match) den XSL-Knoten zu.
Args:
existing_xml: Die bereits vorhandene XML-Datei
selected_xsl_nodes: Liste der ausgewählten XSL-Knoten
Returns:
dict: Statistiken mit 'status', 'added_count', 'existing_file'
"""
try:
added_count = 0
for xsl_node in selected_xsl_nodes:
# Prüfe ob diese XML-Datei bereits in der XslFile-Node vorhanden ist
already_assigned = any(xml_file.xml == existing_xml.xml for xml_file in xsl_node.xmls)
if not already_assigned:
# Erstelle neue XmlFile-Referenz mit gleichem Pfad und Hash
new_xml_ref = XmlFile(xml=existing_xml.xml, hashsum=existing_xml.hashsum)
xsl_node.xmls.append(new_xml_ref)
added_count += 1
logger.info(f"Vorhandene XML-Datei '{existing_xml.xml}' zu XSL-Node '{xsl_node.bez}' zugeordnet")
else:
logger.debug(f"XML-Datei '{existing_xml.xml}' bereits in XSL-Node '{xsl_node.bez}' vorhanden")
if added_count > 0:
# Speichere die aktualisierten Projekt-Einstellungen
self._save_project_settings()
# Aktualisiere das TreeWidget
self._load_nodes_to_tree()
return {
"status": "existing_added",
"added_count": added_count,
"existing_file": existing_xml.xml.name,
}
else:
return {"status": "already_assigned", "added_count": 0, "existing_file": existing_xml.xml.name}
except Exception as e:
error_msg = f"Fehler beim Zuordnen der vorhandenen XML-Datei: {str(e)}"
logger.error(error_msg)
return {"status": "error", "error_msg": error_msg}
def _process_new_xml_file(self, xml_file_path: Path, selected_xsl_nodes: list, file_hash: str | None):
"""
Verarbeitet eine neue XML-Datei (kein Hash-Match gefunden).
Args:
xml_file_path: Pfad zur neuen XML-Datei
selected_xsl_nodes: Liste der ausgewählten XSL-Knoten
file_hash: Berechneter Hash der Datei
Returns:
dict: Statistiken mit 'status', 'added_count', 'new_file', 'renamed_from'
"""
try:
# Prüfe ob Projekt verfügbar ist
if not self.project or not self.project.project_dir:
logger.error("Kein Projekt-Verzeichnis für neue XML-Datei verfügbar")
return {"status": "error", "error_msg": "Kein Projekt-Verzeichnis verfügbar."}
# Erstelle xml-Ordner im Projekt-Verzeichnis falls er nicht existiert
xml_dir = Path(self.project.project_dir) / "xml"
xml_dir.mkdir(parents=True, exist_ok=True)
# Bestimme den Ziel-Pfad in xml-Ordner
target_xml_path = xml_dir / xml_file_path.name
# Prüfe ob eine Datei mit gleichem Namen bereits existiert
if target_xml_path.exists() or self._is_filename_used_in_project(Path("xml") / xml_file_path.name):
# Generiere alternative Dateinamen
alternative_paths = []
for i in range(1, 6): # Generiere bis zu 5 Alternativen
alt_path = self._generate_alternative_filename(xml_file_path, xml_dir)
if alt_path not in alternative_paths:
alternative_paths.append(alt_path)
# Zeige Dialog zur Auswahl des Dateinamens
selected_path = self._show_filename_selection_dialog(xml_file_path.name, alternative_paths)
if not selected_path:
# Benutzer hat abgebrochen
return {"status": "cancelled", "added_count": 0}
target_xml_path = selected_path
# Kopiere die XML-Datei in den xml-Ordner
shutil.copy2(xml_file_path, target_xml_path)
logger.info(f"XML-Datei kopiert: {xml_file_path} -> {target_xml_path}")
# Erstelle relatives Path zur XML-Datei (relativ zum Projekt-Verzeichnis)
relative_xml_path = Path("xml") / target_xml_path.name
# Füge die XML-Datei zu allen ausgewählten XSL-Knoten hinzu
added_count = 0
for xsl_node in selected_xsl_nodes:
# Prüfe ob diese XML-Datei bereits in der XslFile-Node vorhanden ist
existing_xml = None
for xml_file in xsl_node.xmls:
if xml_file.xml == relative_xml_path:
existing_xml = xml_file
break
if not existing_xml:
# Erstelle neues XmlFile-Objekt mit Hash
new_xml_file = XmlFile(xml=relative_xml_path, hashsum=file_hash)
xsl_node.xmls.append(new_xml_file)
added_count += 1
logger.info(f"XML-Datei '{target_xml_path.name}' zu XSL-Node '{xsl_node.bez}' hinzugefügt")
else:
logger.debug(f"XML-Datei '{target_xml_path.name}' bereits in XSL-Node '{xsl_node.bez}' vorhanden")
if added_count > 0:
# Speichere die aktualisierten Projekt-Einstellungen
self._save_project_settings()
# Aktualisiere das TreeWidget
self._load_nodes_to_tree()
return {
"status": "new_added",
"added_count": added_count,
"new_file": target_xml_path.name,
"renamed_from": xml_file_path.name if target_xml_path.name != xml_file_path.name else None,
}
else:
return {
"status": "already_assigned",
"added_count": 0,
"new_file": target_xml_path.name,
}
except Exception as e:
error_msg = f"Fehler beim Verarbeiten der neuen XML-Datei: {str(e)}"
logger.error(error_msg)
return {"status": "error", "error_msg": error_msg}
def _show_filename_selection_dialog(self, original_name: str, alternative_paths: List[Path]) -> Path | None:
"""
Zeigt einen Dialog zur Auswahl eines alternativen Dateinamens.
Args:
original_name: Ursprünglicher Dateiname
alternative_paths: Liste alternativer Pfade
Returns:
Path|None: Ausgewählter Pfad oder None bei Abbruch
"""
try:
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QRadioButton,
QButtonGroup,
QPushButton,
QHBoxLayout,
)
dialog = QDialog(self)
dialog.setWindowTitle("Dateiname auswählen")
dialog.setModal(True)
dialog.resize(400, 300)
layout = QVBoxLayout(dialog)
# Erklärungstext
info_label = QLabel(
f"Eine Datei mit dem Namen '{original_name}' existiert bereits.\n\n"
"Bitte wählen Sie einen alternativen Dateinamen:"
)
layout.addWidget(info_label)
# Radio-Buttons für alternative Namen
button_group = QButtonGroup(dialog)
radio_buttons = []
for i, alt_path in enumerate(alternative_paths):
radio_button = QRadioButton(alt_path.name)
if i == 0: # Ersten als Standard auswählen
radio_button.setChecked(True)
button_group.addButton(radio_button, i)
radio_buttons.append(radio_button)
layout.addWidget(radio_button)
# Buttons
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
cancel_button = QPushButton("Abbrechen")
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Event-Handler
ok_button.clicked.connect(dialog.accept)
cancel_button.clicked.connect(dialog.reject)
# Dialog anzeigen
if dialog.exec() == QDialog.DialogCode.Accepted:
selected_id = button_group.checkedId()
if 0 <= selected_id < len(alternative_paths):
return alternative_paths[selected_id]
return None
except Exception as e:
logger.error(f"Fehler beim Anzeigen des Dateiname-Auswahl-Dialogs: {e}")
# Fallback: Ersten alternativen Namen verwenden
return alternative_paths[0] if alternative_paths else None
+547
View File
@@ -0,0 +1,547 @@
"""
PdfViewerMixin - Mixin für PDF-Viewer-Operationen.
Dieses Mixin enthält alle Methoden für die PDF-Anzeige und -Vergleich im MainWindow:
- PDF-Rendering und -Anzeige
- Alpha-Blending und Zoom
- Thumbnail-Navigation
- Drag-to-Scroll
- PDF-Dokument-Management
"""
import gc
import logging
import time
from pathlib import Path
from PySide6.QtCore import Qt, QSize, QUrl
from PySide6.QtGui import QCursor, QPixmap, QPainter, QDesktopServices
from PySide6.QtWidgets import QLabel, QMessageBox
from PySide6.QtPdf import QPdfDocument
logger = logging.getLogger(__name__)
class PdfViewerMixin:
"""
Mixin-Klasse für PDF-Viewer-Operationen.
Dieses Mixin erwartet, dass die verwendende Klasse folgende Attribute hat:
- self.ui: UI-Objekt mit Layouts, Slidern, etc.
- self.project: Aktuelles Projekt
- self.pdf_documents: Dict für PDF-Dokumente
- self.current_rendered_pixmaps: Cache für gerenderte Pixmaps
- self.fullsize_label: QLabel für Vollbild-Anzeige
- self.thumbnail_to_page: Dict für Thumbnail-zu-Seite-Mapping
- self.current_zoom: Aktueller Zoom-Faktor
- self.current_page: Aktuelle Seitennummer
- self.current_pdf: Aktueller PDF-Dateiname
- self.is_dragging: Drag-Status
- self.last_drag_position: Letzte Drag-Position
- self.drag_threshold: Mindestbewegung für Drag
- self.scroll_sensitivity: Scroll-Empfindlichkeit
"""
def render_and_display_page(self, pdf_filename, page_num):
"""
Rendert und zeigt eine spezifische Seite in der Vollansicht an.
Cached die gerenderten Pixmaps für bessere Performance.
Args:
pdf_filename: Name der PDF-Datei
page_num: Seitennummer (0-basiert)
"""
logger.debug(f"Rendere Seite {page_num + 1} von {pdf_filename}")
if pdf_filename not in self.pdf_documents:
logger.warning(f"PDF-Dokument {pdf_filename} nicht gefunden")
return
start_time = time.time()
try:
docs = self.pdf_documents[pdf_filename]
# Diff-Seite laden (bestimmt die Abmessungen)
diff_doc = docs["diff"]
page_size = diff_doc.pagePointSize(page_num)
# Hohe Auflösung für Vollbild (entspricht ca. Matrix(2.0, 2.0) in PyMuPDF)
scale_factor = 2.0
render_size = QSize(int(page_size.width() * scale_factor), int(page_size.height() * scale_factor))
# Diff-Seite rendern (immer vorhanden)
diff_image = diff_doc.render(page_num, render_size)
diff_pixmap = QPixmap.fromImage(diff_image)
# Ermittle die Abmessungen für weiße Seiten
diff_width = diff_pixmap.width()
diff_height = diff_pixmap.height()
# Ref-Seite prüfen und rendern oder weiße Seite erstellen
ref_doc = docs["ref"]
if page_num < ref_doc.pageCount():
ref_image = ref_doc.render(page_num, render_size)
ref_pixmap = QPixmap.fromImage(ref_image)
logger.debug(f"Ref-Seite {page_num + 1} gerendert")
else:
# Erstelle weiße Seite mit gleichen Abmessungen wie Diff-Seite
ref_pixmap = QPixmap(diff_width, diff_height)
ref_pixmap.fill(Qt.GlobalColor.white)
logger.debug(f"Weiße Ref-Seite {page_num + 1} erstellt")
# New-Seite prüfen und rendern oder weiße Seite erstellen
new_doc = docs["new"]
if page_num < new_doc.pageCount():
new_image = new_doc.render(page_num, render_size)
new_pixmap = QPixmap.fromImage(new_image)
logger.debug(f"New-Seite {page_num + 1} gerendert")
else:
# Erstelle weiße Seite mit gleichen Abmessungen wie Diff-Seite
new_pixmap = QPixmap(diff_width, diff_height)
new_pixmap.fill(Qt.GlobalColor.white)
logger.debug(f"Weiße New-Seite {page_num + 1} erstellt")
# Cache die gerenderten Pixmaps für schnelle Alpha/Zoom-Operationen
self.current_rendered_pixmaps = {"ref": ref_pixmap, "diff": diff_pixmap, "new": new_pixmap}
# Aktualisiere aktuelle Seite
self.current_page = page_num
self.current_pdf = pdf_filename
# Zeige das Bild mit aktuellem Alpha- und Zoom-Wert an
self.update_current_display()
render_time = time.time() - start_time
logger.debug(f"Performance: Seite {page_num + 1} gerendert in {render_time:.3f}s")
except Exception as e:
logger.error(f"Fehler beim Rendern der Seite {page_num + 1}: {e}", exc_info=True)
def update_current_display(self):
"""
Aktualisiert die Anzeige der aktuellen Seite basierend auf gecachten Pixmaps.
Verwendet für Alpha- und Zoom-Änderungen ohne erneutes PDF-Rendering.
"""
if not self.current_rendered_pixmaps:
logger.warning("Keine gerenderten Pixmaps verfügbar")
return
if self.fullsize_label is None:
logger.warning("Fullsize-Label ist nicht verfügbar")
return
try:
# Hole die gecachten Pixmaps
ref_pixmap = self.current_rendered_pixmaps["ref"]
diff_pixmap = self.current_rendered_pixmaps["diff"]
new_pixmap = self.current_rendered_pixmaps["new"]
# Erstelle das überlagerte Bild mit aktuellem Alpha-Wert
alpha_value = self.ui.alpha.value()
layered_pixmap = self.create_layered_pixmap(ref_pixmap, diff_pixmap, new_pixmap, alpha_value)
# Wende aktuellen Zoom an
zoom_factor = self.current_zoom / 100.0
if zoom_factor != 1.0:
new_width = int(layered_pixmap.width() * zoom_factor)
layered_pixmap = layered_pixmap.scaledToWidth(new_width, Qt.TransformationMode.SmoothTransformation)
# Setze das überlagerte Bild
self.fullsize_label.setPixmap(layered_pixmap)
self.fullsize_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
except RuntimeError as e:
# C++-Objekt wurde bereits gelöscht
logger.warning(f"Fullsize-Label wurde bereits gelöscht: {e}")
self.fullsize_label = None
def create_layered_pixmap(self, ref_pixmap, diff_pixmap, new_pixmap, alpha_value):
"""
Erstellt ein übergelagertes Pixmap basierend auf dem Alpha-Wert.
Args:
ref_pixmap: Unterste Ebene (ref)
diff_pixmap: Mittlere Ebene (diff)
new_pixmap: Oberste Ebene (new)
alpha_value: Alpha-Wert (-100 bis 100)
Returns:
QPixmap: Das überlagerte Bild
"""
# Verwende die Größe des größten Bildes
max_width = max(ref_pixmap.width(), diff_pixmap.width(), new_pixmap.width())
max_height = max(ref_pixmap.height(), diff_pixmap.height(), new_pixmap.height())
# Erstelle ein leeres Pixmap für das Ergebnis
result = QPixmap(max_width, max_height)
result.fill(Qt.GlobalColor.white)
painter = QPainter(result)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
if alpha_value <= 0:
# Alpha von -100 bis 0: Übergang von ref zu diff
ref_opacity = abs(alpha_value) / 100
diff_opacity = 1.0 - abs(alpha_value) / 100.0
new_opacity = 0.0
else:
ref_opacity = 0.0
diff_opacity = 1.0 - alpha_value / 100.0
new_opacity = alpha_value / 100.0
# Zeichne die Ebenen mit entsprechender Transparenz
if ref_opacity > 0:
painter.setOpacity(ref_opacity)
painter.drawPixmap(0, 0, ref_pixmap)
if diff_opacity > 0:
painter.setOpacity(diff_opacity)
painter.drawPixmap(0, 0, diff_pixmap)
if new_opacity > 0:
painter.setOpacity(new_opacity)
painter.drawPixmap(0, 0, new_pixmap)
painter.end()
return result
def _clear_layout(self, layout):
"""Entfernt alle Widgets aus einem Layout."""
if layout is not None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
def on_alpha_changed(self, alpha_value):
"""
Wird ausgeführt, wenn der Alpha-Slider geändert wird.
Optimiert: Verwendet gecachte Pixmaps statt erneutes PDF-Rendering.
Args:
alpha_value: Der neue Alpha-Wert (-100 bis 100)
"""
logger.debug(f"Alpha geändert auf {alpha_value}")
start_time = time.time()
# Verwende gecachte Pixmaps für schnelle Alpha-Änderungen
self.update_current_display()
alpha_time = time.time() - start_time
logger.debug(f"Alpha-Update in {alpha_time:.6f}s")
def on_thumbnail_clicked(self, event, thumbnail):
"""
Wird ausgeführt, wenn ein Thumbnail angeklickt wird.
Args:
event: Das Maus-Event
thumbnail: Das geklickte Thumbnail-Label
"""
page_info = self.thumbnail_to_page.get(thumbnail)
if page_info:
pdf_filename = page_info["pdf_filename"]
page_num = page_info["page_num"]
logger.debug(f"Thumbnail für Seite {page_num + 1} von {pdf_filename} wurde angeklickt")
# Rendere und zeige die gewählte Seite an
self.render_and_display_page(pdf_filename, page_num)
def apply_zoom(self, zoom_value):
"""
Wendet den Zoom-Faktor auf das aktuelle Bild an.
Optimiert: Verwendet gecachte Pixmaps statt erneutes PDF-Rendering.
Args:
zoom_value: Der neue Zoom-Wert (in Prozent)
"""
self.current_zoom = zoom_value
logger.debug(f"Zoom geändert auf {zoom_value}%")
# Verwende gecachte Pixmaps für schnelle Zoom-Änderungen
self.update_current_display()
def on_fullsize_mouse_press(self, event, fullsize_label):
"""Wird ausgeführt, wenn die Maustaste auf einem großen Bild gedrückt wird."""
if event.button() == Qt.MouseButton.LeftButton:
self.is_dragging = True
self.last_drag_position = event.globalPosition().toPoint()
fullsize_label.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
def on_fullsize_mouse_move(self, event, fullsize_label):
"""Wird ausgeführt, wenn die Maus über einem großen Bild bewegt wird."""
if self.is_dragging and self.last_drag_position is not None:
current_pos = event.globalPosition().toPoint()
delta = current_pos - self.last_drag_position
if abs(delta.x()) >= self.drag_threshold or abs(delta.y()) >= self.drag_threshold:
v_scrollbar = self.ui.scrollArea_2.verticalScrollBar()
h_scrollbar = self.ui.scrollArea_2.horizontalScrollBar()
scroll_delta_y = int(-delta.y() * self.scroll_sensitivity)
scroll_delta_x = int(-delta.x() * self.scroll_sensitivity)
new_v_value = v_scrollbar.value() + scroll_delta_y
new_h_value = h_scrollbar.value() + scroll_delta_x
v_scrollbar.setValue(new_v_value)
h_scrollbar.setValue(new_h_value)
self.last_drag_position = current_pos
def on_fullsize_mouse_release(self, event, fullsize_label):
"""Wird ausgeführt, wenn die Maustaste auf einem großen Bild losgelassen wird."""
if event.button() == Qt.MouseButton.LeftButton:
self.is_dragging = False
self.last_drag_position = None
fullsize_label.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
def _load_pdf_for_comparison(self, xml_file_path: Path, xsl_id_str: str):
"""
Lädt die PDFs (diff, ref, new) einer Transformation in den Vergleichs-Viewer.
Args:
xml_file_path: Pfad zur XML-Datei (relativ)
xsl_id_str: XSL-ID als String (z.B. "2002_1_128")
"""
try:
if not self.project:
QMessageBox.warning(self, "Fehler", "Kein Projekt geöffnet")
return
# Ermittle PDF-Dateinamen basierend auf XML und XSL-ID
xml_stem = xml_file_path.stem
pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf"
# Pfade zu den drei PDFs
diff_dir = self.project.project_dir / "diff"
ref_dir = self.project.project_dir / "ref"
new_dir = self.project.project_dir / "new"
diff_pdf_path = diff_dir / pdf_basename
ref_pdf_path = ref_dir / pdf_basename
new_pdf_path = new_dir / pdf_basename
# Prüfe ob PDFs existieren
if not diff_pdf_path.exists():
QMessageBox.information(self, "Keine Diff-PDF", f"Diff-PDF nicht gefunden:\n{pdf_basename}")
return
if not ref_pdf_path.exists() or not new_pdf_path.exists():
QMessageBox.warning(
self,
"Fehlende PDFs",
f"Ref-PDF oder New-PDF nicht gefunden:\n{pdf_basename}\n\nNur Diff-PDF vorhanden.",
)
return
logger.info(f"Lade PDFs für Vergleich: {pdf_basename}")
# Entferne bestehende Widgets aus den Layouts
self._clear_layout(self.ui.verticalLayout_2)
self._clear_layout(self.ui.verticalLayout_3)
# Dicts zurücksetzen
self.thumbnail_to_page = {}
self.pdf_documents = {}
self.current_rendered_pixmaps = None
self.fullsize_label = None # Label wurde durch _clear_layout gelöscht
# Alle drei PDF-Dateien öffnen mit QtPdf
diff_doc = QPdfDocument()
ref_doc = QPdfDocument()
new_doc = QPdfDocument()
# PDF-Dateien laden
diff_doc.load(str(diff_pdf_path))
ref_doc.load(str(ref_pdf_path))
new_doc.load(str(new_pdf_path))
# Warten bis PDFs geladen sind
if (
diff_doc.status() != QPdfDocument.Status.Ready
or ref_doc.status() != QPdfDocument.Status.Ready
or new_doc.status() != QPdfDocument.Status.Ready
):
QMessageBox.critical(self, "Fehler", f"Fehler beim Laden der PDFs:\n{pdf_basename}")
return
# PDF-Dokumente speichern
self.pdf_documents[pdf_basename] = {"diff": diff_doc, "ref": ref_doc, "new": new_doc}
# PDF-Pfade für System-Viewer speichern
self.current_ref_pdf_path = ref_pdf_path
self.current_new_pdf_path = new_pdf_path
# Buttons zum Öffnen der PDFs im System-Viewer aktivieren
self.ui.view_ref_pdf.setEnabled(True)
self.ui.view_new_pdf.setEnabled(True)
# Slider aktivieren
self.ui.alpha.setEnabled(True)
self.ui.zoom.setEnabled(True)
logger.info(f"PDFs geladen: {pdf_basename}")
logger.info(f" diff: {diff_doc.pageCount()} Seiten")
logger.info(f" ref: {ref_doc.pageCount()} Seiten")
logger.info(f" new: {new_doc.pageCount()} Seiten")
# Nehme die Seitenzahl der diff-PDF als Basis
max_pages = diff_doc.pageCount()
# Erstelle Thumbnails für alle Seiten
for page_num in range(max_pages):
# Nur diff-Seite für Thumbnail rendern
page_size = diff_doc.pagePointSize(page_num)
# Skalierung für Thumbnail
scale_factor = 200.0 / page_size.width() # 200 Pixel Breite
# Seite rendern
page_image = diff_doc.render(
page_num,
QSize(int(page_size.width() * scale_factor), int(page_size.height() * scale_factor)),
)
diff_pixmap = QPixmap.fromImage(page_image)
# Thumbnail erstellen und zur linken Spalte hinzufügen
thumbnail = QLabel()
thumbnail.setObjectName(f"thumbnail_{pdf_basename}_page_{page_num + 1}")
thumbnail.setPixmap(diff_pixmap.scaledToWidth(200, Qt.TransformationMode.SmoothTransformation))
thumbnail.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
thumbnail.setMouseTracking(True)
self.ui.verticalLayout_2.addWidget(thumbnail)
# Seitennummer für Thumbnail anzeigen
thumbnail_info = QLabel(f"Seite {page_num + 1}")
thumbnail_info.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.ui.verticalLayout_2.addWidget(thumbnail_info)
# Beziehung zwischen Thumbnail und Seitennummer speichern
self.thumbnail_to_page[thumbnail] = {"pdf_filename": pdf_basename, "page_num": page_num}
# Click-Event für das Thumbnail einrichten
thumbnail.mousePressEvent = lambda event, t=thumbnail: self.on_thumbnail_clicked(event, t)
# Erstelle das Vollbild-Label für die rechte Spalte (falls noch nicht vorhanden)
if self.fullsize_label is None:
self.fullsize_label = QLabel()
self.fullsize_label.setObjectName("fullsize_current_page")
self.fullsize_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.fullsize_label.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
self.ui.verticalLayout_3.addWidget(self.fullsize_label)
# Drag-to-Scroll Events für das große Bild einrichten
self.fullsize_label.mousePressEvent = lambda event: self.on_fullsize_mouse_press(
event, self.fullsize_label
)
self.fullsize_label.mouseMoveEvent = lambda event: self.on_fullsize_mouse_move(
event, self.fullsize_label
)
self.fullsize_label.mouseReleaseEvent = lambda event: self.on_fullsize_mouse_release(
event, self.fullsize_label
)
# Setze die aktuelle PDF
self.current_pdf = pdf_basename
# Speichere Diff-PDF-Informationen für Accept Changes
self.current_diff_xml_path = xml_file_path
self.current_diff_xsl_id = xsl_id_str
# Aktiviere Accept-Changes-Button
self.ui.accept_changes.setEnabled(True)
# Zeige die erste Seite initial an
self.render_and_display_page(pdf_basename, 0)
logger.info(f"PDF-Vergleich geladen: {pdf_basename}")
except Exception as e:
logger.error(f"Fehler beim Laden der PDFs für Vergleich: {e}")
QMessageBox.critical(self, "Fehler", f"Konnte PDFs nicht laden:\n{str(e)}")
def _close_all_pdf_documents(self):
"""Schließt alle geöffneten PDF-Dokumente explizit (wichtig für Windows)."""
if self.pdf_documents:
for pdf_basename, docs in self.pdf_documents.items():
for doc_type, doc in docs.items():
if doc:
doc.close()
logger.debug(f"PDF-Dokument geschlossen: {pdf_basename} ({doc_type})")
# Lösche alle Referenzen
self.pdf_documents.clear()
# Lösche gerenderte Pixmaps
self.current_rendered_pixmaps = None
# Erzwinge Garbage Collection um Dateihandles freizugeben (wichtig für Windows)
gc.collect()
logger.info("Alle PDF-Dokumente geschlossen und Referenzen freigegeben")
def _clear_pdf_viewer(self):
"""Leert den PDF-Viewer und alle Thumbnails."""
# Schließe alle PDF-Dokumente explizit (wichtig für Windows)
self._close_all_pdf_documents()
# Entferne Widgets aus Layouts
self._clear_layout(self.ui.verticalLayout_2)
self._clear_layout(self.ui.verticalLayout_3)
# Zurücksetzen der Datenstrukturen
self.thumbnail_to_page = {}
self.pdf_documents = {}
self.current_rendered_pixmaps = None
self.fullsize_label = None
self.current_pdf = None
self.current_diff_xml_path = None
self.current_diff_xsl_id = None
# PDF-Pfade zurücksetzen und Buttons deaktivieren
self.current_ref_pdf_path = None
self.current_new_pdf_path = None
self.ui.view_ref_pdf.setEnabled(False)
self.ui.view_new_pdf.setEnabled(False)
# Slider deaktivieren
self.ui.alpha.setEnabled(False)
self.ui.zoom.setEnabled(False)
logger.info("PDF-Viewer geleert")
def _on_view_ref_pdf_clicked(self):
"""
Handler für view_ref_pdf Button.
Öffnet die Referenz-PDF im systemseitig installierten PDF-Viewer.
"""
if not self.current_ref_pdf_path or not self.current_ref_pdf_path.exists():
QMessageBox.warning(self, "Fehler", "Referenz-PDF nicht gefunden")
logger.warning("Referenz-PDF nicht verfügbar")
return
logger.info(f"Öffne Referenz-PDF im System-Viewer: {self.current_ref_pdf_path}")
url = QUrl.fromLocalFile(str(self.current_ref_pdf_path))
if not QDesktopServices.openUrl(url):
QMessageBox.critical(self, "Fehler", f"Konnte Referenz-PDF nicht öffnen:\n{self.current_ref_pdf_path}")
logger.error(f"Fehler beim Öffnen der Referenz-PDF: {self.current_ref_pdf_path}")
def _on_view_new_pdf_clicked(self):
"""
Handler für view_new_pdf Button.
Öffnet die neue PDF im systemseitig installierten PDF-Viewer.
"""
if not self.current_new_pdf_path or not self.current_new_pdf_path.exists():
QMessageBox.warning(self, "Fehler", "Neue PDF nicht gefunden")
logger.warning("Neue PDF nicht verfügbar")
return
logger.info(f"Öffne neue PDF im System-Viewer: {self.current_new_pdf_path}")
url = QUrl.fromLocalFile(str(self.current_new_pdf_path))
if not QDesktopServices.openUrl(url):
QMessageBox.critical(self, "Fehler", f"Konnte neue PDF nicht öffnen:\n{self.current_new_pdf_path}")
logger.error(f"Fehler beim Öffnen der neuen PDF: {self.current_new_pdf_path}")
+680
View File
@@ -0,0 +1,680 @@
"""
TransformationMixin - Mixin für XSL-Transformationen.
Dieses Mixin enthält alle Methoden zur Durchführung und Verwaltung von
XSL-Transformationen für das MainWindow.
"""
import logging
from copy import deepcopy
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMessageBox, QProgressBar, QTreeWidgetItem
from conf import app_settings, TreeNode, XslFile, XmlFile
from transform import TransformationJob
from ui.threads import TransformationThread
logger = logging.getLogger(__name__)
class TransformationMixin:
"""
Mixin für XSL-Transformationen.
Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute:
- self.project: Das aktuelle Projekt
- self.ui: Das UI-Objekt mit treeWidget, statusBar
- self.transformation_thread: Thread für Transformationen
- self.transformation_progress_bar: QProgressBar für Transformation-Fortschritt
- self.xml_item_map: Mapping von xml_path|xsl_id zu TreeWidgetItems
- self.last_saxon_metrics: Gecachte Saxon-Worker-Pool-Metriken
- self.last_fop_metrics: Gecachte FOP-Worker-Pool-Metriken
Erwartet folgende Methoden von anderen Mixins:
- self._initialize_saxon_worker_pool(): Von WorkerPoolMixin
- self._initialize_fop_worker_pool(): Von WorkerPoolMixin
- self._shutdown_saxon_worker_pool(): Von WorkerPoolMixin
- self._shutdown_fop_worker_pool(): Von WorkerPoolMixin
- self._create_centered_progress_bar(): Von TreeManagerMixin
- self._create_centered_diff_icon(): Von TreeManagerMixin
- self._collect_parent_params(): Von TreeManagerMixin
- self._update_diff_icons_for_existing_pdfs(): Von MainWindow
"""
def _show_transformation_progress_bar(self, total_jobs: int):
"""
Zeigt einen Progressbar in der Statusbar für Transformationen.
Args:
total_jobs: Gesamtanzahl der Transformations-Jobs
"""
if self.transformation_progress_bar is None:
self.transformation_progress_bar = QProgressBar()
self.transformation_progress_bar.setMaximumHeight(20)
self.transformation_progress_bar.setMaximumWidth(300)
self.transformation_progress_bar.setMinimum(0)
self.transformation_progress_bar.setMaximum(total_jobs)
self.transformation_progress_bar.setValue(0)
self.transformation_progress_bar.setFormat("%v/%m Jobs")
# Füge Progressbar zur Statusbar hinzu
self.statusBar().addPermanentWidget(self.transformation_progress_bar)
self.transformation_progress_bar.show()
def _hide_transformation_progress_bar(self):
"""Versteckt und entfernt den Transformation-Progressbar aus der Statusbar."""
if self.transformation_progress_bar:
self.statusBar().removeWidget(self.transformation_progress_bar)
self.transformation_progress_bar.hide()
def _update_transformation_progress(self):
"""Aktualisiert den Transformation-Progressbar um einen Schritt."""
if self.transformation_progress_bar:
current_value = self.transformation_progress_bar.value()
self.transformation_progress_bar.setValue(current_value + 1)
def _transform_xml_file(self, item: QTreeWidgetItem, force: bool = False):
"""
Transformiert eine einzelne XML-Datei.
Args:
item: Das TreeWidgetItem der XML-Datei
force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt
"""
try:
# Hole XslFile vom Parent-Item
parent_item = item.parent()
if not parent_item:
logger.error("XML-Datei hat kein Parent-Item (XslFile)")
QMessageBox.warning(self, "Fehler", "XML-Datei hat keine zugeordnete XSL-Datei")
return
xsl_file_obj = parent_item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(xsl_file_obj, XslFile):
logger.error(f"Parent-Item ist kein XslFile: {type(xsl_file_obj)}")
QMessageBox.warning(self, "Fehler", "Konnte XSL-Datei nicht ermitteln")
return
# Hole XmlFile-Objekt
xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(xml_file_obj, XmlFile):
logger.error(f"Item ist kein XmlFile: {type(xml_file_obj)}")
QMessageBox.warning(self, "Fehler", "Konnte XML-Datei nicht ermitteln")
return
# Erstelle TransformationJob mit TreeWidgetItem-Kontext für Parameter-Sammlung
job = self._create_transformation_job(xsl_file_obj, xml_file_obj, parent_item)
if not job:
return
# Starte Transformation in separatem Thread
self._start_transformation([job], force=force)
except Exception as e:
logger.error(f"Fehler beim Transformieren der XML-Datei: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}")
def _transform_xsl_file(self, item: QTreeWidgetItem, force: bool = False):
"""
Transformiert alle XML-Dateien einer XSL-Datei.
Args:
item: Das TreeWidgetItem der XSL-Datei
force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt
"""
try:
# Hole XslFile-Objekt
xsl_file_obj = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(xsl_file_obj, XslFile):
logger.error(f"Item ist kein XslFile: {type(xsl_file_obj)}")
QMessageBox.warning(self, "Fehler", "Konnte XSL-Datei nicht ermitteln")
return
# Prüfe ob XML-Dateien vorhanden sind
if not xsl_file_obj.xmls:
QMessageBox.information(self, "Info", "Keine XML-Dateien zugeordnet")
return
# Erstelle TransformationJobs für alle XML-Dateien
jobs = []
for xml_file_obj in xsl_file_obj.xmls:
# Übergebe das XslFile-TreeWidgetItem für Parameter-Sammlung
job = self._create_transformation_job(xsl_file_obj, xml_file_obj, item)
if job:
jobs.append(job)
if not jobs:
QMessageBox.warning(self, "Fehler", "Konnte keine Transformations-Jobs erstellen")
return
# Starte Transformation in separatem Thread
self._start_transformation(jobs, force=force)
except Exception as e:
logger.error(f"Fehler beim Transformieren der XSL-Datei: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}")
def _count_diff_pdfs_under_node(self, node: TreeNode | XslFile, node_item: QTreeWidgetItem) -> int:
"""
Zählt die Anzahl der existierenden Diff-PDFs unter einem Knoten.
Args:
node: TreeNode oder XslFile Objekt
node_item: Das TreeWidgetItem des Knotens
Returns:
int: Anzahl der existierenden Diff-PDF-Dateien
"""
count = 0
if isinstance(node, XslFile):
# Für XslFile: Zähle Diff-PDFs für jede XML-Datei
if not self.project:
return 0
diff_dir = self.project.project_dir / "diff"
xsl_id_str = "_".join(str(x) for x in node.id) if node.id else ""
for xml_file_obj in node.xmls:
xml_stem = xml_file_obj.xml.stem
pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf"
diff_pdf_path = diff_dir / pdf_basename
if diff_pdf_path.exists():
count += 1
elif isinstance(node, TreeNode):
# Für TreeNode: Rekursiv alle Kinder durchgehen
for i in range(node_item.childCount()):
child_item = node_item.child(i)
child_node = child_item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(child_node, (XslFile, TreeNode)):
count += self._count_diff_pdfs_under_node(child_node, child_item)
return count
def _update_diff_pdf_counts_recursive(self, tree_item: QTreeWidgetItem):
"""
Aktualisiert rekursiv die Diff-PDF-Anzahl in Spalte 2 für alle TreeNode und XslFile Items.
Args:
tree_item: Das TreeWidgetItem (kann Root oder beliebiger Knoten sein)
"""
node = tree_item.data(0, Qt.ItemDataRole.UserRole)
# Aktualisiere nur für TreeNode und XslFile, nicht für XmlFile
if isinstance(node, (TreeNode, XslFile)):
count = self._count_diff_pdfs_under_node(node, tree_item)
tree_item.setText(2, str(count) if count > 0 else "")
# Rekursiv für alle Kinder
for i in range(tree_item.childCount()):
child_item = tree_item.child(i)
self._update_diff_pdf_counts_recursive(child_item)
def _update_all_diff_pdf_counts(self):
"""
Aktualisiert die Diff-PDF-Anzahl für alle Knoten im TreeWidget.
"""
root = self.ui.treeWidget.invisibleRootItem()
for i in range(root.childCount()):
self._update_diff_pdf_counts_recursive(root.child(i))
def _has_xml_files_recursive(self, node: TreeNode) -> bool:
"""
Prüft rekursiv, ob unter einem TreeNode mindestens eine XML-Datei vorhanden ist.
Args:
node: Der TreeNode
Returns:
bool: True wenn mindestens eine XML-Datei gefunden wurde
"""
if not hasattr(node, "children") or not node.children:
return False
for child in node.children:
if isinstance(child, XslFile):
if child.xmls:
return True
elif isinstance(child, TreeNode):
if self._has_xml_files_recursive(child):
return True
return False
def _collect_all_xsl_xml_pairs_recursive(
self, tree_node: TreeNode, tree_item: QTreeWidgetItem
) -> list[tuple[XslFile, XmlFile, QTreeWidgetItem]]:
"""
Sammelt rekursiv alle (XslFile, XmlFile, XslFileItem) Tupel unter einem TreeNode.
Args:
tree_node: Der TreeNode
tree_item: Das TreeWidgetItem des TreeNode
Returns:
list: Liste von (XslFile, XmlFile, XslFileItem) Tupeln
"""
pairs = []
if not hasattr(tree_node, "children") or not tree_node.children:
return pairs
# Durchlaufe alle Kinder des TreeNode
for i in range(tree_item.childCount()):
child_item = tree_item.child(i)
child_node = child_item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(child_node, XslFile):
# XslFile gefunden - sammle alle XML-Dateien
for xml_file_obj in child_node.xmls:
pairs.append((child_node, xml_file_obj, child_item))
elif isinstance(child_node, TreeNode):
# Rekursiv in Unterknoten suchen
pairs.extend(self._collect_all_xsl_xml_pairs_recursive(child_node, child_item))
return pairs
def _transform_tree_node(self, item: QTreeWidgetItem, force: bool = False):
"""
Transformiert alle XML-Dateien unter einem TreeNode (rekursiv).
Args:
item: Das TreeWidgetItem des TreeNode
force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt
"""
try:
# Hole TreeNode-Objekt
tree_node_obj = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(tree_node_obj, TreeNode):
logger.error(f"Item ist kein TreeNode: {type(tree_node_obj)}")
QMessageBox.warning(self, "Fehler", "Konnte TreeNode nicht ermitteln")
return
# Prüfe ob XML-Dateien vorhanden sind
if not self._has_xml_files_recursive(tree_node_obj):
QMessageBox.information(self, "Info", "Keine XML-Dateien unter diesem Knoten gefunden")
return
# Sammle alle XSL/XML-Paare rekursiv
xsl_xml_pairs = self._collect_all_xsl_xml_pairs_recursive(tree_node_obj, item)
if not xsl_xml_pairs:
QMessageBox.information(self, "Info", "Keine XML-Dateien gefunden")
return
# Erstelle TransformationJobs für alle XML-Dateien
jobs = []
for xsl_file_obj, xml_file_obj, xsl_file_item in xsl_xml_pairs:
# Übergebe das XslFile-TreeWidgetItem für Parameter-Sammlung
job = self._create_transformation_job(xsl_file_obj, xml_file_obj, xsl_file_item)
if job:
jobs.append(job)
if not jobs:
QMessageBox.warning(self, "Fehler", "Konnte keine Transformations-Jobs erstellen")
return
logger.info(f"Starte Transformation für {len(jobs)} XML-Dateien unter TreeNode '{tree_node_obj.bez}'")
# Starte Transformation in separatem Thread
self._start_transformation(jobs, force=force)
except Exception as e:
logger.error(f"Fehler beim Transformieren des TreeNode: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}")
def _create_transformation_job(
self, xsl_file_obj: XslFile, xml_file_obj: XmlFile, xsl_file_item: QTreeWidgetItem | None = None
) -> TransformationJob | None:
"""
Erstellt einen TransformationJob für eine XML/XSL-Kombination.
Args:
xsl_file_obj: Das XslFile-Objekt
xml_file_obj: Das XmlFile-Objekt
xsl_file_item: Optional das TreeWidgetItem des XslFile für hierarchische Parameter-Sammlung
Returns:
TransformationJob oder None bei Fehler
"""
try:
if not self.project:
QMessageBox.warning(self, "Fehler", "Kein Projekt geöffnet")
return None
# Hole Tool-Konfigurationen aus app_settings
java_vm = next((jvm for jvm in app_settings.java_vms if jvm.id == self.project.java_vm_id), None)
saxon_jar = next((sj for sj in app_settings.saxon_jars if sj.id == self.project.saxon_jar_id), None)
apache_fop = next((af for af in app_settings.apache_fops if af.id == self.project.apache_fop_id), None)
diff_pdf = next((dp for dp in app_settings.diff_pdfs if dp.id == self.project.diff_pdf_id), None)
xsl_dir = next((xd for xd in app_settings.xsl_dirs if xd.id == self.project.xsl_dir_id), None)
# Prüfe ob alle Konfigurationen vorhanden sind
if not all([java_vm, saxon_jar, apache_fop, diff_pdf, xsl_dir]):
missing = []
if not java_vm:
missing.append("Java VM")
if not saxon_jar:
missing.append("Saxon JAR")
if not apache_fop:
missing.append("Apache FOP")
if not diff_pdf:
missing.append("diff-pdf")
if not xsl_dir:
missing.append("XSL-Verzeichnis")
QMessageBox.warning(
self, "Fehlende Konfiguration", f"Folgende Konfigurationen fehlen: {', '.join(missing)}"
)
return None
# Zusätzliche Sicherheitsprüfung für path_to_binary_file Attribute
if java_vm is None or not hasattr(java_vm, "path_to_binary_file") or java_vm.path_to_binary_file is None:
QMessageBox.warning(self, "Konfigurationsfehler", "Java VM Pfad ist nicht konfiguriert")
return None
if saxon_jar is None or not hasattr(saxon_jar, "path_to_jar_file") or saxon_jar.path_to_jar_file is None:
QMessageBox.warning(self, "Konfigurationsfehler", "Saxon JAR Pfad ist nicht konfiguriert")
return None
if apache_fop is None or not hasattr(apache_fop, "path_to_dir") or apache_fop.path_to_dir is None:
QMessageBox.warning(self, "Konfigurationsfehler", "Apache FOP Pfad ist nicht konfiguriert")
return None
if diff_pdf is None or not hasattr(diff_pdf, "path_to_binary_file") or diff_pdf.path_to_binary_file is None:
QMessageBox.warning(self, "Konfigurationsfehler", "diff-pdf Pfad ist nicht konfiguriert")
return None
if xsl_dir is None or not hasattr(xsl_dir, "path_to_root_dir") or xsl_dir.path_to_root_dir is None:
QMessageBox.warning(self, "Konfigurationsfehler", "XSL-Verzeichnis Pfad ist nicht konfiguriert")
return None
# Erstelle absoluten Pfad zur XSL-Datei
xsl_file_abs = xsl_dir.path_to_root_dir / xsl_file_obj.xsl_file
# Sammle XSLT-Parameter hierarchisch (TreeNode-Eltern → XslFile)
xslt_params = {}
# 1. Sammle Parameter von übergeordneten TreeNodes (falls TreeWidgetItem verfügbar)
if xsl_file_item is not None:
parent_params = self._collect_parent_params(xsl_file_item)
xslt_params.update(parent_params)
logger.debug(f"Hierarchische Parameter gesammelt: {parent_params}")
else:
logger.warning(
"Kein TreeWidgetItem-Kontext verfügbar - "
"übergeordnete TreeNode-Parameter werden nicht berücksichtigt"
)
# 2. Überschreibe mit XslFile-eigenen Parametern (höchste Priorität)
xslt_params.update(xsl_file_obj.xslt_params)
logger.info(f"Finale XSLT-Parameter für {xml_file_obj.xml} mit {xsl_file_obj.bez}: {xslt_params}")
# Erstelle TransformationJob
job = TransformationJob(
project_dir=self.project.project_dir,
xml_file=xml_file_obj.xml,
xsl_file=xsl_file_abs,
xslt_params=xslt_params,
java_vm_path=java_vm.path_to_binary_file,
saxon_jar_path=saxon_jar.path_to_jar_file,
apache_fop_dir=apache_fop.path_to_dir,
diff_pdf_path=diff_pdf.path_to_binary_file,
diff_pdf_params=diff_pdf.default_params,
xsl_id=xsl_file_obj.id,
fop_config_dir=self.project.fop_config_dir,
)
return job
except Exception as e:
logger.error(f"Fehler beim Erstellen des TransformationJobs: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Erstellen des Jobs: {str(e)}")
return None
def _start_transformation(self, jobs: list[TransformationJob], force: bool = False):
"""
Startet die Transformation in einem separaten Thread.
Args:
jobs: Liste der TransformationJobs
force: Wenn True, werden alle Jobs ausgeführt (ignoriert Up-to-Date)
"""
try:
# Prüfe ob bereits ein Thread läuft
if self.transformation_thread and self.transformation_thread.isRunning():
QMessageBox.warning(self, "Warnung", "Es läuft bereits eine Transformation")
return
# Erstelle und konfiguriere Thread
self.transformation_thread = TransformationThread(jobs, force=force, max_workers=app_settings.max_workers)
# Verbinde Signale
self.transformation_thread.job_started.connect(self._on_transformation_job_started)
self.transformation_thread.job_finished.connect(self._on_transformation_job_finished)
self.transformation_thread.job_error.connect(self._on_transformation_job_error)
self.transformation_thread.all_jobs_finished.connect(self._on_all_transformations_finished)
# Zeige Progressbar
self._show_transformation_progress_bar(len(jobs))
# Initialisiere Worker-Pools (lazy loading - nur wenn benötigt)
self._initialize_saxon_worker_pool()
self._initialize_fop_worker_pool()
# Erfasse RAM-Verbrauch vor Transformation
import transform
if transform._saxon_worker_pool:
transform._saxon_worker_pool.capture_ram_before_transform()
if transform._fop_worker_pool:
transform._fop_worker_pool.capture_ram_before_transform()
# Starte Thread
self.transformation_thread.start()
logger.info(f"Transformation von {len(jobs)} Job(s) gestartet (force={force})")
self.statusBar().showMessage(f"Transformation von {len(jobs)} Job(s) gestartet...")
except Exception as e:
logger.error(f"Fehler beim Starten der Transformation: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Starten: {str(e)}")
def _expand_tree_item_parents(self, item: QTreeWidgetItem):
"""
Öffnet alle Eltern-Knoten eines Tree-Items rekursiv.
Args:
item: Das Tree-Item, dessen Eltern geöffnet werden sollen
"""
if item is None:
return
# Rekursiv alle Eltern öffnen
parent = item.parent()
while parent is not None:
parent.setExpanded(True)
parent = parent.parent()
def _on_transformation_job_started(self, xml_file_name: str, xsl_id_str: str):
"""
Signal-Handler: Ein Job wurde gestartet.
Args:
xml_file_name: Name der XML-Datei
xsl_id_str: XSL-ID als String (z.B. "2002_1_128")
"""
logger.info(f"Transformation gestartet: {xml_file_name} (XSL-ID: {xsl_id_str})")
self.statusBar().showMessage(f"Transformiere: {xml_file_name}")
# Progress Bar anzeigen
map_key = f"{xml_file_name}|{xsl_id_str}"
if map_key not in self.xml_item_map and self.xml_item_map:
# Zeige erste Keys zur Diagnose
list(self.xml_item_map.keys())[:3]
logger.info(f"Suche TreeWidget-Item für: '{map_key}'")
logger.info(f"Map hat {len(self.xml_item_map)} Einträge")
tree_item = self.xml_item_map.get(map_key)
if tree_item:
# Öffne alle Eltern-Knoten, damit der Benutzer den Fortschritt sehen kann
self._expand_tree_item_parents(tree_item)
# Scrolle zum Item, damit es sichtbar ist
self.ui.treeWidget.scrollToItem(tree_item)
# Entferne vorhandenes Widget (falls Icon vorhanden)
self.ui.treeWidget.removeItemWidget(tree_item, 2)
# Erstelle und setze Progress Bar
progress_widget, progress_bar = self._create_centered_progress_bar()
self.ui.treeWidget.setItemWidget(tree_item, 2, progress_widget)
logger.debug(f"Progress Bar für {xml_file_name} gesetzt und Eltern-Knoten geöffnet")
else:
logger.warning(f"Kein TreeWidget-Item für {xml_file_name} gefunden")
def _on_transformation_job_finished(self, result: dict):
"""
Signal-Handler: Ein Job wurde abgeschlossen.
Args:
result: Ergebnis-Dictionary
"""
# Aktualisiere Transformation-Progressbar
self._update_transformation_progress()
xml_file = result.get("xml_file", "?")
success = result.get("success", False)
duration = result.get("duration", 0)
if success:
logger.info(f"Transformation erfolgreich: {xml_file} ({duration:.2f}s)")
pdfs_identical = result.get("pdfs_identical", False)
if pdfs_identical:
self.statusBar().showMessage(f"{xml_file} - PDFs identisch ({duration:.2f}s)", 3000)
else:
self.statusBar().showMessage(f"{xml_file} - Unterschiede gefunden ({duration:.2f}s)", 3000)
else:
logger.error(f"Transformation fehlgeschlagen: {xml_file}")
# Zeige Fehlerdetails an
steps = result.get("steps", {})
error_msgs = []
for step_name, step_info in steps.items():
if not step_info.get("success", True):
error_msgs.append(f"{step_name}: {step_info.get('message', 'Unbekannter Fehler')}")
error_text = "\n".join(error_msgs) if error_msgs else "Unbekannter Fehler"
QMessageBox.critical(
self, "Transformation fehlgeschlagen", f"XML-Datei: {xml_file}\n\nFehler:\n{error_text}"
)
# Update Widget in Spalte 2: Entferne Progress Bar, zeige Icon wenn Diff-PDF existiert
xml_file_str = result.get("xml_file", "")
xsl_id = result.get("xsl_id", None)
xsl_id_str = "_".join(str(x) for x in xsl_id) if xsl_id else ""
map_key = f"{xml_file_str}|{xsl_id_str}"
diff_pdf_str = result.get("diff_pdf", None)
tree_item = self.xml_item_map.get(map_key)
if tree_item:
# Entferne Progress Bar
self.ui.treeWidget.removeItemWidget(tree_item, 2)
# Wenn Diff-PDF existiert, zeige Icon
if diff_pdf_str and Path(diff_pdf_str).exists():
xml_file_path = Path(xml_file_str)
icon_widget = self._create_centered_diff_icon(xml_file_path, xsl_id_str)
self.ui.treeWidget.setItemWidget(tree_item, 2, icon_widget)
logger.debug(f"Diff-Icon für {xml_file_str} gesetzt")
else:
logger.debug(f"Keine Diff-PDF für {xml_file_str}, kein Icon gesetzt")
def _on_transformation_job_error(self, xml_file_name: str, xsl_id_str: str, error_message: str):
"""
Signal-Handler: Ein Job ist mit einem Fehler abgebrochen.
Args:
xml_file_name: Name der XML-Datei
xsl_id_str: XSL-ID als String
error_message: Fehlermeldung
"""
# Aktualisiere Transformation-Progressbar
self._update_transformation_progress()
logger.error(f"Transformation-Fehler bei {xml_file_name} (XSL-ID: {xsl_id_str}): {error_message}")
QMessageBox.critical(self, "Fehler", f"Fehler bei {xml_file_name}:\n{error_message}")
# Entferne Progress Bar bei Fehler
map_key = f"{xml_file_name}|{xsl_id_str}"
tree_item = self.xml_item_map.get(map_key)
if tree_item:
self.ui.treeWidget.removeItemWidget(tree_item, 2)
logger.debug(f"Progress Bar für {map_key} entfernt (Fehler)")
def _on_all_transformations_finished(self, successful_count: int, total_count: int, total_duration: float):
"""
Signal-Handler: Alle Jobs wurden abgeschlossen.
Args:
successful_count: Anzahl erfolgreicher Jobs
total_count: Gesamtanzahl der Jobs
total_duration: Gesamtdauer aller Transformationen in Sekunden
"""
logger.info(
f"Alle Transformationen abgeschlossen: {successful_count}/{total_count} erfolgreich ({total_duration:.2f}s)"
)
# Erfasse RAM-Verbrauch nach Transformation
import transform
if transform._saxon_worker_pool:
transform._saxon_worker_pool.capture_ram_after_transform()
# Speichere Metriken vor Shutdown (für späteren Zugriff im Dialog)
self.last_saxon_metrics = deepcopy(transform._saxon_worker_pool.metrics)
logger.debug("Saxon Worker-Pool Metriken gespeichert")
if transform._fop_worker_pool:
transform._fop_worker_pool.capture_ram_after_transform()
# Speichere Metriken vor Shutdown (für späteren Zugriff im Dialog)
self.last_fop_metrics = deepcopy(transform._fop_worker_pool.metrics)
logger.debug("FOP Worker-Pool Metriken gespeichert")
# Beende Worker-Pools (RAM-Optimierung - Pools werden bei nächster Transformation neu gestartet)
self._shutdown_saxon_worker_pool()
self._shutdown_fop_worker_pool()
# Verstecke Transformation-Progressbar
self._hide_transformation_progress_bar()
# Aktualisiere Diff-PDF-Anzahl und Icons in allen Knoten
self._update_all_diff_pdf_counts()
self._update_diff_icons_for_existing_pdfs()
# Formatiere Dauer für Anzeige
duration_str = f"{total_duration:.2f}s"
if successful_count == total_count:
self.statusBar().showMessage(f"✓ Alle {total_count} Transformationen erfolgreich ({duration_str})", 5000)
QMessageBox.information(
self,
"Abgeschlossen",
f"Alle {total_count} Transformationen wurden erfolgreich abgeschlossen.\n\nGesamtdauer: {duration_str}",
)
else:
failed_count = total_count - successful_count
self.statusBar().showMessage(
f"{successful_count}/{total_count} erfolgreich, {failed_count} fehlgeschlagen ({duration_str})", 5000
)
QMessageBox.warning(
self,
"Abgeschlossen mit Fehlern",
f"{successful_count} von {total_count} Transformationen erfolgreich\n{failed_count} fehlgeschlagen\n\nGesamtdauer: {duration_str}",
)
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
"""
WorkerPoolMixin - Mixin für Worker-Pool-Verwaltung.
Dieses Mixin enthält alle Methoden zur Verwaltung der Saxon- und FOP-Worker-Pools
für das MainWindow.
"""
import logging
from PySide6.QtWidgets import QMessageBox
from conf import app_settings, XsltVersion
from transform import TransformationJob, set_saxon_worker_pool
from saxon_pool import SaxonWorkerPool
from saxon_pool_s9api import SaxonWorkerPoolS9Api
logger = logging.getLogger(__name__)
class WorkerPoolMixin:
"""
Mixin für Worker-Pool-Verwaltung.
Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute:
- self.project: Das aktuelle Projekt
"""
def _initialize_saxon_worker_pool(self):
"""Initialisiert den Saxon-Worker-Pool für schnelle Transformationen."""
try:
# Shutdown vorherigen Pool falls vorhanden
self._shutdown_saxon_worker_pool()
# Prüfe ob SaxonWorkerPool aktiviert ist
if not app_settings.use_saxon_worker_pool:
logger.info("SaxonWorkerPool deaktiviert - Verwende Fallback-Modus (subprocess)")
return
if not self.project:
logger.warning("Kein Projekt geladen, Saxon-Worker-Pool nicht initialisiert")
return
# Hole Tool-Konfigurationen
java_vm = next((vm for vm in app_settings.java_vms if vm.id == self.project.java_vm_id), None)
saxon_jar = next((jar for jar in app_settings.saxon_jars if jar.id == self.project.saxon_jar_id), None)
if not java_vm or not saxon_jar:
logger.warning("Java VM oder Saxon JAR nicht gefunden, Pool nicht initialisiert")
return
# Erstelle Worker-Pool (wähle richtige Variante basierend auf XSLT-Version)
num_workers = app_settings.max_workers
log_dir = self.project.project_dir / "temp"
# Wähle die richtige Worker-Pool-Implementierung
if app_settings.saxon_xslt_version == XsltVersion.XSLT_1_0:
# JAXP-basierte Variante für XSLT 1.0
pool = SaxonWorkerPool(
num_workers=num_workers,
java_vm_path=java_vm.path_to_binary_file,
saxon_jar_path=saxon_jar.path_to_jar_file,
classpath_cache=TransformationJob._classpath_cache,
log_dir=log_dir,
)
pool_type = "JAXP (XSLT 1.0)"
else:
# s9api-basierte Variante für XSLT 2.0/3.0
pool = SaxonWorkerPoolS9Api(
num_workers=num_workers,
java_vm_path=java_vm.path_to_binary_file,
saxon_jar_path=saxon_jar.path_to_jar_file,
classpath_cache=TransformationJob._classpath_cache,
log_dir=log_dir,
)
pool_type = "s9api (XSLT 2.0/3.0)"
# Setze globalen Pool
set_saxon_worker_pool(pool)
logger.info(
f"Saxon-Worker-Pool initialisiert: {num_workers} Worker mit {pool_type} "
f"(erwartet: {num_workers}x schneller für Saxon-Transformationen)"
)
except Exception as e:
logger.error(f"Fehler beim Initialisieren des Saxon-Worker-Pools: {e}")
logger.info("Fallback auf subprocess-Modus")
# Kein Pool ist OK - Fallback auf subprocess
def _shutdown_saxon_worker_pool(self):
"""Beendet den Saxon-Worker-Pool sauber."""
try:
# Importiere transform um Zugriff auf globalen Pool zu haben
import transform
if transform._saxon_worker_pool:
logger.info("Beende Saxon-Worker-Pool...")
transform._saxon_worker_pool.shutdown()
set_saxon_worker_pool(None)
logger.info("Saxon-Worker-Pool beendet")
except Exception as e:
logger.error(f"Fehler beim Beenden des Saxon-Worker-Pools: {e}")
def _initialize_fop_worker_pool(self):
"""Initialisiert den FOP-Worker-Pool für schnelle PDF-Generierung."""
try:
# Shutdown vorherigen Pool falls vorhanden
self._shutdown_fop_worker_pool()
# Prüfe ob FopWorkerPool aktiviert ist
if not app_settings.use_fop_worker_pool:
logger.info("FopWorkerPool deaktiviert - Verwende Fallback-Modus (subprocess)")
return
if not self.project:
logger.warning("Kein Projekt geladen, FOP-Worker-Pool nicht initialisiert")
return
# Hole Tool-Konfigurationen
java_vm = next((vm for vm in app_settings.java_vms if vm.id == self.project.java_vm_id), None)
apache_fop = next((fop for fop in app_settings.apache_fops if fop.id == self.project.apache_fop_id), None)
if not java_vm or not apache_fop:
logger.warning("Java VM oder Apache FOP nicht gefunden, Pool nicht initialisiert")
return
# FOP-Konfigurationsdatei (falls vorhanden)
fop_config_file = None
if self.project.fop_config_dir:
fop_config_file = self.project.fop_config_dir / "fop.xconf"
else:
default_config = apache_fop.path_to_dir / "conf" / "fop.xconf"
if default_config.exists():
fop_config_file = default_config
# Importiere FopWorkerPool
from fop_pool import FopWorkerPool
from transform import set_fop_worker_pool
# Erstelle Worker-Pool
num_workers = app_settings.max_workers
log_dir = self.project.project_dir / "temp"
pool = FopWorkerPool(
num_workers=num_workers,
java_vm_path=java_vm.path_to_binary_file,
apache_fop_dir=apache_fop.path_to_dir,
fop_config_file=fop_config_file,
log_dir=log_dir,
)
# Setze globalen Pool
set_fop_worker_pool(pool)
logger.info(
f"FOP-Worker-Pool initialisiert: {num_workers} Worker "
f"(erwartet: {num_workers}x schneller für PDF-Generierung)"
)
except Exception as e:
logger.error(f"Fehler beim Initialisieren des FOP-Worker-Pools: {e}")
logger.info("Fallback auf subprocess-Modus")
# Kein Pool ist OK - Fallback auf subprocess
def _shutdown_fop_worker_pool(self):
"""Beendet den FOP-Worker-Pool sauber."""
try:
# Importiere transform um Zugriff auf globalen Pool zu haben
import transform
if transform._fop_worker_pool:
logger.info("Beende FOP-Worker-Pool...")
transform._fop_worker_pool.shutdown()
# Importiere set_fop_worker_pool
from transform import set_fop_worker_pool
set_fop_worker_pool(None)
logger.info("FOP-Worker-Pool beendet")
except Exception as e:
logger.error(f"Fehler beim Beenden des FOP-Worker-Pools: {e}")
def _show_worker_pool_metrics(self):
"""Zeigt den Worker-Pool-Metriken-Dialog an."""
try:
from ui.WorkerPoolMetricsDialog import WorkerPoolMetricsDialog
dialog = WorkerPoolMetricsDialog(self)
dialog.exec()
except Exception as e:
logger.error(f"Fehler beim Öffnen des Metriken-Dialogs: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Öffnen des Metriken-Dialogs:\n{str(e)}")
+461
View File
@@ -0,0 +1,461 @@
"""
Thread-Klassen für asynchrone Operationen in DocuMentor.
Dieses Modul enthält alle QThread-Klassen, die für Hintergrundoperationen verwendet werden:
- XmlHashCalculatorThread: Berechnung von blake2b-Hashes für XML-Dateien
- XmlBatchProcessingThread: Batch-Verarbeitung von XML-Dateien
- TransformationThread: Ausführung von XSL-Transformationen
"""
import hashlib
import logging
import shutil
from pathlib import Path
from typing import List
from PySide6.QtCore import QThread, Signal
from conf import TreeNode, XslFile, XmlFile
from transform import TransformationJob
logger = logging.getLogger(__name__)
class XmlHashCalculatorThread(QThread):
"""
Thread für die asynchrone Berechnung von blake2b-Hash-Werten für XML-Dateien.
"""
# Signale für die Kommunikation mit dem Haupt-Thread
hash_calculated = Signal(object, str) # xml_file_object, hash_value
calculation_finished = Signal(int, int) # processed_count, total_count
error_occurred = Signal(str, str) # xml_file_path, error_message
def __init__(self, project_dir: Path, xml_files: List[XmlFile]):
"""
Initialisiert den Hash-Berechnungs-Thread.
Args:
project_dir: Pfad zum Projekt-Verzeichnis
xml_files: Liste der XmlFile-Objekte für die Hash-Berechnung
"""
super().__init__()
self.project_dir = project_dir
self.xml_files = xml_files
self.processed_count = 0
def run(self):
"""
Führt die Hash-Berechnung für alle XML-Dateien aus.
"""
logger.info(f"Starte Hash-Berechnung für {len(self.xml_files)} XML-Dateien")
for xml_file in self.xml_files:
try:
# Prüfe ob hashsum bereits vorhanden ist
if xml_file.hashsum:
logger.debug(f"Hash bereits vorhanden für {xml_file.xml}: {xml_file.hashsum}")
self.processed_count += 1
continue
# Berechne Hash für die XML-Datei
xml_file_path = self.project_dir / xml_file.xml
hash_value = self._calculate_blake2b_hash(xml_file_path)
if hash_value:
# Sende Signal mit berechnetem Hash
self.hash_calculated.emit(xml_file, hash_value)
logger.debug(f"Hash berechnet für {xml_file.xml}: {hash_value}")
self.processed_count += 1
except Exception as e:
error_msg = f"Fehler bei Hash-Berechnung für {xml_file.xml}: {str(e)}"
logger.error(error_msg)
self.error_occurred.emit(str(xml_file.xml), error_msg)
self.processed_count += 1
# Sende Abschluss-Signal
self.calculation_finished.emit(self.processed_count, len(self.xml_files))
logger.info(f"Hash-Berechnung abgeschlossen: {self.processed_count}/{len(self.xml_files)} verarbeitet")
def _calculate_blake2b_hash(self, file_path: Path) -> str | None:
"""
Berechnet den blake2b-Hash einer XML-Datei.
Args:
file_path: Pfad zur XML-Datei
Returns:
str: Hash-Wert mit "blake2b:" Präfix oder None bei Fehler
"""
try:
if not file_path.exists():
logger.warning(f"XML-Datei nicht gefunden: {file_path}")
return None
# Datei binär lesen und Hash berechnen
with open(file_path, "rb") as f:
file_content = f.read()
hash_obj = hashlib.blake2b(file_content)
hash_hex = hash_obj.hexdigest()
# Präfix hinzufügen
return f"blake2b:{hash_hex}"
except Exception as e:
logger.error(f"Fehler beim Berechnen des Hash für {file_path}: {e}")
return None
class XmlBatchProcessingThread(QThread):
"""
Thread für die asynchrone Batch-Verarbeitung von mehreren XML-Dateien.
Verarbeitet XML-Dateien mit Hash-Berechnung, Duplikatserkennung und Dateikopieren.
"""
# Signale für die Kommunikation mit dem Haupt-Thread
progress_update = Signal(int, int, str) # current, total, current_file_name
file_processed = Signal(dict) # result dictionary
processing_finished = Signal(dict) # stats dictionary
error_occurred = Signal(str) # error_message
def __init__(self, xml_files: list, selected_xsl_nodes: list, project_dir: Path, pdf_project):
"""
Initialisiert den Batch-Verarbeitungs-Thread.
Args:
xml_files: Liste von Pfaden zu XML-Dateien
selected_xsl_nodes: Liste der ausgewählten XSL-Knoten
project_dir: Pfad zum Projekt-Verzeichnis
pdf_project: ProjectData-Objekt
"""
super().__init__()
self.xml_files = xml_files
self.selected_xsl_nodes = selected_xsl_nodes
self.project_dir = project_dir
self.pdf_project = pdf_project
# Statistiken
self.stats = {
"total": len(xml_files),
"processed": 0,
"new_added": 0,
"existing_added": 0,
"already_assigned": 0,
"cancelled": 0,
"errors": 0,
"error_messages": [],
"renamed_files": [],
}
def run(self):
"""
Führt die Batch-Verarbeitung aller XML-Dateien aus.
"""
logger.info(f"Starte Batch-Verarbeitung für {len(self.xml_files)} XML-Dateien")
for i, xml_file_path in enumerate(self.xml_files):
try:
# Sende Progress-Update
self.progress_update.emit(i + 1, len(self.xml_files), xml_file_path.name)
# Prüfe ob die Datei existiert
if not xml_file_path.exists():
self.stats["errors"] += 1
self.stats["error_messages"].append(f"{xml_file_path.name}: Datei existiert nicht")
continue
# Verarbeite die XML-Datei
result = self._process_xml_file(xml_file_path)
# Aktualisiere Statistiken
self._update_stats(result)
# Sende Ergebnis
self.file_processed.emit(result)
except Exception as e:
error_msg = f"Fehler bei {xml_file_path.name}: {str(e)}"
logger.error(error_msg)
self.stats["errors"] += 1
self.stats["error_messages"].append(error_msg)
# Sende Abschluss-Signal mit Statistiken
self.processing_finished.emit(self.stats)
logger.info(f"Batch-Verarbeitung abgeschlossen: {self.stats['processed']}/{self.stats['total']} verarbeitet")
def _process_xml_file(self, xml_file_path: Path) -> dict:
"""
Verarbeitet eine einzelne XML-Datei.
Args:
xml_file_path: Pfad zur XML-Datei
Returns:
dict: Ergebnis-Dictionary mit Status
"""
try:
# 1. Hash berechnen
file_hash = self._calculate_hash_for_file(xml_file_path)
if not file_hash:
logger.warning(f"Hash-Berechnung für {xml_file_path} fehlgeschlagen")
# 2. Prüfe auf Hash-Duplikat
existing_xml = self._find_xml_file_by_hash(file_hash) if file_hash else None
if existing_xml:
# Hash-Match: Ordne vorhandene Datei zu
return self._assign_existing_xml_to_nodes(existing_xml)
else:
# Keine Duplikate: Verarbeite als neue Datei
return self._process_new_xml_file(xml_file_path, file_hash)
except Exception as e:
return {"status": "error", "error_msg": str(e)}
def _calculate_hash_for_file(self, file_path: Path) -> str | None:
"""Berechnet blake2b Hash für eine Datei."""
try:
if not file_path.exists():
return None
with open(file_path, "rb") as f:
file_content = f.read()
hash_obj = hashlib.blake2b(file_content)
hash_hex = hash_obj.hexdigest()
return f"blake2b:{hash_hex}"
except Exception as e:
logger.error(f"Fehler beim Berechnen des Hash für {file_path}: {e}")
return None
def _find_xml_file_by_hash(self, hash_value: str) -> XmlFile | None:
"""Sucht eine XML-Datei anhand ihres Hash-Werts."""
if not hash_value or not self.pdf_project.nodes:
return None
def search_recursive(nodes):
for node in nodes:
if isinstance(node, XslFile) and node.xmls:
for xml_file in node.xmls:
if xml_file.hashsum == hash_value:
return xml_file
elif isinstance(node, TreeNode) and node.children:
found = search_recursive(node.children)
if found:
return found
return None
return search_recursive(self.pdf_project.nodes)
def _assign_existing_xml_to_nodes(self, existing_xml: XmlFile) -> dict:
"""Ordnet eine vorhandene XML-Datei den Knoten zu."""
try:
added_count = 0
for xsl_node in self.selected_xsl_nodes:
already_assigned = any(xml_file.xml == existing_xml.xml for xml_file in xsl_node.xmls)
if not already_assigned:
new_xml_ref = XmlFile(xml=existing_xml.xml, hashsum=existing_xml.hashsum)
xsl_node.xmls.append(new_xml_ref)
added_count += 1
if added_count > 0:
return {
"status": "existing_added",
"added_count": added_count,
"existing_file": existing_xml.xml.name,
}
else:
return {"status": "already_assigned", "added_count": 0, "existing_file": existing_xml.xml.name}
except Exception as e:
return {"status": "error", "error_msg": str(e)}
def _process_new_xml_file(self, xml_file_path: Path, file_hash: str | None) -> dict:
"""Verarbeitet eine neue XML-Datei."""
try:
# Erstelle xml-Ordner
xml_dir = self.project_dir / "xml"
xml_dir.mkdir(parents=True, exist_ok=True)
# Bestimme Ziel-Pfad
target_xml_path = xml_dir / xml_file_path.name
# Prüfe auf Namenskonflikte und generiere ggf. alternativen Namen
original_name = xml_file_path.name
counter = 1
while target_xml_path.exists() or self._is_filename_used_in_project(Path("xml") / target_xml_path.name):
# Generiere alternativen Namen
stem = xml_file_path.stem
suffix = xml_file_path.suffix
target_xml_path = xml_dir / f"{stem}_{counter}{suffix}"
counter += 1
# Sicherheit: Maximal 1000 Versuche
if counter > 1000:
return {"status": "error", "error_msg": "Konnte keinen eindeutigen Dateinamen finden"}
# Kopiere Datei
shutil.copy2(xml_file_path, target_xml_path)
# Erstelle relatives Path
relative_xml_path = Path("xml") / target_xml_path.name
# Füge zu XSL-Knoten hinzu
added_count = 0
for xsl_node in self.selected_xsl_nodes:
existing_xml = any(xml_file.xml == relative_xml_path for xml_file in xsl_node.xmls)
if not existing_xml:
new_xml_file = XmlFile(xml=relative_xml_path, hashsum=file_hash)
xsl_node.xmls.append(new_xml_file)
added_count += 1
if added_count > 0:
return {
"status": "new_added",
"added_count": added_count,
"new_file": target_xml_path.name,
"renamed_from": original_name if target_xml_path.name != original_name else None,
}
else:
return {"status": "already_assigned", "added_count": 0, "new_file": target_xml_path.name}
except Exception as e:
return {"status": "error", "error_msg": str(e)}
def _is_filename_used_in_project(self, filename: Path) -> bool:
"""Prüft ob ein Dateiname bereits im Projekt verwendet wird."""
if not self.pdf_project.nodes:
return False
def search_recursive(nodes):
for node in nodes:
if isinstance(node, XslFile) and node.xmls:
for xml_file in node.xmls:
if xml_file.xml == filename:
return True
elif isinstance(node, TreeNode) and node.children:
if search_recursive(node.children):
return True
return False
return search_recursive(self.pdf_project.nodes)
def _update_stats(self, result: dict):
"""Aktualisiert die Statistiken."""
self.stats["processed"] += 1
status = result.get("status")
if status == "new_added":
self.stats["new_added"] += 1
if result.get("renamed_from"):
self.stats["renamed_files"].append(f"{result['renamed_from']}{result['new_file']}")
elif status == "existing_added":
self.stats["existing_added"] += 1
elif status == "already_assigned":
self.stats["already_assigned"] += 1
elif status == "error":
self.stats["errors"] += 1
self.stats["error_messages"].append(result.get("error_msg", "Unbekannter Fehler"))
class TransformationThread(QThread):
"""
Thread für die asynchrone Ausführung von Transformations-Jobs.
"""
# Signale für die Kommunikation mit dem Haupt-Thread
job_started = Signal(str, str) # xml_file_name, xsl_id_str
job_finished = Signal(dict) # result_dict
job_error = Signal(str, str, str) # xml_file_name, xsl_id_str, error_message
all_jobs_finished = Signal(int, int, float) # successful_count, total_count, total_duration
def __init__(self, jobs: list[TransformationJob], force: bool = False, max_workers: int = 8):
"""
Initialisiert den Transformations-Thread.
Args:
jobs: Liste der TransformationJob-Objekte
force: Wenn True, werden alle Jobs ausgeführt (ignoriert Up-to-Date)
max_workers: Maximale Anzahl paralleler Worker (Standard: 8)
"""
super().__init__()
self.jobs = jobs
self.force = force
self.max_workers = max_workers
self.successful_count = 0
def _process_single_job(self, job: TransformationJob) -> dict:
"""
Verarbeitet einen einzelnen Transformations-Job (Thread-safe).
Args:
job: Der zu verarbeitende TransformationJob
Returns:
dict: Ergebnis-Dictionary des Jobs
"""
try:
# Sende Start-Signal mit XSL-ID
xsl_id_str = "_".join(str(x) for x in job.xsl_id) if job.xsl_id else ""
self.job_started.emit(str(job.xml_file), xsl_id_str)
# Führe Transformations-Pipeline aus
result = job.run_full_pipeline(force=self.force)
# Sende Abschluss-Signal
self.job_finished.emit(result)
return result
except Exception as e:
error_msg = f"Unerwarteter Fehler bei Transformation: {str(e)}"
logger.error(error_msg)
xsl_id_str = "_".join(str(x) for x in job.xsl_id) if job.xsl_id else ""
self.job_error.emit(str(job.xml_file), xsl_id_str, error_msg)
return {"success": False, "error": error_msg}
def run(self):
"""
Führt alle Transformations-Jobs parallel aus mit ThreadPoolExecutor.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
import threading
start_time = datetime.now()
logger.info(f"Starte parallele Transformation von {len(self.jobs)} Jobs mit {self.max_workers} Workern")
# Thread-sicherer Counter
successful_lock = threading.Lock()
# Verwende ThreadPoolExecutor für parallele Verarbeitung
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Starte alle Jobs
future_to_job = {executor.submit(self._process_single_job, job): job for job in self.jobs}
# Warte auf Abschluss und sammle Ergebnisse
for future in as_completed(future_to_job):
try:
result = future.result()
if result.get("success", False):
with successful_lock:
self.successful_count += 1
except Exception as e:
logger.error(f"Fehler beim Verarbeiten des Future: {e}")
# Berechne Gesamtdauer
total_duration = (datetime.now() - start_time).total_seconds()
# Sende Abschluss-Signal für alle Jobs mit Gesamtdauer
self.all_jobs_finished.emit(self.successful_count, len(self.jobs), total_duration)
logger.info(
f"Transformation abgeschlossen: {self.successful_count}/{len(self.jobs)} erfolgreich ({total_duration:.2f}s) "
f"[{len(self.jobs) / total_duration:.2f} Jobs/s mit {self.max_workers} Workern]"
)