Zwei Dateien vergleichen.

Python PyQt6 Applikation

Zurück

Was hat sich denn zwischen zwei Python-Dateien mit gleichem Namen geändert? Dass sie unterschiedlich sind, ist klar, denn das Datum sagt es aus, und sie liegen in unterschiedlichen Ordnern. Aber was genau wurde geändert? Das nachfolgende Programm, das mit Python (PyQt6) erstellt wurde, soll Klarheit schaffen. Es analysiert zwei beliebige Dateien, egal ob Text-, Bild- oder binäre Dateien. Es ist eine weitere Anwendung zur Lernreihe „Fenstertechnik“ mit Qt6-Python ohne Qt-Designer.


Compare File


Was die App macht:
Zwei gestylte Buttons (grün/orange) öffnen je einen Explorer-Dialog für Datei A und Datei B.
Ein Dropdown wählt die Vergleichsart: Automatisch (erkennt anhand der Dateiendung), Text, Bild oder Binär.
Text: zeilenbasierter Diff (wie ein Unified-Diff) mit farblich markierten Zeilen (hinzugefügt/entfernt).
Bild: Pixelvergleich per Pillow – zeigt nur „identisch“ oder „unterschiedlich" (inkl. Hinweis bei abweichender Auflösung).
Binär: SHA-256-Schnellvergleich, bei Unterschied Auflistung der abweichenden Byte-Offsets (Hex).
Im Infofeld werden vor dem eigentlichen Ergebnis immer Name, Erstellungs-/Änderungsdatum und Größe beider Dateien angezeigt. Fehlerfälle (fehlende Datei, fehlendes Pillow, Leserechte etc.) werden ebenfalls im Infofeld farblich hervorgehoben. Komplett im Dark Mode via QSS-Stylesheet.


File Listing


Zur Erinnerung! Der Schwerpunkt dieser Applikation basiert auf „Was hat sich bei einer Programmiersprachen-Quellcodedatei geändert?“. „Die neue Version läuft plötzlich nicht mehr.“ Bevor man den Debugger anschmeißt, analysieren, wo man Änderungen durchgeführt hat. Das obige Bild zeigt Details zur Visualisierung der Unterschiede: Für beide Dateien werden Erstellungsdatum, Datum der letzten Änderung und die Größe der Datei angezeigt. Danach erfolgt der Hinweis: Dateien sind gleich oder unterschiedlich. Sind sie unterschiedlich, erfolgt eine Visualisierung der Zeilen, in denen sich die Dateien unterscheiden, in roter bzw. grüner Farbe. Die Sonderzeichen wie „+“, "-", "@@" und „Datei A/B“ werden weiter unten in dieser Beschreibung genau erklärt. Im obigen Bild sind es genau drei Zeilen, die unterschiedlich sind.

