Files
xsl-validator/src/ui/mixins/pdf_viewer.py
T

560 lines
23 KiB
Python
Raw Normal View History

"""
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, QSpacerItem, QSizePolicy
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)
# Setze kompaktes Spacing für Thumbnail-Layout
self.ui.verticalLayout_2.setSpacing(5) # Minimaler Abstand zwischen Widgets
self.ui.verticalLayout_2.setContentsMargins(0, 0, 0, 0) # Keine Ränder
# 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)
thumbnail.setAlignment(Qt.AlignmentFlag.AlignCenter)
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)
thumbnail_info.setMaximumHeight(18) # Kompakte Höhe
thumbnail_info.setContentsMargins(0, 0, 0, 0) # Keine Ränder
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)
# Füge expandierenden Spacer am Ende hinzu, damit Thumbnails oben bleiben
spacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.ui.verticalLayout_2.addItem(spacer)
# 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)
self.ui.accept_changes.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}")