XSL-Transformations-Engine mit Saxon, Apache FOP und diff-pdf implementiert
Fügt die komplette Transformations-Pipeline hinzu: - Saxon XSLT-Transformation (XML → FO) mit vollständigem Classpath-Support - Apache FOP PDF-Generierung (FO → PDF) mit plattformübergreifender Unterstützung - Automatische diff-pdf Vergleichs- und Diff-Generierung - Valide-PDF-Verwaltung (Referenz-PDFs beim ersten erfolgreichen Build) - Up-to-Date-Prüfung basierend auf Datei-Zeitstempeln - Asynchrone Ausführung via TransformationThread (QThread) - Kontextmenü-Integration für XML- und XSL-Dateien - Detailliertes Fehler-Reporting und Fortschritts-Feedback Neue Dateien: - src/transform.py: TransformationJob-Klasse mit vollständiger Pipeline Erweiterte Dateien: - src/ui/MainWindow.py: TransformationThread und Transformations-Methoden Technische Details: - Löst Saxon ClassNotFoundException durch Verwendung aller JARs im Saxon-Verzeichnis - Verwendet -cp statt -jar für vollständigen Classpath-Zugriff - Automatisches Cleanup temporärer FO-Dateien - Thread-sicheres Shutdown-Handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+360
-6
@@ -20,6 +20,7 @@ from ui.TreeNodeEditDialog import TreeNodeEditDialog
|
||||
from ui.XslFileEditDialog import XslFileEditDialog
|
||||
from ui.XmlToXslAssignDialog import XmlToXslAssignDialog
|
||||
from conf import app_settings, Project, ProjectData, TreeNode, XslFile, XmlFile
|
||||
from transform import TransformationJob
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -113,6 +114,60 @@ class XmlHashCalculatorThread(QThread):
|
||||
return None
|
||||
|
||||
|
||||
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) # xml_file_name
|
||||
job_finished = Signal(dict) # result_dict
|
||||
job_error = Signal(str, str) # xml_file_name, error_message
|
||||
all_jobs_finished = Signal(int, int) # successful_count, total_count
|
||||
|
||||
def __init__(self, jobs: list[TransformationJob], force: bool = False):
|
||||
"""
|
||||
Initialisiert den Transformations-Thread.
|
||||
|
||||
Args:
|
||||
jobs: Liste der TransformationJob-Objekte
|
||||
force: Wenn True, werden alle Jobs ausgeführt (ignoriert Up-to-Date)
|
||||
"""
|
||||
super().__init__()
|
||||
self.jobs = jobs
|
||||
self.force = force
|
||||
self.successful_count = 0
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Führt alle Transformations-Jobs sequenziell aus.
|
||||
"""
|
||||
logger.info(f"Starte Transformation von {len(self.jobs)} Jobs")
|
||||
|
||||
for job in self.jobs:
|
||||
try:
|
||||
# Sende Start-Signal
|
||||
self.job_started.emit(str(job.xml_file))
|
||||
|
||||
# Führe Transformations-Pipeline aus
|
||||
result = job.run_full_pipeline(force=self.force)
|
||||
|
||||
# Sende Abschluss-Signal
|
||||
self.job_finished.emit(result)
|
||||
|
||||
if result["success"]:
|
||||
self.successful_count += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Unerwarteter Fehler bei Transformation: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
self.job_error.emit(str(job.xml_file), error_msg)
|
||||
|
||||
# Sende Abschluss-Signal für alle Jobs
|
||||
self.all_jobs_finished.emit(self.successful_count, len(self.jobs))
|
||||
logger.info(f"Transformation abgeschlossen: {self.successful_count}/{len(self.jobs)} erfolgreich")
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
"""
|
||||
@@ -161,7 +216,10 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Hash-Berechnungs-Thread
|
||||
self.hash_calculator_thread = None
|
||||
|
||||
|
||||
# Transformations-Thread
|
||||
self.transformation_thread = None
|
||||
|
||||
# Theme-Menü initialisieren
|
||||
self._setup_theme_menu()
|
||||
|
||||
@@ -856,14 +914,27 @@ class MainWindow(QMainWindow):
|
||||
action_add_xml.setIcon(QIcon(QIcon.fromTheme("document-new")))
|
||||
action_add_xml.triggered.connect(lambda: self._add_xml_file_to_xsl(item))
|
||||
menu.addAction(action_add_xml)
|
||||
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
|
||||
# Transformations-Aktionen
|
||||
action_transform = QAction("Alle XML-Dateien transformieren", self)
|
||||
action_transform.setIcon(QIcon(QIcon.fromTheme("system-run")))
|
||||
action_transform.triggered.connect(lambda: self._transform_xsl_file(item))
|
||||
menu.addAction(action_transform)
|
||||
|
||||
action_transform_force = QAction("Alle XML-Dateien neu transformieren (force)", self)
|
||||
action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh")))
|
||||
action_transform_force.triggered.connect(lambda: self._transform_xsl_file(item, force=True))
|
||||
menu.addAction(action_transform_force)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
action_edit = QAction("Bearbeiten", self)
|
||||
action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties)))
|
||||
action_edit.triggered.connect(lambda: self._edit_xsl_file(item))
|
||||
menu.addAction(action_edit)
|
||||
|
||||
|
||||
action_delete = QAction("Löschen", self)
|
||||
action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete)))
|
||||
action_delete.triggered.connect(lambda: self._delete_xsl_file(item))
|
||||
@@ -871,11 +942,24 @@ class MainWindow(QMainWindow):
|
||||
|
||||
elif node_type == "XmlFile":
|
||||
# Kontextmenü für XmlFile
|
||||
# Transformations-Aktionen
|
||||
action_transform = QAction("Transformieren", self)
|
||||
action_transform.setIcon(QIcon(QIcon.fromTheme("system-run")))
|
||||
action_transform.triggered.connect(lambda: self._transform_xml_file(item))
|
||||
menu.addAction(action_transform)
|
||||
|
||||
action_transform_force = QAction("Neu transformieren (force)", self)
|
||||
action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh")))
|
||||
action_transform_force.triggered.connect(lambda: self._transform_xml_file(item, force=True))
|
||||
menu.addAction(action_transform_force)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
action_edit = QAction("Bearbeiten", self)
|
||||
action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties)))
|
||||
action_edit.triggered.connect(lambda: self._edit_xml_file(item))
|
||||
menu.addAction(action_edit)
|
||||
|
||||
|
||||
action_delete = QAction("Löschen", self)
|
||||
action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete)))
|
||||
action_delete.triggered.connect(lambda: self._delete_xml_file(item))
|
||||
@@ -2618,12 +2702,282 @@ class MainWindow(QMainWindow):
|
||||
# Fallback: Ersten alternativen Namen verwenden
|
||||
return alternative_paths[0] if alternative_paths else None
|
||||
|
||||
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
|
||||
job = self._create_transformation_job(xsl_file_obj, xml_file_obj)
|
||||
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:
|
||||
job = self._create_transformation_job(xsl_file_obj, xml_file_obj)
|
||||
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 _create_transformation_job(self, xsl_file_obj: XslFile, xml_file_obj: XmlFile) -> TransformationJob | None:
|
||||
"""
|
||||
Erstellt einen TransformationJob für eine XML/XSL-Kombination.
|
||||
|
||||
Args:
|
||||
xsl_file_obj: Das XslFile-Objekt
|
||||
xml_file_obj: Das XmlFile-Objekt
|
||||
|
||||
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
|
||||
|
||||
# Erstelle absoluten Pfad zur XSL-Datei
|
||||
xsl_file_abs = xsl_dir.path_to_root_dir / xsl_file_obj.xsl_file
|
||||
|
||||
# Sammle XSLT-Parameter (kombiniere TreeNode + XslFile Parameter)
|
||||
xslt_params = {}
|
||||
# TODO: Hier könnten TreeNode-Parameter von übergeordneten Knoten gesammelt werden
|
||||
xslt_params.update(xsl_file_obj.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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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 _on_transformation_job_started(self, xml_file_name: str):
|
||||
"""
|
||||
Signal-Handler: Ein Job wurde gestartet.
|
||||
|
||||
Args:
|
||||
xml_file_name: Name der XML-Datei
|
||||
"""
|
||||
logger.info(f"Transformation gestartet: {xml_file_name}")
|
||||
self.statusBar().showMessage(f"Transformiere: {xml_file_name}")
|
||||
|
||||
def _on_transformation_job_finished(self, result: dict):
|
||||
"""
|
||||
Signal-Handler: Ein Job wurde abgeschlossen.
|
||||
|
||||
Args:
|
||||
result: Ergebnis-Dictionary
|
||||
"""
|
||||
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}"
|
||||
)
|
||||
|
||||
def _on_transformation_job_error(self, xml_file_name: str, error_message: str):
|
||||
"""
|
||||
Signal-Handler: Ein Job ist mit einem Fehler abgebrochen.
|
||||
|
||||
Args:
|
||||
xml_file_name: Name der XML-Datei
|
||||
error_message: Fehlermeldung
|
||||
"""
|
||||
logger.error(f"Transformation-Fehler bei {xml_file_name}: {error_message}")
|
||||
QMessageBox.critical(self, "Fehler", f"Fehler bei {xml_file_name}:\n{error_message}")
|
||||
|
||||
def _on_all_transformations_finished(self, successful_count: int, total_count: int):
|
||||
"""
|
||||
Signal-Handler: Alle Jobs wurden abgeschlossen.
|
||||
|
||||
Args:
|
||||
successful_count: Anzahl erfolgreicher Jobs
|
||||
total_count: Gesamtanzahl der Jobs
|
||||
"""
|
||||
logger.info(f"Alle Transformationen abgeschlossen: {successful_count}/{total_count} erfolgreich")
|
||||
|
||||
if successful_count == total_count:
|
||||
self.statusBar().showMessage(f"✓ Alle {total_count} Transformationen erfolgreich", 5000)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Abgeschlossen",
|
||||
f"Alle {total_count} Transformationen wurden erfolgreich abgeschlossen"
|
||||
)
|
||||
else:
|
||||
failed_count = total_count - successful_count
|
||||
self.statusBar().showMessage(
|
||||
f"⚠ {successful_count}/{total_count} erfolgreich, {failed_count} fehlgeschlagen",
|
||||
5000
|
||||
)
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Abgeschlossen mit Fehlern",
|
||||
f"{successful_count} von {total_count} Transformationen erfolgreich\n"
|
||||
f"{failed_count} fehlgeschlagen"
|
||||
)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wird beim Schließen der Anwendung aufgerufen."""
|
||||
# Stoppe Hash-Berechnungs-Thread falls noch aktiv
|
||||
if hasattr(self, 'hash_calculator_thread') and self.hash_calculator_thread and self.hash_calculator_thread.isRunning():
|
||||
self.hash_calculator_thread.quit()
|
||||
self.hash_calculator_thread.wait()
|
||||
|
||||
|
||||
# Stoppe Transformations-Thread falls noch aktiv
|
||||
if hasattr(self, 'transformation_thread') and self.transformation_thread and self.transformation_thread.isRunning():
|
||||
self.transformation_thread.quit()
|
||||
self.transformation_thread.wait()
|
||||
|
||||
# PDF-Dokumente schließen ist bei QtPdf automatisch durch Garbage Collection
|
||||
super().closeEvent(event)
|
||||
|
||||
Reference in New Issue
Block a user