Python Code
#!/usr/bin/env python3 # ***************************************************************************** # Eine PyQt6-Anwendung im Dark Mode zum Vergleichen von zwei Dateien. # Unterstützte Vergleichsarten: # - Text -> zeilenbasierter Diff mit Zeilennummern # - Bild -> Vergleich per Pixel (nur "gleich"/"unterschiedlich") # - Binär -> byteweiser Vergleich mit Angabe der abweichenden Offsets # Installation der Abhängigkeiten: pip install PyQt6 Pillow # (c) Hans Busche Datum: 30.08.2026 # ***************************************************************************** import sys import os import difflib import hashlib from pathlib import Path from datetime import datetime from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QComboBox, QTextEdit, QGroupBox, QSizePolicy, QFrame ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont, QColor, QTextCursor, QTextCharFormat try: from PIL import Image, ImageChops PIL_AVAILABLE = True except ImportError: PIL_AVAILABLE = False # Verzeichnis dieser Datei, damit das Icon unabhängig vom Startort gefunden wird BASE_DIR = os.path.dirname(os.path.abspath(__file__)) ICON_PATH = os.path.join(BASE_DIR, "Schaefer.ico") # ***************************************************************************** # Dark-Mode-Stylesheet # ***************************************************************************** DARK_STYLESHEET = """ QMainWindow, QWidget { background-color: #1e1e1e; color: #d4d4d4; font-family: "Segoe UI", "Ubuntu", sans-serif; font-size: 13px; } QLabel#titleLabel { font-size: 20px; font-weight: 600; color: #ffffff; padding-bottom: 4px; } QGroupBox { border: 1px solid #3c3c3c; border-radius: 8px; margin-top: 10px; padding: 12px; font-weight: 600; color: #cccccc; background-color: #252526; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 6px; color: #9cdcfe; } QLabel#pathLabel { color: #9a9a9a; padding-top: 6px; font-style: italic; } QLabel#detailLabel { color: #d4d4d4; } QPushButton { border: none; border-radius: 6px; padding: 9px 16px; color: white; font-weight: 600; } QPushButton#fileButtonA { background-color: #2d7d46; } QPushButton#fileButtonA:hover { background-color: #35934f; } QPushButton#fileButtonA:pressed { background-color: #256339; } QPushButton#fileButtonB { background-color: #c9702e; } QPushButton#fileButtonB:hover { background-color: #de8039; } QPushButton#fileButtonB:pressed { background-color: #a85d24; } QPushButton#compareButton { background-color: #0e639c; padding: 10px 24px; font-size: 14px; } QPushButton#compareButton:hover { background-color: #1177bb; } QPushButton#compareButton:pressed { background-color: #0a4d7a; } QPushButton#exportButton { background-color: #5a3d8a; padding: 10px 20px; font-size: 14px; } QPushButton#exportButton:hover { background-color: #6c49a3; } QPushButton#exportButton:pressed { background-color: #472f6e; } QPushButton#exportButton:disabled { background-color: #3c3c3c; color: #7a7a7a; } QStatusBar { background-color: #1e1e1e; color: #9cdcfe; } QComboBox { background-color: #2d2d2d; border: 1px solid #3c3c3c; border-radius: 6px; padding: 6px 10px; min-width: 160px; color: #d4d4d4; } QComboBox:hover { border: 1px solid #0e639c; } QComboBox::drop-down { border: none; width: 24px; } QComboBox QAbstractItemView { background-color: #2d2d2d; selection-background-color: #0e639c; color: #d4d4d4; border: 1px solid #3c3c3c; } QTextEdit#infoField { background-color: #121212; border: 1px solid #3c3c3c; border-radius: 8px; padding: 10px; color: #d4d4d4; } QLabel#sectionLabel { font-weight: 600; color: #9cdcfe; padding-top: 6px; } QScrollBar:vertical { background: #1e1e1e; width: 12px; } QScrollBar::handle:vertical { background: #3c3c3c; border-radius: 6px; min-height: 24px; } QScrollBar::handle:vertical:hover { background: #4a4a4a; } """ # ***************************************************************************** # Hilfsfunktionen # ***************************************************************************** def format_size(size_bytes: int) -> str: """Formatiert eine Byte-Angabe menschenlesbar.""" size = float(size_bytes) for unit in ("Bytes", "KB", "MB", "GB", "TB"): if size < 1024.0: return f"{size:.2f} {unit}" if unit != "Bytes" else f"{int(size)} {unit}" size /= 1024.0 return f"{size:.2f} PB" def get_file_info(path: str) -> dict: """Liest Name, Erstellungsdatum und Größe einer Datei aus.""" stat = os.stat(path) # Hinweis: os.path.getctime liefert unter Windows das Erstellungsdatum, # unter Linux/Mac eher das Datum der letzten Metadaten-Änderung. created = datetime.fromtimestamp(stat.st_ctime).strftime("%d.%m.%Y %H:%M:%S") modified = datetime.fromtimestamp(stat.st_mtime).strftime("%d.%m.%Y %H:%M:%S") return { "name": os.path.basename(path), "path": path, "created": created, "modified": modified, "size": format_size(stat.st_size), "size_bytes": stat.st_size, } def read_text_with_fallback(path: str) -> str: """Liest eine Textdatei, versucht UTF-8 und fällt sonst auf Latin-1 zurück.""" try: with open(path, "r", encoding="utf-8") as f: return f.read() except UnicodeDecodeError: with open(path, "r", encoding="latin-1") as f: return f.read() # ***************************************************************************** # Formatierter Text ohne HTML-Syntax # ***************************************************************************** class RichTextWriter: """ Schreibt farbig/fett formatierten Text in ein QTextEdit. Nutzt QTextCursor/QTextCharFormat statt HTML-Strings - im Code taucht dadurch keinerlei HTML-Syntax auf. """ def __init__(self, text_edit: QTextEdit): self.edit = text_edit def clear(self): self.edit.clear() def _end_cursor(self) -> QTextCursor: cursor = self.edit.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) return cursor def write(self, text: str, color: str = None, bold: bool = False, italic: bool = False, background: str = None): """Fügt Text mit optionaler Formatierung an, ohne Zeilenumbruch.""" char_format = QTextCharFormat() if color: char_format.setForeground(QColor(color)) if background: char_format.setBackground(QColor(background)) if bold: char_format.setFontWeight(QFont.Weight.Bold) if italic: char_format.setFontItalic(True) cursor = self._end_cursor() cursor.insertText(text, char_format) self.edit.setTextCursor(cursor) def newline(self): cursor = self._end_cursor() cursor.insertBlock() self.edit.setTextCursor(cursor) def write_line(self, text: str = "", **kwargs): """Fügt Text an und schließt mit einem Zeilenumbruch ab.""" self.write(text, **kwargs) self.newline() # ***************************************************************************** # Hauptfenster # ***************************************************************************** class FileComparerApp(QMainWindow): TEXT_EXTENSIONS = { ".txt", ".md", ".csv", ".json", ".xml", ".py", ".html", ".htm", ".css", ".js", ".log", ".ini", ".yaml", ".yml", ".cfg", ".conf", ".java", ".c", ".cpp", ".h", ".sh", ".bat", ".ts", ".sql" } IMAGE_EXTENSIONS = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif", ".webp", ".ico" } MAX_BINARY_DIFFS = 100 # max. Anzahl angezeigter Byte-Unterschiede MAX_TEXT_DIFF_LINES = 400 # max. Anzahl angezeigter Diff-Zeilen def __init__(self): super().__init__() self.file_a = None self.file_b = None self.init_ui() # ***************************************************************************** # UI-Aufbau # ***************************************************************************** def init_ui(self): self.setWindowTitle("Datei Vergleichen") self.resize(920, 720) central = QWidget() self.setCentralWidget(central) main_layout = QVBoxLayout(central) main_layout.setSpacing(14) main_layout.setContentsMargins(20, 20, 20, 20) title = QLabel("📁 Datei Vergleichen") title.setObjectName("titleLabel") main_layout.addWidget(title) # --- Datei-Auswahl --- files_layout = QHBoxLayout() files_layout.setSpacing(14) self.group_a, self.btn_a, self.label_a = self._create_file_group( "Datei A", "fileButtonA", self.load_file_a ) self.group_b, self.btn_b, self.label_b = self._create_file_group( "Datei B", "fileButtonB", self.load_file_b ) files_layout.addWidget(self.group_a) files_layout.addWidget(self.group_b) main_layout.addLayout(files_layout) # --- Optionen / Vergleichsstart --- options_layout = QHBoxLayout() mode_label = QLabel("Vergleichsart:") self.mode_combo = QComboBox() self.mode_combo.addItems(["Automatisch", "Text", "Bild", "Binär"]) self.compare_btn = QPushButton("🔍 Vergleichen") self.compare_btn.setObjectName("compareButton") self.compare_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.compare_btn.clicked.connect(self.compare_files) self.export_btn = QPushButton("💾 Exportieren") self.export_btn.setObjectName("exportButton") self.export_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.export_btn.setEnabled(False) self.export_btn.clicked.connect(self.export_results) options_layout.addWidget(mode_label) options_layout.addWidget(self.mode_combo) options_layout.addStretch() options_layout.addWidget(self.compare_btn) options_layout.addWidget(self.export_btn) main_layout.addLayout(options_layout) self.statusBar().showMessage("Bereit.") # --- Infofeld --- result_label = QLabel("Ergebnis") result_label.setObjectName("sectionLabel") main_layout.addWidget(result_label) self.info_field = QTextEdit() self.info_field.setObjectName("infoField") self.info_field.setReadOnly(True) mono_font = QFont("Consolas") mono_font.setStyleHint(QFont.StyleHint.Monospace) mono_font.setPointSize(10) self.info_field.setFont(mono_font) main_layout.addWidget(self.info_field, stretch=1) self.writer = RichTextWriter(self.info_field) self._show_placeholder() def _create_file_group(self, title: str, button_id: str, handler): group = QGroupBox(title) layout = QVBoxLayout(group) btn = QPushButton(f"📂 {title} wählen") btn.setObjectName(button_id) btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.clicked.connect(handler) path_label = QLabel("Keine Datei ausgewählt") path_label.setObjectName("pathLabel") path_label.setWordWrap(True) layout.addWidget(btn) layout.addWidget(path_label) return group, btn, path_label def _show_placeholder(self): self.writer.clear() self.writer.write_line( "Bitte wähle Datei A und Datei B aus und klicke auf „Vergleichen“.", color="#9a9a9a", italic=True ) # ***************************************************************************** # Datei-Auswahl # ***************************************************************************** def load_file_a(self): path, _ = QFileDialog.getOpenFileName(self, "Datei A auswählen") if path: self.file_a = path self.label_a.setText(path) def load_file_b(self): path, _ = QFileDialog.getOpenFileName(self, "Datei B auswählen") if path: self.file_b = path self.label_b.setText(path) # ***************************************************************************** # Vergleichslogik # ***************************************************************************** def detect_mode(self, path_a: str, path_b: str) -> str: ext_a = Path(path_a).suffix.lower() ext_b = Path(path_b).suffix.lower() if ext_a in self.IMAGE_EXTENSIONS or ext_b in self.IMAGE_EXTENSIONS: return "Bild" if ext_a in self.TEXT_EXTENSIONS or ext_b in self.TEXT_EXTENSIONS: return "Text" return "Binär" def compare_files(self): self.writer.clear() if not self.file_a or not self.file_b: self._render_error("Bitte wähle beide Dateien aus, bevor du den Vergleich startest.") return if not os.path.isfile(self.file_a) or not os.path.isfile(self.file_b): self._render_error("Eine der ausgewählten Dateien existiert nicht mehr oder ist nicht lesbar.") return try: info_a = get_file_info(self.file_a) info_b = get_file_info(self.file_b) except OSError as e: self._render_error(f"Fehler beim Lesen der Datei-Informationen: {e}") return mode = self.mode_combo.currentText() if mode == "Automatisch": mode = self.detect_mode(self.file_a, self.file_b) self._write_details(info_a, info_b, mode) try: if mode == "Text": self._compare_text(self.file_a, self.file_b) elif mode == "Bild": self._compare_images(self.file_a, self.file_b) else: self._compare_binary(self.file_a, self.file_b) except Exception as e: self.writer.write_line("⚠ Fehler beim Vergleich", color="#f14c4c", bold=True) self.writer.write_line(str(e), color="#f14c4c") self.export_btn.setEnabled(True) self.statusBar().showMessage("Vergleich mit Fehler beendet.", 5000) return self.export_btn.setEnabled(True) self.statusBar().showMessage("Vergleich abgeschlossen.", 5000) # ***************************************************************************** # Detail-Anzeige (Name, Datum, Größe) # ***************************************************************************** def _write_details(self, info_a: dict, info_b: dict, mode: str): self.writer.write_line(f"Vergleichsart: {mode}", color="#569cd6", bold=True) self.writer.newline() self._write_file_block(info_a, "#2d9d55", "Datei A") self.writer.newline() self._write_file_block(info_b, "#e08a3f", "Datei B") self.writer.newline() self.writer.write_line("-" * 64, color="#3c3c3c") self.writer.newline() def _write_file_block(self, info: dict, farbe: str, titel: str): self.writer.write_line(titel, color=farbe, bold=True) self.writer.write(" Name: ", color="#9a9a9a") self.writer.write_line(info["name"]) self.writer.write(" Erstellt: ", color="#9a9a9a") self.writer.write_line(info["created"]) self.writer.write(" Geändert: ", color="#9a9a9a") self.writer.write_line(info["modified"]) self.writer.write(" Größe: ", color="#9a9a9a") self.writer.write_line(info["size"]) # ***************************************************************************** # Export der Ergebnisse # ***************************************************************************** def export_results(self): if not self.info_field.toPlainText().strip(): return default_name = "vergleichsergebnis.txt" path, selected_filter = QFileDialog.getSaveFileName( self, "Ergebnisse exportieren", default_name, "Textdatei (*.txt);;HTML-Datei (*.html)" ) if not path: return wants_html = path.lower().endswith(".html") or "HTML" in selected_filter if wants_html and not path.lower().endswith(".html"): path += ".html" elif not wants_html and not path.lower().endswith(".txt"): path += ".txt" try: if wants_html: content = self.info_field.toHtml() else: content = self.info_field.toPlainText() with open(path, "w", encoding="utf-8") as f: f.write(content) except OSError as e: self.statusBar().showMessage(f"⚠ Export fehlgeschlagen: {e}", 8000) return self.statusBar().showMessage(f"Ergebnisse exportiert nach: {path}", 6000) def _render_error(self, message: str): self.writer.clear() self.writer.write_line("⚠ Fehler", color="#f14c4c", bold=True) self.writer.write_line(message, color="#f14c4c") # ***************************************************************************** # Text-Vergleich # ***************************************************************************** def _compare_text(self, path_a: str, path_b: str): text_a = read_text_with_fallback(path_a) text_b = read_text_with_fallback(path_b) if text_a == text_b: self.writer.write_line("✔ Die Textdateien sind identisch.", color="#4ec9b0", bold=True) return lines_a = text_a.splitlines() lines_b = text_b.splitlines() diff = list(difflib.unified_diff( lines_a, lines_b, fromfile="Datei A", tofile="Datei B", lineterm="" )) truncated = len(diff) > self.MAX_TEXT_DIFF_LINES diff = diff[:self.MAX_TEXT_DIFF_LINES] self.writer.write_line("✘ Die Textdateien unterscheiden sich.", color="#f14c4c", bold=True) self.writer.newline() for line in diff: if line.startswith("+++") or line.startswith("---"): self.writer.write_line(line, color="#569cd6", bold=True) elif line.startswith("@@"): self.writer.write_line(line, color="#c586c0") elif line.startswith("+"): self.writer.write_line(line, color="#4ec9b0", background="#1b3a2b") elif line.startswith("-"): self.writer.write_line(line, color="#f14c4c", background="#3a1b1b") else: self.writer.write_line(line, color="#9a9a9a") if truncated: self.writer.newline() self.writer.write_line( f"… Ausgabe nach {self.MAX_TEXT_DIFF_LINES} Zeilen gekürzt.", color="#9a9a9a", italic=True ) # ***************************************************************************** # Bild-Vergleich # ***************************************************************************** def _compare_images(self, path_a: str, path_b: str): if not PIL_AVAILABLE: raise RuntimeError( "Das Paket „Pillow“ ist nicht installiert. Bitte mit " "„pip install Pillow“ nachinstallieren, um Bilder vergleichen zu können." ) img_a = Image.open(path_a) img_b = Image.open(path_b) if img_a.size != img_b.size: self.writer.write_line("✘ Die Bilder sind unterschiedlich.", color="#f14c4c", bold=True) self.writer.write_line( f"Unterschiedliche Bildgröße: {img_a.size[0]}×{img_a.size[1]} px " f"vs. {img_b.size[0]}×{img_b.size[1]} px.", color="#9a9a9a" ) return img_a_conv = img_a.convert("RGBA") img_b_conv = img_b.convert("RGBA") diff = ImageChops.difference(img_a_conv, img_b_conv) if diff.getbbox() is None: self.writer.write_line("✔ Die Bilder sind identisch.", color="#4ec9b0", bold=True) else: self.writer.write_line("✘ Die Bilder sind unterschiedlich.", color="#f14c4c", bold=True) # ***************************************************************************** # Binär-Vergleich # ***************************************************************************** def _compare_binary(self, path_a: str, path_b: str): with open(path_a, "rb") as f: data_a = f.read() with open(path_b, "rb") as f: data_b = f.read() hash_a = hashlib.sha256(data_a).hexdigest() hash_b = hashlib.sha256(data_b).hexdigest() if hash_a == hash_b: self.writer.write_line( "✔ Die Dateien sind binär identisch (SHA-256 stimmt überein).", color="#4ec9b0", bold=True ) return self.writer.write_line("✘ Die Dateien unterscheiden sich.", color="#f14c4c", bold=True) len_a, len_b = len(data_a), len(data_b) if len_a != len_b: self.writer.write_line( f"Unterschiedliche Dateigröße: {format_size(len_a)} vs. {format_size(len_b)}.", color="#9a9a9a" ) self.writer.newline() min_len = min(len_a, len_b) diffs = [] for i in range(min_len): if data_a[i] != data_b[i]: diffs.append(i) if len(diffs) >= self.MAX_BINARY_DIFFS: break if not diffs: self.writer.write_line( "Keine byteweisen Unterschiede im gemeinsamen Bereich gefunden.", color="#9a9a9a" ) return for offset in diffs: self.writer.write(f"Offset 0x{offset:08X} ", color="#9a9a9a") self.writer.write("A=", color="#9a9a9a") self.writer.write(f"0x{data_a[offset]:02X}", color="#4ec9b0") self.writer.write(" B=", color="#9a9a9a") self.writer.write_line(f"0x{data_b[offset]:02X}", color="#e08a3f") if len(diffs) >= self.MAX_BINARY_DIFFS: self.writer.newline() self.writer.write_line( f"… Ausgabe nach {self.MAX_BINARY_DIFFS} Unterschieden gekürzt.", color="#9a9a9a", italic=True ) # ***************************************************************************** # Einstiegspunkt # ***************************************************************************** def main(): app = QApplication(sys.argv) app.setStyleSheet(DARK_STYLESHEET) window = FileComparerApp() window.show() sys.exit(app.exec()) if __name__ == "__main__": main() # ***************************************************************************** # Ende # *****************************************************************************


Grober Aufbau. Die Datei gliedert sich in 5 Blöcke:
Imports & optionales Pillow.
DARK_STYLESHEET – das Aussehen.
Freie Hilfsfunktionen (format_size, get_file_info, read_text_with_fallback).
RichTextWriter – schreibt formatierten Text ins Infofeld.
FileComparerApp – das eigentliche Fenster mit der ganzen Logik.
main() – Startpunkt.

Imports (Zeile 11–30):
Neben Standardbibliotheken (os, difflib für Textvergleiche, hashlib für Prüfsummen, pathlib, datetime) kommen die PyQt6-Bausteine für UI-Elemente. Pillow (PIL) wird optional importiert – falls es fehlt, bleibt PIL_AVAILABLE = False, und der Bildvergleich meldet später einen sauberen Fehler statt eines Absturzes.

DARK_STYLESHEET:
Ein großer String im QSS-Format (Qt Style Sheets – CSS-ähnlich, aber für Qt-Widgets). Jeder Button/jedes Label hat über setObjectName(...) einen eindeutigen Namen (z. B. fileButtonA), auf den sich hier ein Selektor wie QPushButton#fileButtonA bezieht. So bekommt jeder Button seine eigene Farbe, ohne dass man Farben im Python-Code hart verdrahten muss.

Hilfsfunktionen:
format_size: rechnet Bytes in KB/MB/GB um.
get_file_info: liest über os.stat() Name, Erstellungs-/Änderungsdatum und Größe einer Datei aus.
read_text_with_fallback: versucht eine Textdatei als UTF-8 zu lesen, fällt bei Fehlern auf Latin-1 zurück (deckt die meisten europäischen Textdateien ab).

RichTextWriter:
Er kapselt drei Operationen auf dem QTextEdit: 1. write(text, color=..., bold=..., ...) – fügt Text mit einem QTextCharFormat an der aktuellen Cursor-Position ein. 2. newline() – erzeugt einen neuen Absatz. 3. write_line(...) – Kombination aus beidem.

FileComparerApp. Das Hauptfenster:
init_ui() baut die Oberfläche von oben nach unten auf:
Titel-Label
Zwei QGroupBoxen nebeneinander (Datei A / B) mit je einem Button und einem Pfad-Label — erzeugt über die kleine Fabrikmethode _create_file_group(), damit der Code nicht doppelt existiert.
Eine Optionszeile: Dropdown für die Vergleichsart, „Vergleichen“- und „Exportieren“-Button.
Eine Statusleiste (self.statusBar()) für kurze Meldungen.
Das QTextEdit-Infofeld mit fester Monospace-Schrift, darüber wird self.writer als RichTextWriter angelegt.
Datei-Auswahl: (load_file_a/load_file_b): öffnet QFileDialog.getOpenFileName, speichert den Pfad in self.file_a/self.file_b und zeigt ihn im Label an.
detect_mode(): schaut sich die Dateiendungen an und rät bei „Automatisch“ zwischen Text/Bild/Binär.

compare_files() ist der zentrale Ablauf, der beim Klick auf „Vergleichen“ läuft:
Infofeld leeren.
Prüfen: Sind beide Dateien gewählt und existieren sie noch?
get_file_info() für beide Dateien holen.
Modus bestimmen (bzw. automatisch erkennen).
_write_details() schreibt den Block mit Name/Datum/Größe für A und B.
Je nach Modus wird _compare_text, _compare_images oder _compare_binary aufgerufen – die schreiben ihr Ergebnis direkt über self.writer ins Feld.
Bei einer Exception (z. B. Pillow fehlt) wird die Fehlermeldung rot ausgegeben, statt dass das Programm abstürzt.
Am Ende wird der „Exportieren“-Button aktiviert.

Die drei Vergleichsmethoden im Detail:
_compare_text: nutzt difflib.unified_diff, um zeilenweise Unterschiede zu berechnen, und färbt Zeilen ein (Rot = entfernt, Grün = hinzugefügt, Blau = Dateikopf).
_compare_images: öffnet beide Bilder mit Pillow, vergleicht bei gleicher Größe die Pixel per ImageChops. difference – ist die „Differenzbox“ leer, sind die Bilder identisch.
_compare_binary: vergleicht zuerst per SHA-256-Hash (schneller Identisch-Check), listet bei Unterschieden sonst die ersten 100 abweichenden Byte-Offsets in Hex auf.
export_results(): öffnet einen Speichern-Dialog, schreibt je nach gewählter Endung entweder toPlainText() (reiner Text) oder toHtml() (von Qt automatisch generiertes HTML mit allen Farben) in eine Datei.

main():
Erstellt die QApplication, wendet das Stylesheet global an, zeigt das Fenster und startet die Event-Loop mit app.exec().

Der Diff-Teil steckt komplett in difflib.unified_diff() aus der Python-Standardbibliothek – hier lohnt sich ein genauerer Blick, wie das eigentlich funktioniert:

diff = list(difflib.unified_diff(
lines_a, lines_b,
fromfile="Datei A", tofile="Datei B",
lineterm=""))


lines_a/lines_b sind Listen von Strings (text.splitlines()) – der Vergleich läuft also zeilenweise, nicht zeichenweise. Zwei Dateien, die sich nur durch ein einziges Leerzeichen mitten in einer langen Zeile unterscheiden, zeigen als Diff trotzdem die komplette Zeile als geändert – das ist eine bewusste Grenze von unified_diff.
Der Algorithmus dahinter: SequenceMatcher unified_diff ruft intern difflib.SequenceMatcher auf. Das ist eine Variante des Ratcliff-Obershelp-Algorithmus, kein klassischer LCS-Algorithmus (wie diff unter Linux ihn nutzt), auch wenn das Ergebnis ähnlich aussieht. Grob passiert das:
Längsten gemeinsamen zusammenhängenden Block finden zwischen lines_a und lines_b (nicht die längste gemeinsame Teilfolge über die ganze Datei, sondern den längsten zusammenhängenden Treffer).
Dieser Block teilt beide Listen in einen Bereich davor und einen Bereich danach.
Schritt 1 wird rekursiv auf beide Teilbereiche angewendet, bis nichts mehr übrig ist.
Am Ende ergibt sich eine Folge von Opcodes: equal, replace, insert, delete – jeweils mit Start-/Endindex in beiden Listen.

Für die Blockerkennung nutzt SequenceMatcher einen Index (Dictionary: Zeileninhalt → Positionen), damit er nicht bei jedem Vergleich quadratisch alles durchsuchen muss. Es gibt zusätzlich eine „Autojunk"-Heuristik: Zeilen, die in einer der beiden Dateien sehr häufig vorkommen (Standard: mehr als 1 % aller Zeilen bei Dateien >200 Zeilen), werden für die Blocksuche ignoriert, damit z. B. viele leere Zeilen die Treffererkennung nicht verfälschen.

Das Ausgabeformat (Unified Diff) unified_diff verpackt die Opcodes in das bekannte Format, das diff -u auch erzeugt:

--- Datei A
+++ Datei B
@@ -12,3 +12,4 @@
unveränderte Zeile
-alte Zeile
+neue Zeile
+noch eine neue Zeile
unveränderte Zeile


--- Datei A / +++ Datei B – die beiden Dateinamen (kommen von fromfile/tofile).
@@ -12,3 +12,4 @@ – der „Hunk-Header": ab Zeile 12 werden 3 Zeilen aus A bzw. 4 Zeilen aus B gezeigt.
Zeilen ohne Präfix = unverändert (Kontext).
- = nur in A vorhanden (entfernt).
+ = nur in B vorhanden (hinzugefügt).

Warum lineterm=""? splitlines() entfernt die Zeilenumbrüche bereits. unified_diff würde von sich aus \n an ---/+++/@@-Zeilen anhängen (praktisch für Datei-Ausgabe). Da wir die Zeilen selbst über write_line() mit einem eigenen newline() abschließen, würde ein zusätzliches \n nur eine Leerzeile erzeugen – lineterm="" unterdrückt das.

Ab 400 Zeilen ist Schluss!! Bei riesigen Dateien mit tausenden Unterschieden würde das Infofeld sonst unbrauchbar lang werden. Der Diff wird nach 400 Zeilen (Klassenkonstante MAX_TEXT_DIFF_LINES) abgeschnitten, mit einem Hinweis am Ende.



Zurück