#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Soft-Team-PDF-Engine
=============

Ein barrierefreies PDF-Werkzeug auf Basis von wxPython.

Funktionen:
    - Mehrere PDF-Dateien in beliebiger, frei wählbarer Reihenfolge
      zu einer einzigen PDF-Datei zusammenfügen (PDF-1 + PDF-2 + ... + PDF-n)
    - Einzelne Seiten aus einer PDF entfernen
    - PDF in einzelne Seiten aufteilen (Trennen)
    - Seitenbereich aus einer PDF extrahieren
    - Seiten drehen (90 / 180 / 270 Grad)
    - PDF mit einem Passwort verschlüsseln
    - Passwortschutz von einer PDF entfernen
    - Metadaten (Titel, Autor) anzeigen und ändern
    - PDF-Seiten als Bilddateien (PNG/JPG) exportieren
    - Mehrere Bilddateien zu einer PDF-Datei zusammenfügen
    - PDF-Dateigröße verkleinern (komprimieren)
    - Wasserzeichen-Text auf allen Seiten einfügen
    - Seitenzahlen auf allen Seiten einfügen
    - Datum und Unterschrift (Bilddatei oder Schreibschrift-Name) auf
      wählbaren Seiten einfügen
    - Text aus einer PDF-Datei in eine TXT-Datei extrahieren

Hinweis zur Barrierefreiheit (NVDA / JAWS):
    - Jedes Bedienelement besitzt ein sichtbares Label mit Tastenkürzel (&).
    - Jedes Bedienelement besitzt zusätzlich SetName() / SetHelpText(),
      damit Screenreader einen aussagekräftigen Namen vorlesen.
    - Die Tab-Reihenfolge folgt der visuellen/logischen Reihenfolge.
    - Wichtige Aktionen sind zusätzlich per Tastenkombination erreichbar.
    - Statusleiste und Meldungsfenster geben Rückmeldung zu jeder Aktion.
"""

import os
import sys
import wx
import wx.adv

from pypdf import PdfReader, PdfWriter
from pypdf.errors import PdfReadError

try:
    import fitz  # PyMuPDF, für PDF<->Bild und Textextraktion
    HAVE_FITZ = True
except ImportError:
    HAVE_FITZ = False

try:
    from PIL import Image
    HAVE_PIL = True
except ImportError:
    HAVE_PIL = False

try:
    import numpy as np
    HAVE_NUMPY = True
except ImportError:
    HAVE_NUMPY = False


APP_NAME = "Soft-Team-PDF-Engine"
APP_VERSION = "1.4"


# ---------------------------------------------------------------------------
# Hilfsfunktionen (PDF-Logik, unabhängig von der Oberfläche)
# ---------------------------------------------------------------------------

def merge_pdfs(file_list, output_path):
    """
    Fügt mehrere PDF-Dateien in der angegebenen Reihenfolge zusammen.
    file_list[0] wird zuerst eingefügt, file_list[1] wird direkt darunter
    angehängt, usw. Das Ergebnis wird unter output_path gespeichert.
    """
    writer = PdfWriter()
    for path in file_list:
        reader = PdfReader(path)
        if reader.is_encrypted:
            raise PdfReadError(
                "Datei ist passwortgeschützt: {0}".format(os.path.basename(path))
            )
        for page in reader.pages:
            writer.add_page(page)
    with open(output_path, "wb") as f:
        writer.write(f)
    return True


def split_pdf(input_path, output_dir):
    """
    Zerlegt eine PDF-Datei in einzelne Seiten.
    Jede Seite wird als eigene Datei gespeichert: <name>_Seite_001.pdf usw.
    Gibt die Anzahl der erzeugten Dateien zurück.
    """
    reader = PdfReader(input_path)
    if reader.is_encrypted:
        raise PdfReadError("Datei ist passwortgeschützt.")
    base_name = os.path.splitext(os.path.basename(input_path))[0]
    count = len(reader.pages)
    digits = max(3, len(str(count)))
    for i, page in enumerate(reader.pages, start=1):
        writer = PdfWriter()
        writer.add_page(page)
        out_name = "{0}_Seite_{1}.pdf".format(base_name, str(i).zfill(digits))
        out_path = os.path.join(output_dir, out_name)
        with open(out_path, "wb") as f:
            writer.write(f)
    return count


def extract_pages(input_path, output_path, start_page, end_page):
    """
    Extrahiert einen Seitenbereich (1-basiert, inklusive) aus einer PDF
    und speichert ihn als neue Datei.
    """
    reader = PdfReader(input_path)
    if reader.is_encrypted:
        raise PdfReadError("Datei ist passwortgeschützt.")
    total = len(reader.pages)
    if start_page < 1 or end_page > total or start_page > end_page:
        raise ValueError(
            "Ungültiger Seitenbereich. Die Datei hat {0} Seiten.".format(total)
        )
    writer = PdfWriter()
    for i in range(start_page - 1, end_page):
        writer.add_page(reader.pages[i])
    with open(output_path, "wb") as f:
        writer.write(f)
    return True


def remove_pages(input_path, output_path, pages_to_remove):
    """
    Entfernt die angegebenen Seiten (1-basierte Seitenzahlen, Menge/Liste)
    aus einer PDF-Datei und speichert das Ergebnis.
    """
    reader = PdfReader(input_path)
    if reader.is_encrypted:
        raise PdfReadError("Datei ist passwortgeschützt.")
    total = len(reader.pages)
    remove_set = set(pages_to_remove)
    for p in remove_set:
        if p < 1 or p > total:
            raise ValueError(
                "Seite {0} existiert nicht. Die Datei hat {1} Seiten.".format(p, total)
            )
    if len(remove_set) >= total:
        raise ValueError("Es können nicht alle Seiten entfernt werden.")
    writer = PdfWriter()
    for i in range(total):
        if (i + 1) not in remove_set:
            writer.add_page(reader.pages[i])
    with open(output_path, "wb") as f:
        writer.write(f)
    return True


def rotate_pdf(input_path, output_path, angle):
    """
    Dreht alle Seiten einer PDF um den angegebenen Winkel
    (90, 180 oder 270 Grad, im Uhrzeigersinn) und speichert das Ergebnis.
    """
    reader = PdfReader(input_path)
    if reader.is_encrypted:
        raise PdfReadError("Datei ist passwortgeschützt.")
    writer = PdfWriter()
    for page in reader.pages:
        page.rotate(angle)
        writer.add_page(page)
    with open(output_path, "wb") as f:
        writer.write(f)
    return True


def encrypt_pdf(input_path, output_path, user_password, owner_password=None):
    """
    Verschlüsselt eine PDF-Datei mit einem Benutzerpasswort
    (optional zusätzlich ein Eigentümerpasswort).
    """
    reader = PdfReader(input_path)
    if reader.is_encrypted:
        raise PdfReadError("Datei ist bereits passwortgeschützt.")
    writer = PdfWriter()
    for page in reader.pages:
        writer.add_page(page)
    if owner_password:
        writer.encrypt(user_password=user_password, owner_password=owner_password)
    else:
        writer.encrypt(user_password=user_password)
    with open(output_path, "wb") as f:
        writer.write(f)
    return True


def decrypt_pdf(input_path, output_path, password):
    """
    Entfernt den Passwortschutz einer PDF-Datei, sofern das Passwort korrekt ist.
    """
    reader = PdfReader(input_path)
    if reader.is_encrypted:
        ok = reader.decrypt(password)
        if not ok:
            raise ValueError("Das Passwort ist falsch.")
    writer = PdfWriter()
    for page in reader.pages:
        writer.add_page(page)
    with open(output_path, "wb") as f:
        writer.write(f)
    return True


def get_pdf_info(input_path):
    """
    Liefert grundlegende Informationen zu einer PDF-Datei zurück:
    Seitenzahl, Titel, Autor, Verschlüsselungsstatus.
    """
    reader = PdfReader(input_path)
    info = {
        "pages": "unbekannt (passwortgeschützt)",
        "title": "unbekannt",
        "author": "unbekannt",
        "encrypted": reader.is_encrypted,
    }
    if not reader.is_encrypted:
        info["pages"] = len(reader.pages)
        meta = reader.metadata
        if meta:
            info["title"] = meta.title or "(kein Titel hinterlegt)"
            info["author"] = meta.author or "(kein Autor hinterlegt)"
    return info


def set_pdf_metadata(input_path, output_path, title, author):
    """
    Setzt Titel und Autor einer PDF-Datei und speichert das Ergebnis
    unter output_path.
    """
    reader = PdfReader(input_path)
    if reader.is_encrypted:
        raise PdfReadError("Datei ist passwortgeschützt.")
    writer = PdfWriter()
    for page in reader.pages:
        writer.add_page(page)
    writer.add_metadata({"/Title": title, "/Author": author})
    with open(output_path, "wb") as f:
        writer.write(f)
    return True


def pdf_to_images(input_path, output_dir, image_format="png", dpi=150):
    """
    Wandelt jede Seite einer PDF-Datei in eine Bilddatei um (PNG oder JPG).
    Benötigt PyMuPDF (fitz). Gibt die Anzahl der erzeugten Bilddateien zurück.
    """
    if not HAVE_FITZ:
        raise RuntimeError(
            "Diese Funktion benötigt das Paket 'PyMuPDF' (fitz), das "
            "nicht installiert ist."
        )
    image_format = image_format.lower()
    if image_format not in ("png", "jpg", "jpeg"):
        raise ValueError("Ungültiges Bildformat: {0}".format(image_format))
    ext = "jpg" if image_format in ("jpg", "jpeg") else "png"

    doc = fitz.open(input_path)
    try:
        if doc.is_encrypted:
            raise PdfReadError("Datei ist passwortgeschützt.")
        base_name = os.path.splitext(os.path.basename(input_path))[0]
        count = doc.page_count
        digits = max(3, len(str(count)))
        zoom = dpi / 72.0
        matrix = fitz.Matrix(zoom, zoom)
        for i in range(count):
            page = doc.load_page(i)
            pix = page.get_pixmap(matrix=matrix)
            out_name = "{0}_Seite_{1}.{2}".format(
                base_name, str(i + 1).zfill(digits), ext
            )
            out_path = os.path.join(output_dir, out_name)
            pix.save(out_path)
        return count
    finally:
        doc.close()


def images_to_pdf(image_paths, output_path):
    """
    Fügt mehrere Bilddateien (PNG, JPG, BMP, ...) in der angegebenen
    Reihenfolge zu einer einzigen PDF-Datei zusammen, eine Seite je Bild.
    Benötigt Pillow.
    """
    if not HAVE_PIL:
        raise RuntimeError(
            "Diese Funktion benötigt das Paket 'Pillow', das nicht "
            "installiert ist."
        )
    if not image_paths:
        raise ValueError("Es wurden keine Bilddateien ausgewählt.")

    converted_images = []
    for path in image_paths:
        img = Image.open(path)
        if img.mode != "RGB":
            img = img.convert("RGB")
        converted_images.append(img)

    first_image = converted_images[0]
    rest_images = converted_images[1:]
    first_image.save(
        output_path, save_all=True, append_images=rest_images
    )
    return len(converted_images)


def compress_pdf(input_path, output_path, image_quality="mittel"):
    """
    Verkleinert die Dateigröße einer PDF-Datei. Wendet verlustbehaftete
    Komprimierung auf eingebettete Bilder an (sofern PyMuPDF verfügbar ist)
    und schreibt die Datei kompakter neu. Gibt ein Tupel
    (Originalgröße, neue Größe) in Byte zurück.
    """
    original_size = os.path.getsize(input_path)

    quality_map = {"niedrig": 30, "mittel": 55, "hoch": 80}
    jpeg_quality = quality_map.get(image_quality, 55)

    if HAVE_FITZ:
        doc = fitz.open(input_path)
        try:
            if doc.is_encrypted:
                raise PdfReadError("Datei ist passwortgeschützt.")
            for page in doc:
                images = page.get_images(full=True)
                for img in images:
                    xref = img[0]
                    try:
                        pix = fitz.Pixmap(doc, xref)
                        if pix.colorspace and pix.colorspace.n > 4:
                            pix = None
                            continue
                        if pix.alpha:
                            pix = fitz.Pixmap(pix, 0)
                        img_bytes = pix.tobytes("jpeg", jpg_quality=jpeg_quality)
                        doc.update_stream(xref, img_bytes)
                        pix = None
                    except Exception:
                        # Einzelne Bilder, die nicht komprimiert werden
                        # können, werden uebersprungen statt den ganzen
                        # Vorgang abzubrechen.
                        continue
            doc.save(
                output_path,
                garbage=4,
                deflate=True,
                deflate_images=True,
                deflate_fonts=True,
            )
        finally:
            doc.close()
    else:
        # Ohne PyMuPDF nur strukturelle Komprimierung ueber pypdf.
        reader = PdfReader(input_path)
        if reader.is_encrypted:
            raise PdfReadError("Datei ist passwortgeschützt.")
        writer = PdfWriter()
        for page in reader.pages:
            page.compress_content_streams()
            writer.add_page(page)
        with open(output_path, "wb") as f:
            writer.write(f)

    new_size = os.path.getsize(output_path)
    return original_size, new_size


def add_watermark_text(input_path, output_path, text, font_size=40, opacity=0.3):
    """
    Fügt auf jeder Seite der PDF-Datei einen diagonalen Wasserzeichen-Text
    ein. Benötigt PyMuPDF (fitz).
    """
    if not HAVE_FITZ:
        raise RuntimeError(
            "Diese Funktion benötigt das Paket 'PyMuPDF' (fitz), das "
            "nicht installiert ist."
        )
    if not text:
        raise ValueError("Bitte einen Wasserzeichen-Text eingeben.")

    doc = fitz.open(input_path)
    try:
        if doc.is_encrypted:
            raise PdfReadError("Datei ist passwortgeschützt.")
        gray = 0.5
        for page in doc:
            rect = page.rect
            center = fitz.Point(rect.width / 2.0, rect.height / 2.0)
            # 45 Grad gegen den Uhrzeigersinn um die Seitenmitte drehen,
            # da insert_text() selbst nur Vielfache von 90 Grad erlaubt.
            morph = (center, fitz.Matrix(45))
            page.insert_text(
                fitz.Point(rect.width * 0.15, rect.height * 0.55),
                text,
                fontsize=font_size,
                color=(gray, gray, gray),
                fill_opacity=opacity,
                overlay=True,
                morph=morph,
            )
        doc.save(output_path, deflate=True)
    finally:
        doc.close()
    return True


def add_page_numbers(input_path, output_path, start_number=1, position="unten-mitte"):
    """
    Fügt auf jeder Seite der PDF-Datei eine fortlaufende Seitenzahl ein.
    position kann sein: "unten-mitte", "unten-rechts", "unten-links".
    Benötigt PyMuPDF (fitz).
    """
    if not HAVE_FITZ:
        raise RuntimeError(
            "Diese Funktion benötigt das Paket 'PyMuPDF' (fitz), das "
            "nicht installiert ist."
        )
    doc = fitz.open(input_path)
    try:
        if doc.is_encrypted:
            raise PdfReadError("Datei ist passwortgeschützt.")
        number = start_number
        margin = 24
        for page in doc:
            rect = page.rect
            label = str(number)
            font_size = 11
            text_width = fitz.get_text_length(label, fontsize=font_size)
            if position == "unten-rechts":
                x = rect.width - margin - text_width
            elif position == "unten-links":
                x = margin
            else:
                x = (rect.width - text_width) / 2.0
            y = rect.height - margin
            page.insert_text(
                fitz.Point(x, y),
                label,
                fontsize=font_size,
                color=(0, 0, 0),
            )
            number += 1
        doc.save(output_path, deflate=True)
    finally:
        doc.close()
    return True


def extract_text_from_pdf(input_path, output_path):
    """
    Extrahiert den gesamten Text einer PDF-Datei und speichert ihn als
    TXT-Datei (UTF-8). Seiten werden durch eine Trennzeile mit der
    jeweiligen Seitenzahl gekennzeichnet. Gibt die Anzahl der Seiten zurück.
    Funktioniert mit PyMuPDF, falls vorhanden, sonst mit pypdf.
    """
    if HAVE_FITZ:
        doc = fitz.open(input_path)
        try:
            if doc.is_encrypted:
                raise PdfReadError("Datei ist passwortgeschützt.")
            count = doc.page_count
            with open(output_path, "w", encoding="utf-8") as f:
                for i in range(count):
                    page = doc.load_page(i)
                    f.write("--- Seite {0} ---\n".format(i + 1))
                    f.write(page.get_text())
                    f.write("\n\n")
            return count
        finally:
            doc.close()
    else:
        reader = PdfReader(input_path)
        if reader.is_encrypted:
            raise PdfReadError("Datei ist passwortgeschützt.")
        count = len(reader.pages)
        with open(output_path, "w", encoding="utf-8") as f:
            for i, page in enumerate(reader.pages, start=1):
                f.write("--- Seite {0} ---\n".format(i))
                f.write(page.extract_text() or "")
                f.write("\n\n")
        return count


SIGNATURE_TARGET_ALL = "alle"


def resolve_sign_target_pages(target, total_pages):
    """
    Wandelt eine Zielseiten-Angabe für das Signieren in eine sortierte
    Liste 1-basierter Seitenzahlen um.
    target kann sein: SIGNATURE_TARGET_ALL ("alle"), eine einzelne Zahl
    (int) oder ein Text wie "1,3,5-8" (wird mit parse_page_list verarbeitet).
    """
    if target == SIGNATURE_TARGET_ALL:
        return list(range(1, total_pages + 1))
    if isinstance(target, int):
        if target < 1 or target > total_pages:
            raise ValueError(
                "Seite {0} liegt außerhalb des gültigen Bereichs "
                "(1-{1}).".format(target, total_pages)
            )
        return [target]
    return parse_page_list(str(target), total_pages)


def _rects_overlap(a, b):
    """
    Prüft, ob sich zwei Rechtecke (x0, y0, x1, y1) überschneiden.
    """
    ax0, ay0, ax1, ay1 = a
    bx0, by0, bx1, by1 = b
    return ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0


def _get_occupied_rects(page):
    """
    Liefert eine Liste aller Rechtecke (x0, y0, x1, y1), an denen sich auf
    der Seite bereits Text oder ein Bild befindet.
    """
    occupied = []
    for b in page.get_text_blocks():
        occupied.append((b[0], b[1], b[2], b[3]))
    for img in page.get_image_info():
        occupied.append(tuple(img["bbox"]))
    return occupied


def _find_free_signature_y(occupied, block_x0, block_x1, block_y0, block_y1, page_rect):
    """
    Sucht ausgehend von der gewünschten Position (block_x0/x1, block_y0/y1)
    eine freie Stelle für den Unterschriftsblock, indem geprüft wird, ob
    sich an dieser Stelle bereits vorhandener Text oder ein Bild befindet
    (occupied: Liste der belegten Rechtecke, siehe _get_occupied_rects).
    Ist die gewünschte Stelle belegt, wird der Block in kleinen Schritten
    nach oben verschoben (maximal bis zur Seitenmitte).

    Gibt ein Tupel (neues_y0, neues_y1, frei_gefunden) zurück. Wird keine
    wirklich freie Stelle gefunden, werden die ursprünglich gewünschten
    Koordinaten zurückgegeben und frei_gefunden ist False.
    """
    step = 14  # Punkte, um die pro Versuch nach oben gerutscht wird
    min_y0 = page_rect.height / 2.0  # nicht weiter als bis zur Seitenmitte

    y0 = block_y0
    y1 = block_y1
    while y0 >= min_y0:
        candidate = (block_x0, y0, block_x1, y1)
        if not any(_rects_overlap(candidate, occ) for occ in occupied):
            return y0, y1, True
        y0 -= step
        y1 -= step

    # Keine freie Stelle gefunden: ursprüngliche Position verwenden
    return block_y0, block_y1, False


# Um diesen Betrag (in PDF-Punkten, 1 cm = 28,35 Punkt) wird eine Seite am
# unteren Rand verlängert, falls dort partout keine freie Stelle für Datum
# oder Unterschrift gefunden wird. Der neu entstehende Bereich ist immer
# vollständig leer, eine Kollisionsprüfung ist dort nicht nötig.
PAGE_EXTENSION_HEIGHT = 100  # entspricht rund 3,5 cm


def _describe_position(position, block_y0, rect_height, extended):
    """
    Beschreibt eine Einfügeposition in Worten, damit die Stelle auch ohne
    visuelle Prüfung nachvollziehbar ist. Angabe des Abstands vom unteren
    Seitenrand in Zentimetern, dazu die gewählte Seite (links/mitte/rechts).
    """
    cm = 28.3465
    from_bottom = (rect_height - block_y0) / cm
    side_text = {
        "unten-links": "linken",
        "unten-mitte": "mittleren",
        "unten-rechts": "rechten",
    }.get(position, "rechten")

    if extended:
        return (
            "im neu angefügten Bereich am Ende der Seite, {0} Seite "
            "(die Seite wurde um etwa {1:.1f} cm verlängert, damit die "
            "Stelle garantiert frei ist)".format(
                side_text, PAGE_EXTENSION_HEIGHT / cm
            )
        )
    return (
        "im {0} Bereich, etwa {1:.1f} cm vom unteren Seitenrand "
        "entfernt".format(side_text, from_bottom)
    )


def sign_pdf(
    input_path,
    output_path,
    target_pages,
    date_text=None,
    signature_image_path=None,
    signature_name_text=None,
    position="unten-rechts",
):
    """
    Fügt auf den angegebenen Seiten ein Datum und/oder eine Unterschrift
    ein. Die Unterschrift kann entweder als Bilddatei (eingescannte
    Unterschrift) ODER als getippter Name in Schreibschrift-Optik
    eingefügt werden (nicht beides gleichzeitig). target_pages ist eine
    Liste 1-basierter Seitenzahlen (siehe resolve_sign_target_pages).
    position: "unten-rechts", "unten-links", "unten-mitte".
    Benötigt PyMuPDF (fitz); für die Bild-Unterschrift zusätzlich Pillow.

    Ist die gewünschte Stelle bereits mit Text oder einem Bild belegt,
    wird automatisch eine freie Stelle weiter oben auf derselben Seite
    gesucht. Findet sich auch dort nichts, wird die Seite am unteren Rand
    um einen festen Betrag verlängert und Datum/Unterschrift in diesen
    neuen, garantiert leeren Bereich eingefügt - es wird also niemals über
    vorhandenen Inhalt geschrieben.

    Gibt ein Tupel (Anzahl_Seiten, Seiten_Ergebnisse) zurück.
    Seiten_Ergebnisse ist eine Liste mit einem Eintrag je bearbeiteter
    Seite, jeweils ein Dictionary mit den Schlüsseln "page" (Seitenzahl),
    "description" (Positionsbeschreibung in Worten) und "extended"
    (True, wenn die Seite verlängert werden musste). Geeignet, um daraus
    eine für blinde Personen vorlesbare Zusammenfassung zu bauen.
    """
    if not HAVE_FITZ:
        raise RuntimeError(
            "Diese Funktion benötigt das Paket 'PyMuPDF' (fitz), das "
            "nicht installiert ist."
        )
    if not date_text and not signature_image_path and not signature_name_text:
        raise ValueError(
            "Bitte mindestens ein Datum, eine Unterschrift-Bilddatei oder "
            "einen Namen für die Unterschrift angeben."
        )
    if signature_image_path and signature_name_text:
        raise ValueError(
            "Bitte entweder eine Unterschrift-Bilddatei ODER einen Namen "
            "für die Schreibschrift-Unterschrift angeben, nicht beides."
        )
    if not target_pages:
        raise ValueError("Es wurde keine Zielseite ausgewählt.")

    doc = fitz.open(input_path)
    page_results = []
    try:
        if doc.is_encrypted:
            raise PdfReadError("Datei ist passwortgeschützt.")
        total = doc.page_count
        margin = 85  # ca. 3 cm Rand links/rechts und unten (1 cm = 28,35 Punkt)
        sig_width = 170
        sig_height = 60

        for page_number in target_pages:
            if page_number < 1 or page_number > total:
                raise ValueError(
                    "Seite {0} liegt außerhalb des gültigen Bereichs "
                    "(1-{1}).".format(page_number, total)
                )
            page = doc.load_page(page_number - 1)
            rect = page.rect

            if position == "unten-links":
                block_x0 = margin
            elif position == "unten-mitte":
                block_x0 = (rect.width - sig_width) / 2.0
            else:
                block_x0 = rect.width - margin - sig_width
            block_x1 = block_x0 + sig_width
            block_y1 = rect.height - margin
            block_y0 = block_y1 - sig_height

            occupied = _get_occupied_rects(page)
            block_y0, block_y1, found_free = _find_free_signature_y(
                occupied, block_x0, block_x1, block_y0, block_y1, rect
            )

            extended = False
            if not found_free:
                # Garantiert freie Stelle schaffen: Seite am unteren Rand
                # verlängern. PyMuPDF zählt y von oben, daher vergrößert
                # eine größere y1-Grenze der MediaBox die Seite tatsächlich
                # nach unten, ohne den vorhandenen Inhalt zu verschieben.
                old_box = page.mediabox
                new_box = fitz.Rect(
                    old_box.x0,
                    old_box.y0,
                    old_box.x1,
                    old_box.y1 + PAGE_EXTENSION_HEIGHT,
                )
                page.set_mediabox(new_box)
                rect = page.rect
                extended = True

                if position == "unten-links":
                    block_x0 = margin
                elif position == "unten-mitte":
                    block_x0 = (rect.width - sig_width) / 2.0
                else:
                    block_x0 = rect.width - margin - sig_width
                block_x1 = block_x0 + sig_width
                block_y1 = rect.height - margin
                block_y0 = block_y1 - sig_height

            page_results.append(
                {
                    "page": page_number,
                    "description": _describe_position(
                        position, block_y0, rect.height, extended
                    ),
                    "extended": extended,
                }
            )

            cursor_y = block_y0

            if signature_image_path:
                img_rect = fitz.Rect(
                    block_x0, cursor_y, block_x0 + sig_width, cursor_y + 40
                )
                page.insert_image(img_rect, filename=signature_image_path)
                cursor_y += 44
            elif signature_name_text:
                # Schreibschrift-Optik: kursive Standardschrift, da
                # PyMuPDF keine externen TTF-Schreibschriften mitbringt.
                page.insert_text(
                    fitz.Point(block_x0, cursor_y + 32),
                    signature_name_text,
                    fontsize=24,
                    fontname="Times-Italic",
                    color=(0, 0, 0.4),
                )
                cursor_y += 44

            if date_text:
                page.insert_text(
                    fitz.Point(block_x0, cursor_y + 12),
                    "Datum: {0}".format(date_text),
                    fontsize=10,
                    color=(0, 0, 0),
                )

        doc.save(output_path, deflate=True)
    finally:
        doc.close()
    return len(target_pages), page_results


def _detect_black_border_cropbox(page, dpi=100, black_threshold=30,
                                  border_ratio=0.8, min_border_px=3):
    """
    Rastert eine PDF-Seite und sucht schwarze Randstreifen, wie sie beim
    Scannen häufig entstehen. Gibt ein fitz.Rect zurück, das den freien
    Innenbereich der Seite (ohne schwarzen Rahmen) beschreibt, oder None,
    wenn kein ausreichend dicker schwarzer Rahmen erkannt wurde.

    dpi:            Auflösung für das interne Raster (höher = genauer,
                    langsamer; 100 ist für die Rahmenerkennung ausreichend)
    black_threshold: Grauwert 0-255; Pixel darunter gelten als schwarz
    border_ratio:   Anteil schwarzer Pixel in einer Zeile/Spalte, ab dem
                    diese als Rahmenstreifen zählt (0.8 = 80 %)
    min_border_px:  Mindestbreite des Rahmens in Pixeln; verhindert, dass
                    einzelne dunkle Zeilen fälschlich als Rahmen erkannt werden
    """
    if not HAVE_FITZ or not HAVE_NUMPY:
        return None

    pix = page.get_pixmap(dpi=dpi)
    arr = np.frombuffer(pix.samples, dtype=np.uint8).reshape(
        pix.height, pix.width, pix.n
    )
    gray = arr[:, :, :3].mean(axis=2) if pix.n >= 3 else arr[:, :, 0].astype(float)
    mask = gray < black_threshold

    row_black = mask.sum(axis=1) / pix.width   # schwarzer Anteil pro Zeile
    col_black = mask.sum(axis=0) / pix.height  # schwarzer Anteil pro Spalte

    heavy_rows = np.where(row_black >= border_ratio)[0]
    heavy_cols = np.where(col_black >= border_ratio)[0]

    if len(heavy_rows) < min_border_px and len(heavy_cols) < min_border_px:
        return None  # kein schwarzer Rahmen erkannt

    top_px = bottom_px = left_px = right_px = None
    rect = page.rect

    if len(heavy_rows) >= min_border_px:
        top_rows = heavy_rows[heavy_rows < pix.height * 0.3]
        bot_rows = heavy_rows[heavy_rows > pix.height * 0.7]
        if len(top_rows) >= min_border_px:
            top_px = int(top_rows.max()) + 1
        if len(bot_rows) >= min_border_px:
            bottom_px = int(bot_rows.min())

    if len(heavy_cols) >= min_border_px:
        left_cols = heavy_cols[heavy_cols < pix.width * 0.3]
        right_cols = heavy_cols[heavy_cols > pix.width * 0.7]
        if len(left_cols) >= min_border_px:
            left_px = int(left_cols.max()) + 1
        if len(right_cols) >= min_border_px:
            right_px = int(right_cols.min())

    if top_px is None and bottom_px is None and left_px is None and right_px is None:
        return None

    sx = rect.width  / pix.width
    sy = rect.height / pix.height
    return fitz.Rect(
        (left_px   * sx) if left_px   is not None else rect.x0,
        (top_px    * sy) if top_px    is not None else rect.y0,
        (right_px  * sx) if right_px  is not None else rect.x1,
        (bottom_px * sy) if bottom_px is not None else rect.y1,
    )


def check_black_borders(input_path):
    """
    Prüft alle Seiten eines PDFs auf schwarze Rahmen.
    Gibt ein Dictionary {seitenzahl: cropbox_rect} zurück; Seiten ohne
    erkennbaren Rahmen fehlen im Dictionary. Gibt {} zurück, wenn
    keine einzige Seite einen Rahmen hat.
    Benötigt PyMuPDF und numpy.
    """
    if not HAVE_FITZ:
        raise RuntimeError(
            "Diese Funktion benötigt das Paket 'PyMuPDF' (fitz)."
        )
    if not HAVE_NUMPY:
        raise RuntimeError(
            "Diese Funktion benötigt das Paket 'numpy'."
        )
    doc = fitz.open(input_path)
    borders = {}
    try:
        for i in range(doc.page_count):
            page = doc.load_page(i)
            cropbox = _detect_black_border_cropbox(page)
            if cropbox is not None:
                borders[i + 1] = cropbox  # 1-basierte Seitenzahl
    finally:
        doc.close()
    return borders


def remove_black_borders(input_path, output_path):
    """
    Entfernt schwarze Scanrahmen von allen Seiten, auf denen einer
    erkannt wurde, indem die Cropbox der Seite auf den freien
    Innenbereich gesetzt wird. Seiten ohne Rahmen bleiben unverändert.
    Gibt ein Tupel (gesamt_seiten, behandelte_seiten) zurück.
    Benötigt PyMuPDF und numpy.
    """
    if not HAVE_FITZ:
        raise RuntimeError(
            "Diese Funktion benötigt das Paket 'PyMuPDF' (fitz)."
        )
    if not HAVE_NUMPY:
        raise RuntimeError(
            "Diese Funktion benötigt das Paket 'numpy'."
        )
    doc = fitz.open(input_path)
    treated = 0
    try:
        if doc.is_encrypted:
            raise PdfReadError("Datei ist passwortgeschützt.")
        total = doc.page_count
        for i in range(doc.page_count):
            page = doc.load_page(i)
            cropbox = _detect_black_border_cropbox(page)
            if cropbox is not None:
                page.set_cropbox(cropbox)
                treated += 1
        doc.save(output_path, deflate=True)
    finally:
        doc.close()
    return total, treated


def parse_page_list(text, max_page):
    """
    Wandelt eine Texteingabe wie "1,3,5-8,10" in eine sortierte Liste
    von Seitenzahlen (1-basiert) um. Wirft ValueError bei ungültiger Eingabe.
    """
    result = set()
    text = text.strip()
    if not text:
        raise ValueError("Bitte Seitenzahlen eingeben, z. B. 1,3,5-8")
    parts = text.split(",")
    for part in parts:
        part = part.strip()
        if not part:
            continue
        if "-" in part:
            bounds = part.split("-")
            if len(bounds) != 2:
                raise ValueError("Ungültiger Bereich: {0}".format(part))
            try:
                start = int(bounds[0].strip())
                end = int(bounds[1].strip())
            except ValueError:
                raise ValueError("Ungültige Zahl im Bereich: {0}".format(part))
            if start > end:
                start, end = end, start
            for p in range(start, end + 1):
                result.add(p)
        else:
            try:
                result.add(int(part))
            except ValueError:
                raise ValueError("Ungültige Seitenzahl: {0}".format(part))
    for p in result:
        if p < 1 or p > max_page:
            raise ValueError(
                "Seite {0} liegt außerhalb des gültigen Bereichs (1-{1}).".format(
                    p, max_page
                )
            )
    return sorted(result)


# ---------------------------------------------------------------------------
# Listbox mit Drag/Reorder-Buttons für das Zusammenfügen
# ---------------------------------------------------------------------------

class MergePanel(wx.Panel):
    """
    Reiter zum Zusammenfügen mehrerer PDF-Dateien in frei wählbarer
    Reihenfolge. Die Reihenfolge in der Liste entspricht der Reihenfolge
    im Ergebnis: der erste Eintrag wird zuerst eingefügt, der zweite
    Eintrag wird direkt darunter angehängt, usw.
    """

    def __init__(self, parent, status_callback):
        super().__init__(parent)
        self.status_callback = status_callback
        self.file_paths = []

        main_sizer = wx.BoxSizer(wx.VERTICAL)

        intro = wx.StaticText(
            self,
            label=(
                "Wählen Sie mehrere PDF-Dateien aus. Die Reihenfolge in der "
                "Liste bestimmt die Reihenfolge im Ergebnis: die erste Datei "
                "in der Liste wird zuerst eingefügt, die zweite Datei wird "
                "direkt darunter angehängt, und so weiter."
            ),
        )
        intro.Wrap(640)
        main_sizer.Add(intro, 0, wx.ALL | wx.EXPAND, 10)

        # Liste der ausgewählten Dateien
        list_label = wx.StaticText(self, label="&Liste der PDF-Dateien in Reihenfolge:")
        main_sizer.Add(list_label, 0, wx.LEFT | wx.TOP, 10)

        self.file_listbox = wx.ListBox(self, style=wx.LB_SINGLE)
        self.file_listbox.SetName("Liste der PDF-Dateien in Reihenfolge")
        self.file_listbox.SetHelpText(
            "Enthält die ausgewählten PDF-Dateien in der Reihenfolge, "
            "in der sie zusammengefügt werden. Mit den Schaltflächen "
            "Nach oben und Nach unten kann die Reihenfolge geändert werden."
        )
        main_sizer.Add(self.file_listbox, 1, wx.ALL | wx.EXPAND, 10)

        # Buttons: Hinzufügen, Entfernen, Hoch, Runter
        button_row = wx.BoxSizer(wx.HORIZONTAL)

        self.add_button = wx.Button(self, label="PDF &hinzufügen...")
        self.add_button.SetName("PDF-Datei hinzufügen")
        self.add_button.SetHelpText(
            "Öffnet einen Dialog, um eine oder mehrere PDF-Dateien zur Liste hinzuzufügen."
        )
        self.add_button.Bind(wx.EVT_BUTTON, self.on_add_files)
        button_row.Add(self.add_button, 0, wx.ALL, 5)

        self.remove_button = wx.Button(self, label="&Entfernen")
        self.remove_button.SetName("Ausgewählte Datei aus Liste entfernen")
        self.remove_button.SetHelpText(
            "Entfernt die in der Liste markierte PDF-Datei aus der Liste."
        )
        self.remove_button.Bind(wx.EVT_BUTTON, self.on_remove_file)
        button_row.Add(self.remove_button, 0, wx.ALL, 5)

        self.up_button = wx.Button(self, label="Nach &oben")
        self.up_button.SetName("Markierte Datei in der Liste nach oben verschieben")
        self.up_button.SetHelpText(
            "Verschiebt die markierte Datei eine Position nach oben in der Reihenfolge."
        )
        self.up_button.Bind(wx.EVT_BUTTON, self.on_move_up)
        button_row.Add(self.up_button, 0, wx.ALL, 5)

        self.down_button = wx.Button(self, label="Nach &unten")
        self.down_button.SetName("Markierte Datei in der Liste nach unten verschieben")
        self.down_button.SetHelpText(
            "Verschiebt die markierte Datei eine Position nach unten in der Reihenfolge."
        )
        self.down_button.Bind(wx.EVT_BUTTON, self.on_move_down)
        button_row.Add(self.down_button, 0, wx.ALL, 5)

        self.clear_button = wx.Button(self, label="Liste &leeren")
        self.clear_button.SetName("Gesamte Liste leeren")
        self.clear_button.SetHelpText("Entfernt alle Dateien aus der Liste.")
        self.clear_button.Bind(wx.EVT_BUTTON, self.on_clear)
        button_row.Add(self.clear_button, 0, wx.ALL, 5)

        main_sizer.Add(button_row, 0, wx.ALIGN_LEFT)

        # Zusammenfügen-Button
        merge_row = wx.BoxSizer(wx.HORIZONTAL)
        self.merge_button = wx.Button(self, label="PDF-Dateien &zusammenfügen...")
        self.merge_button.SetName("PDF-Dateien zusammenfügen und speichern")
        self.merge_button.SetHelpText(
            "Fügt alle Dateien aus der Liste in der angezeigten Reihenfolge "
            "zu einer einzigen PDF-Datei zusammen und fragt nach einem Speicherort."
        )
        self.merge_button.Bind(wx.EVT_BUTTON, self.on_merge)
        merge_row.Add(self.merge_button, 0, wx.ALL, 10)
        main_sizer.Add(merge_row, 0, wx.ALIGN_LEFT)

        self.SetSizer(main_sizer)

        # Logische Tab-Reihenfolge
        self.add_button.MoveAfterInTabOrder(self.file_listbox)
        self.remove_button.MoveAfterInTabOrder(self.add_button)
        self.up_button.MoveAfterInTabOrder(self.remove_button)
        self.down_button.MoveAfterInTabOrder(self.up_button)
        self.clear_button.MoveAfterInTabOrder(self.down_button)
        self.merge_button.MoveAfterInTabOrder(self.clear_button)

    def on_add_files(self, event):
        with wx.FileDialog(
            self,
            message="PDF-Dateien auswählen",
            wildcard="PDF-Dateien (*.pdf)|*.pdf",
            style=wx.FD_OPEN | wx.FD_MULTIPLE | wx.FD_FILE_MUST_EXIST,
        ) as dlg:
            if dlg.ShowModal() == wx.ID_CANCEL:
                return
            paths = dlg.GetPaths()
            for p in paths:
                self.file_paths.append(p)
                self.file_listbox.Append(os.path.basename(p))
            self.status_callback(
                "{0} Datei(en) zur Liste hinzugefügt.".format(len(paths))
            )

    def on_remove_file(self, event):
        idx = self.file_listbox.GetSelection()
        if idx == wx.NOT_FOUND:
            self.status_callback("Bitte zuerst eine Datei in der Liste markieren.")
            return
        name = self.file_listbox.GetString(idx)
        self.file_listbox.Delete(idx)
        del self.file_paths[idx]
        self.status_callback("Datei entfernt: {0}".format(name))

    def on_move_up(self, event):
        idx = self.file_listbox.GetSelection()
        if idx == wx.NOT_FOUND or idx == 0:
            return
        self._swap(idx, idx - 1)
        self.file_listbox.SetSelection(idx - 1)
        self.status_callback("Datei nach oben verschoben.")

    def on_move_down(self, event):
        idx = self.file_listbox.GetSelection()
        if idx == wx.NOT_FOUND or idx >= len(self.file_paths) - 1:
            return
        self._swap(idx, idx + 1)
        self.file_listbox.SetSelection(idx + 1)
        self.status_callback("Datei nach unten verschoben.")

    def _swap(self, i, j):
        self.file_paths[i], self.file_paths[j] = self.file_paths[j], self.file_paths[i]
        name_i = self.file_listbox.GetString(i)
        name_j = self.file_listbox.GetString(j)
        self.file_listbox.SetString(i, name_j)
        self.file_listbox.SetString(j, name_i)

    def on_clear(self, event):
        self.file_listbox.Clear()
        self.file_paths = []
        self.status_callback("Liste geleert.")

    def on_merge(self, event):
        if len(self.file_paths) < 2:
            wx.MessageBox(
                "Bitte mindestens zwei PDF-Dateien zur Liste hinzufügen, "
                "bevor sie zusammengefügt werden.",
                "Hinweis",
                wx.OK | wx.ICON_INFORMATION,
            )
            return
        with wx.FileDialog(
            self,
            message="Zusammengefügte PDF speichern als",
            wildcard="PDF-Dateien (*.pdf)|*.pdf",
            style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT,
        ) as dlg:
            if dlg.ShowModal() == wx.ID_CANCEL:
                return
            output_path = dlg.GetPath()
            if not output_path.lower().endswith(".pdf"):
                output_path += ".pdf"
        try:
            merge_pdfs(self.file_paths, output_path)
            self.status_callback(
                "Erfolg: {0} Dateien zusammengefügt und gespeichert unter {1}".format(
                    len(self.file_paths), output_path
                )
            )
            wx.MessageBox(
                "Die PDF-Dateien wurden erfolgreich zusammengefügt und "
                "gespeichert unter:\n{0}".format(output_path),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Zusammenfügen: {0}".format(e))
            wx.MessageBox(
                "Beim Zusammenfügen ist ein Fehler aufgetreten:\n{0}".format(e),
                "Fehler",
                wx.OK | wx.ICON_ERROR,
            )


# ---------------------------------------------------------------------------
# Reiter für weitere Werkzeuge (Trennen, Entfernen, Extrahieren, Drehen,
# Passwort, Informationen/Metadaten)
# ---------------------------------------------------------------------------

class ToolsPanel:
    """
    Stellt die einzelnen PDF-Werkzeuge (Trennen, Seiten entfernen,
    Extrahieren, Drehen, Passwort, Informationen, PDF zu Bild, Bild zu
    PDF, Komprimieren, Wasserzeichen, Seitenzahlen, Signieren, Text
    exportieren) als eigenständige Reiter bereit, die direkt im
    Haupt-Notebook erscheinen (keine verschachtelten Reiter mehr).

    Jeder dieser Reiter besitzt oben einen eigenen Bereich zur Wahl der
    PDF-Quelldatei. Da sich alle Werkzeuge auf dieselbe Quelldatei
    beziehen, wird die Auswahl über alle Reiter hinweg synchronisiert:
    Wird die Datei in einem Reiter gewählt, übernehmen alle anderen
    Reiter automatisch denselben Pfad.
    """

    def __init__(self, notebook, status_callback):
        self.notebook = notebook
        self.status_callback = status_callback
        self.current_path = None
        # Alle Anzeigefelder, die den aktuellen Quelldatei-Pfad zeigen
        # (eines pro Werkzeug-Reiter), werden synchron gehalten.
        self.path_displays = []

        self.notebook.AddPage(self._build_split_panel(self.notebook), "PDF &Trennen")
        self.notebook.AddPage(self._build_remove_panel(self.notebook), "Seiten &entfernen")
        self.notebook.AddPage(self._build_extract_panel(self.notebook), "E&xtrahieren")
        self.notebook.AddPage(self._build_rotate_panel(self.notebook), "PDF &Drehen")
        self.notebook.AddPage(self._build_password_panel(self.notebook), "&Passwort hinzufügen")
        self.notebook.AddPage(self._build_info_panel(self.notebook), "&Informationen anzeigen")
        self.notebook.AddPage(self._build_pdf_to_image_panel(self.notebook), "PDF zu &Bild")
        self.notebook.AddPage(self._build_image_to_pdf_panel(self.notebook), "Bild zu PD&F")
        self.notebook.AddPage(self._build_compress_panel(self.notebook), "PDF &Komprimieren")
        self.notebook.AddPage(self._build_watermark_panel(self.notebook), "&Wasserzeichen hinzufügen")
        self.notebook.AddPage(self._build_page_numbers_panel(self.notebook), "&Seitenzahlen einfügen")
        self.notebook.AddPage(self._build_sign_panel(self.notebook), "Si&gnieren")
        self.notebook.AddPage(self._build_text_extract_panel(self.notebook), "Text expo&rtieren")
        self.notebook.AddPage(self._build_remove_border_panel(self.notebook), "Sc&hwarze Rahmen entfernen")

    # -- Hilfsfunktion: gemeinsamen Quelldatei-Bereich in ein Werkzeug-
    #    Panel einfügen. Wird von jeder _build_*_panel-Methode als
    #    erstes aufgerufen, damit jeder Reiter oben dieselbe Auswahl
    #    der PDF-Quelldatei besitzt. -------------------------------
    def _add_source_box(self, panel, outer_sizer):
        source_box = wx.StaticBox(panel, label="Quelldatei")
        source_sizer = wx.StaticBoxSizer(source_box, wx.HORIZONTAL)

        path_display = wx.TextCtrl(
            panel, style=wx.TE_READONLY, value="Keine Datei ausgewählt"
        )
        path_display.SetName("Pfad der ausgewählten PDF-Datei")
        path_display.SetHelpText(
            "Zeigt den vollständigen Pfad der aktuell ausgewählten PDF-Datei an."
        )
        source_sizer.Add(path_display, 1, wx.ALL | wx.EXPAND, 5)
        self.path_displays.append(path_display)

        choose_button = wx.Button(panel, label="PDF &auswählen...")
        choose_button.SetName("PDF-Quelldatei auswählen")
        choose_button.SetHelpText(
            "Öffnet einen Dialog, um die PDF-Datei auszuwählen, "
            "auf die dieses Werkzeug angewendet wird."
        )
        choose_button.Bind(wx.EVT_BUTTON, self.on_choose_file)
        source_sizer.Add(choose_button, 0, wx.ALL, 5)

        outer_sizer.Add(source_sizer, 0, wx.ALL | wx.EXPAND, 10)
        choose_button.MoveAfterInTabOrder(path_display)
        return path_display, choose_button

    # -- Hilfsfunktion: Quelldatei auswählen --------------------------
    def on_choose_file(self, event):
        with wx.FileDialog(
            self.notebook,
            message="PDF-Datei auswählen",
            wildcard="PDF-Dateien (*.pdf)|*.pdf",
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        ) as dlg:
            if dlg.ShowModal() == wx.ID_CANCEL:
                return
            self.current_path = dlg.GetPath()
            for path_display in self.path_displays:
                path_display.SetValue(self.current_path)
            self.status_callback(
                "Quelldatei ausgewählt: {0}".format(os.path.basename(self.current_path))
            )

    def _require_source(self):
        if not self.current_path:
            wx.MessageBox(
                "Bitte oben zuerst eine PDF-Quelldatei auswählen.",
                "Hinweis",
                wx.OK | wx.ICON_INFORMATION,
            )
            return False
        return True

    def _ask_save_path(self, message, default_name=""):
        with wx.FileDialog(
            self.notebook,
            message=message,
            defaultFile=default_name,
            wildcard="PDF-Dateien (*.pdf)|*.pdf",
            style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT,
        ) as dlg:
            if dlg.ShowModal() == wx.ID_CANCEL:
                return None
            path = dlg.GetPath()
            if not path.lower().endswith(".pdf"):
                path += ".pdf"
            return path

    # -- Trennen ---------------------------------------------------------
    def _build_split_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Zerlegt die ausgewählte PDF-Datei in einzelne Seiten. "
                "Jede Seite wird als eigene Datei in einem gewählten Ordner gespeichert."
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        self.split_button = wx.Button(panel, label="In &Einzelseiten zerlegen...")
        self.split_button.SetName("PDF in Einzelseiten zerlegen")
        self.split_button.SetHelpText(
            "Fragt nach einem Zielordner und speichert dort jede Seite der "
            "ausgewählten PDF-Datei als eigene Datei."
        )
        self.split_button.Bind(wx.EVT_BUTTON, self.on_split)
        sizer.Add(self.split_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)
        return panel

    def on_split(self, event):
        if not self._require_source():
            return
        with wx.DirDialog(
            self.notebook, message="Zielordner für Einzelseiten auswählen"
        ) as dlg:
            if dlg.ShowModal() == wx.ID_CANCEL:
                return
            target_dir = dlg.GetPath()
        try:
            count = split_pdf(self.current_path, target_dir)
            self.status_callback(
                "Erfolg: {0} Seiten wurden in {1} gespeichert.".format(count, target_dir)
            )
            wx.MessageBox(
                "Die Datei wurde in {0} einzelne Seiten zerlegt und im "
                "Ordner gespeichert:\n{1}".format(count, target_dir),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Zerlegen: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Seiten entfernen --------------------------------------------------
    def _build_remove_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Entfernt bestimmte Seiten aus der ausgewählten PDF-Datei. "
                "Geben Sie die Seitenzahlen ein, getrennt durch Kommas, "
                "Bereiche mit einem Bindestrich. Beispiel: 1,3,5-8"
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        label = wx.StaticText(panel, label="Zu entfernende &Seitenzahlen:")
        sizer.Add(label, 0, wx.LEFT | wx.TOP, 10)

        self.remove_pages_field = wx.TextCtrl(panel)
        self.remove_pages_field.SetName("Zu entfernende Seitenzahlen")
        self.remove_pages_field.SetHelpText(
            "Geben Sie die Seitenzahlen ein, die entfernt werden sollen. "
            "Mehrere Zahlen durch Komma trennen, Bereiche mit Bindestrich "
            "angeben, zum Beispiel 1,3,5-8"
        )
        sizer.Add(self.remove_pages_field, 0, wx.ALL | wx.EXPAND, 10)

        self.remove_button = wx.Button(panel, label="Seiten &entfernen und speichern als...")
        self.remove_button.SetName("Eingegebene Seiten entfernen und Ergebnis speichern")
        self.remove_button.SetHelpText(
            "Entfernt die eingegebenen Seitenzahlen aus der PDF-Datei und "
            "fragt anschließend nach einem Speicherort für das Ergebnis."
        )
        self.remove_button.Bind(wx.EVT_BUTTON, self.on_remove_pages)
        sizer.Add(self.remove_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)
        self.remove_button.MoveAfterInTabOrder(self.remove_pages_field)
        return panel

    def on_remove_pages(self, event):
        if not self._require_source():
            return
        try:
            reader = PdfReader(self.current_path)
            if reader.is_encrypted:
                raise PdfReadError("Datei ist passwortgeschützt.")
            total = len(reader.pages)
            pages = parse_page_list(self.remove_pages_field.GetValue(), total)
        except Exception as e:
            wx.MessageBox(str(e), "Fehler in der Eingabe", wx.OK | wx.ICON_ERROR)
            return
        output_path = self._ask_save_path("PDF ohne entfernte Seiten speichern als")
        if not output_path:
            return
        try:
            remove_pages(self.current_path, output_path, pages)
            self.status_callback(
                "Erfolg: {0} Seite(n) entfernt, gespeichert unter {1}".format(
                    len(pages), output_path
                )
            )
            wx.MessageBox(
                "Die Seiten wurden entfernt und das Ergebnis gespeichert unter:\n{0}".format(
                    output_path
                ),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Entfernen von Seiten: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Extrahieren ---------------------------------------------------------
    def _build_extract_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Extrahiert einen Seitenbereich aus der ausgewählten PDF-Datei "
                "und speichert ihn als neue Datei."
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        grid = wx.FlexGridSizer(rows=2, cols=2, vgap=10, hgap=10)

        start_label = wx.StaticText(panel, label="&Erste Seite:")
        self.extract_start = wx.SpinCtrl(panel, min=1, max=1, initial=1)
        self.extract_start.SetName("Erste Seite des zu extrahierenden Bereichs")
        self.extract_start.SetHelpText(
            "Geben Sie die Nummer der ersten Seite ein, die extrahiert werden soll."
        )

        end_label = wx.StaticText(panel, label="&Letzte Seite:")
        self.extract_end = wx.SpinCtrl(panel, min=1, max=1, initial=1)
        self.extract_end.SetName("Letzte Seite des zu extrahierenden Bereichs")
        self.extract_end.SetHelpText(
            "Geben Sie die Nummer der letzten Seite ein, die extrahiert werden soll."
        )

        grid.Add(start_label)
        grid.Add(self.extract_start)
        grid.Add(end_label)
        grid.Add(self.extract_end)
        sizer.Add(grid, 0, wx.ALL, 10)

        self.extract_button = wx.Button(panel, label="Bereich &extrahieren und speichern als...")
        self.extract_button.SetName("Seitenbereich extrahieren und Ergebnis speichern")
        self.extract_button.SetHelpText(
            "Extrahiert den angegebenen Seitenbereich und fragt anschließend "
            "nach einem Speicherort für das Ergebnis."
        )
        self.extract_button.Bind(wx.EVT_BUTTON, self.on_extract)
        sizer.Add(self.extract_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)
        self.extract_end.MoveAfterInTabOrder(self.extract_start)
        self.extract_button.MoveAfterInTabOrder(self.extract_end)
        return panel

    def on_extract(self, event):
        if not self._require_source():
            return
        try:
            reader = PdfReader(self.current_path)
            if reader.is_encrypted:
                raise PdfReadError("Datei ist passwortgeschützt.")
            total = len(reader.pages)
            self.extract_start.SetRange(1, total)
            self.extract_end.SetRange(1, total)
        except Exception as e:
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)
            return
        start = self.extract_start.GetValue()
        end = self.extract_end.GetValue()
        output_path = self._ask_save_path("Extrahierten Bereich speichern als")
        if not output_path:
            return
        try:
            extract_pages(self.current_path, output_path, start, end)
            self.status_callback(
                "Erfolg: Seiten {0} bis {1} extrahiert, gespeichert unter {2}".format(
                    start, end, output_path
                )
            )
            wx.MessageBox(
                "Der Seitenbereich wurde extrahiert und gespeichert unter:\n{0}".format(
                    output_path
                ),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Extrahieren: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Drehen ---------------------------------------------------------
    def _build_rotate_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label="Dreht alle Seiten der ausgewählten PDF-Datei um den gewählten Winkel.",
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        angle_label = wx.StaticText(panel, label="&Drehwinkel im Uhrzeigersinn:")
        sizer.Add(angle_label, 0, wx.LEFT | wx.TOP, 10)

        self.angle_choice = wx.RadioBox(
            panel,
            label="Drehwinkel im Uhrzeigersinn",
            choices=["90 Grad", "180 Grad", "270 Grad"],
            majorDimension=1,
            style=wx.RA_SPECIFY_COLS,
        )
        self.angle_choice.SetName("Drehwinkel im Uhrzeigersinn auswählen")
        self.angle_choice.SetHelpText(
            "Wählen Sie, um wie viel Grad im Uhrzeigersinn alle Seiten "
            "gedreht werden sollen."
        )
        sizer.Add(self.angle_choice, 0, wx.ALL, 10)

        self.rotate_button = wx.Button(panel, label="&Drehen und speichern als...")
        self.rotate_button.SetName("PDF drehen und Ergebnis speichern")
        self.rotate_button.SetHelpText(
            "Dreht alle Seiten um den gewählten Winkel und fragt anschließend "
            "nach einem Speicherort für das Ergebnis."
        )
        self.rotate_button.Bind(wx.EVT_BUTTON, self.on_rotate)
        sizer.Add(self.rotate_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)
        self.angle_choice.MoveAfterInTabOrder(angle_label)
        self.rotate_button.MoveAfterInTabOrder(self.angle_choice)
        return panel

    def on_rotate(self, event):
        if not self._require_source():
            return
        angle_map = {0: 90, 1: 180, 2: 270}
        angle = angle_map[self.angle_choice.GetSelection()]
        output_path = self._ask_save_path("Gedrehte PDF speichern als")
        if not output_path:
            return
        try:
            rotate_pdf(self.current_path, output_path, angle)
            self.status_callback(
                "Erfolg: Datei um {0} Grad gedreht, gespeichert unter {1}".format(
                    angle, output_path
                )
            )
            wx.MessageBox(
                "Die Datei wurde um {0} Grad gedreht und gespeichert unter:\n{1}".format(
                    angle, output_path
                ),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Drehen: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Passwort ---------------------------------------------------------
    def _build_password_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        # Verschlüsseln
        encrypt_box = wx.StaticBox(panel, label="Passwortschutz hinzufügen")
        encrypt_sizer = wx.StaticBoxSizer(encrypt_box, wx.VERTICAL)

        pw_label = wx.StaticText(encrypt_box, label="&Neues Passwort:")
        self.encrypt_password = wx.TextCtrl(encrypt_box, style=wx.TE_PASSWORD)
        self.encrypt_password.SetName("Neues Passwort für die PDF-Datei")
        self.encrypt_password.SetHelpText(
            "Geben Sie das Passwort ein, mit dem die PDF-Datei beim Oeffnen "
            "geschützt werden soll."
        )
        encrypt_sizer.Add(pw_label, 0, wx.ALL, 5)
        encrypt_sizer.Add(self.encrypt_password, 0, wx.ALL | wx.EXPAND, 5)

        self.encrypt_button = wx.Button(encrypt_box, label="&Verschlüsseln und speichern als...")
        self.encrypt_button.SetName("PDF-Datei mit Passwort verschlüsseln")
        self.encrypt_button.SetHelpText(
            "Verschlüsselt die ausgewählte PDF-Datei mit dem eingegebenen "
            "Passwort und fragt anschließend nach einem Speicherort."
        )
        self.encrypt_button.Bind(wx.EVT_BUTTON, self.on_encrypt)
        encrypt_sizer.Add(self.encrypt_button, 0, wx.ALL, 5)

        sizer.Add(encrypt_sizer, 0, wx.ALL | wx.EXPAND, 10)

        # Entschlüsseln
        decrypt_box = wx.StaticBox(panel, label="Passwortschutz entfernen")
        decrypt_sizer = wx.StaticBoxSizer(decrypt_box, wx.VERTICAL)

        dec_label = wx.StaticText(decrypt_box, label="&Aktuelles Passwort:")
        self.decrypt_password = wx.TextCtrl(decrypt_box, style=wx.TE_PASSWORD)
        self.decrypt_password.SetName("Aktuelles Passwort der PDF-Datei")
        self.decrypt_password.SetHelpText(
            "Geben Sie das aktuelle Passwort der PDF-Datei ein, um den "
            "Passwortschutz zu entfernen."
        )
        decrypt_sizer.Add(dec_label, 0, wx.ALL, 5)
        decrypt_sizer.Add(self.decrypt_password, 0, wx.ALL | wx.EXPAND, 5)

        self.decrypt_button = wx.Button(decrypt_box, label="Passwortschutz e&ntfernen und speichern als...")
        self.decrypt_button.SetName("Passwortschutz der PDF-Datei entfernen")
        self.decrypt_button.SetHelpText(
            "Entfernt den Passwortschutz der ausgewählten PDF-Datei, sofern "
            "das eingegebene Passwort korrekt ist, und fragt anschließend "
            "nach einem Speicherort."
        )
        self.decrypt_button.Bind(wx.EVT_BUTTON, self.on_decrypt)
        decrypt_sizer.Add(self.decrypt_button, 0, wx.ALL, 5)

        sizer.Add(decrypt_sizer, 0, wx.ALL | wx.EXPAND, 10)

        panel.SetSizer(sizer)
        return panel

    def on_encrypt(self, event):
        if not self._require_source():
            return
        password = self.encrypt_password.GetValue()
        if not password:
            wx.MessageBox(
                "Bitte ein Passwort eingeben.", "Hinweis", wx.OK | wx.ICON_INFORMATION
            )
            return
        output_path = self._ask_save_path("Verschlüsselte PDF speichern als")
        if not output_path:
            return
        try:
            encrypt_pdf(self.current_path, output_path, password)
            self.status_callback(
                "Erfolg: Datei verschlüsselt und gespeichert unter {0}".format(
                    output_path
                )
            )
            wx.MessageBox(
                "Die Datei wurde verschlüsselt und gespeichert unter:\n{0}".format(
                    output_path
                ),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Verschlüsseln: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    def on_decrypt(self, event):
        if not self._require_source():
            return
        password = self.decrypt_password.GetValue()
        output_path = self._ask_save_path("Entschlüsselte PDF speichern als")
        if not output_path:
            return
        try:
            decrypt_pdf(self.current_path, output_path, password)
            self.status_callback(
                "Erfolg: Passwortschutz entfernt, gespeichert unter {0}".format(
                    output_path
                )
            )
            wx.MessageBox(
                "Der Passwortschutz wurde entfernt. Datei gespeichert unter:\n{0}".format(
                    output_path
                ),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Entschlüsseln: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Informationen / Metadaten ----------------------------------------
    def _build_info_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        self.info_button = wx.Button(panel, label="&Informationen anzeigen")
        self.info_button.SetName("Informationen zur PDF-Datei anzeigen")
        self.info_button.SetHelpText(
            "Zeigt Seitenzahl, Titel, Autor und Verschlüsselungsstatus der "
            "ausgewählten PDF-Datei an."
        )
        self.info_button.Bind(wx.EVT_BUTTON, self.on_show_info)
        sizer.Add(self.info_button, 0, wx.ALL, 10)

        self.info_display = wx.TextCtrl(
            panel, style=wx.TE_READONLY | wx.TE_MULTILINE, size=(-1, 100)
        )
        self.info_display.SetName("Angezeigte Informationen zur PDF-Datei")
        self.info_display.SetHelpText(
            "Zeigt die ermittelten Informationen zur PDF-Datei als Text an."
        )
        sizer.Add(self.info_display, 0, wx.ALL | wx.EXPAND, 10)

        meta_box = wx.StaticBox(panel, label="Metadaten aendern")
        meta_sizer = wx.StaticBoxSizer(meta_box, wx.VERTICAL)

        title_label = wx.StaticText(meta_box, label="&Titel:")
        self.title_field = wx.TextCtrl(meta_box)
        self.title_field.SetName("Neuer Titel der PDF-Datei")
        self.title_field.SetHelpText("Geben Sie den neuen Titel der PDF-Datei ein.")
        meta_sizer.Add(title_label, 0, wx.ALL, 5)
        meta_sizer.Add(self.title_field, 0, wx.ALL | wx.EXPAND, 5)

        author_label = wx.StaticText(meta_box, label="&Autor:")
        self.author_field = wx.TextCtrl(meta_box)
        self.author_field.SetName("Neuer Autor der PDF-Datei")
        self.author_field.SetHelpText("Geben Sie den neuen Autor der PDF-Datei ein.")
        meta_sizer.Add(author_label, 0, wx.ALL, 5)
        meta_sizer.Add(self.author_field, 0, wx.ALL | wx.EXPAND, 5)

        self.meta_button = wx.Button(meta_box, label="&Metadaten speichern als...")
        self.meta_button.SetName("Geänderte Metadaten speichern")
        self.meta_button.SetHelpText(
            "Speichert eine Kopie der PDF-Datei mit dem eingegebenen Titel "
            "und Autor an einem gewählten Speicherort."
        )
        self.meta_button.Bind(wx.EVT_BUTTON, self.on_save_metadata)
        meta_sizer.Add(self.meta_button, 0, wx.ALL, 5)

        sizer.Add(meta_sizer, 0, wx.ALL | wx.EXPAND, 10)

        panel.SetSizer(sizer)
        self.info_display.MoveAfterInTabOrder(self.info_button)
        return panel

    def on_show_info(self, event):
        if not self._require_source():
            return
        try:
            info = get_pdf_info(self.current_path)
            text = (
                "Datei: {0}\n"
                "Seitenzahl: {1}\n"
                "Titel: {2}\n"
                "Autor: {3}\n"
                "Passwortgeschützt: {4}"
            ).format(
                os.path.basename(self.current_path),
                info["pages"],
                info["title"],
                info["author"],
                "Ja" if info["encrypted"] else "Nein",
            )
            self.info_display.SetValue(text)
            self.status_callback("Informationen angezeigt.")
        except Exception as e:
            self.status_callback("Fehler beim Lesen der Informationen: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    def on_save_metadata(self, event):
        if not self._require_source():
            return
        title = self.title_field.GetValue()
        author = self.author_field.GetValue()
        output_path = self._ask_save_path("PDF mit neuen Metadaten speichern als")
        if not output_path:
            return
        try:
            set_pdf_metadata(self.current_path, output_path, title, author)
            self.status_callback(
                "Erfolg: Metadaten geändert, gespeichert unter {0}".format(output_path)
            )
            wx.MessageBox(
                "Die Metadaten wurden geändert. Datei gespeichert unter:\n{0}".format(
                    output_path
                ),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Speichern der Metadaten: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- PDF zu Bild --------------------------------------------------------
    def _build_pdf_to_image_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Wandelt jede Seite der ausgewählten PDF-Datei in eine "
                "eigene Bilddatei um (PNG oder JPG) und speichert alle "
                "Bilder in einem gewählten Zielordner."
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        format_label = wx.StaticText(panel, label="&Bildformat:")
        sizer.Add(format_label, 0, wx.LEFT | wx.TOP, 10)

        self.image_format_choice = wx.RadioBox(
            panel,
            label="Bildformat",
            choices=["PNG", "JPG"],
            majorDimension=1,
            style=wx.RA_SPECIFY_COLS,
        )
        self.image_format_choice.SetName("Bildformat für den Export auswählen")
        self.image_format_choice.SetHelpText(
            "Wählen Sie, in welchem Bildformat die Seiten gespeichert "
            "werden sollen: PNG (verlustfrei) oder JPG (kleinere Dateien)."
        )
        sizer.Add(self.image_format_choice, 0, wx.ALL, 10)

        self.pdf_to_image_button = wx.Button(panel, label="PDF zu &Bildern exportieren...")
        self.pdf_to_image_button.SetName("PDF-Seiten als Bilddateien exportieren")
        self.pdf_to_image_button.SetHelpText(
            "Fragt nach einem Zielordner und speichert dort jede Seite der "
            "ausgewählten PDF-Datei als eigene Bilddatei."
        )
        self.pdf_to_image_button.Bind(wx.EVT_BUTTON, self.on_pdf_to_image)
        sizer.Add(self.pdf_to_image_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)
        self.image_format_choice.MoveAfterInTabOrder(format_label)
        self.pdf_to_image_button.MoveAfterInTabOrder(self.image_format_choice)
        return panel

    def on_pdf_to_image(self, event):
        if not self._require_source():
            return
        if not HAVE_FITZ:
            wx.MessageBox(
                "Diese Funktion benötigt das zusätzliche Paket "
                "'PyMuPDF'. Bitte 'pip install pymupdf' ausführen und "
                "das Programm neu starten.",
                "Paket fehlt",
                wx.OK | wx.ICON_ERROR,
            )
            return
        fmt = "png" if self.image_format_choice.GetSelection() == 0 else "jpg"
        with wx.DirDialog(
            self.notebook, message="Zielordner für Bilddateien auswählen"
        ) as dlg:
            if dlg.ShowModal() == wx.ID_CANCEL:
                return
            target_dir = dlg.GetPath()
        try:
            count = pdf_to_images(self.current_path, target_dir, image_format=fmt)
            self.status_callback(
                "Erfolg: {0} Seite(n) als {1}-Bilder in {2} gespeichert.".format(
                    count, fmt.upper(), target_dir
                )
            )
            wx.MessageBox(
                "{0} Seite(n) wurden als Bilder gespeichert im Ordner:\n{1}".format(
                    count, target_dir
                ),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Exportieren als Bild: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Bild zu PDF ----------------------------------------------------------
    def _build_image_to_pdf_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Fügt mehrere Bilddateien in frei wählbarer Reihenfolge "
                "zu einer einzigen PDF-Datei zusammen, eine Seite je Bild. "
                "Diese Liste ist unabhängig von der Quelldatei oben."
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        list_label = wx.StaticText(panel, label="&Liste der Bilddateien in Reihenfolge:")
        sizer.Add(list_label, 0, wx.LEFT | wx.TOP, 10)

        self.image_listbox = wx.ListBox(panel, style=wx.LB_SINGLE)
        self.image_listbox.SetName("Liste der Bilddateien in Reihenfolge")
        self.image_listbox.SetHelpText(
            "Enthält die ausgewählten Bilddateien in der Reihenfolge, in "
            "der sie als Seiten in die neue PDF-Datei uebernommen werden."
        )
        self.image_paths = []
        sizer.Add(self.image_listbox, 1, wx.ALL | wx.EXPAND, 10)

        button_row = wx.BoxSizer(wx.HORIZONTAL)

        self.image_add_button = wx.Button(panel, label="Bilder hin&zufügen...")
        self.image_add_button.SetName("Bilddateien hinzufügen")
        self.image_add_button.SetHelpText(
            "Öffnet einen Dialog, um eine oder mehrere Bilddateien zur "
            "Liste hinzuzufügen (PNG, JPG, BMP, TIFF)."
        )
        self.image_add_button.Bind(wx.EVT_BUTTON, self.on_image_add)
        button_row.Add(self.image_add_button, 0, wx.ALL, 5)

        self.image_remove_button = wx.Button(panel, label="Ent&fernen")
        self.image_remove_button.SetName("Ausgewähltes Bild aus Liste entfernen")
        self.image_remove_button.SetHelpText(
            "Entfernt die in der Liste markierte Bilddatei aus der Liste."
        )
        self.image_remove_button.Bind(wx.EVT_BUTTON, self.on_image_remove)
        button_row.Add(self.image_remove_button, 0, wx.ALL, 5)

        self.image_up_button = wx.Button(panel, label="Nach o&ben")
        self.image_up_button.SetName("Markiertes Bild in der Liste nach oben verschieben")
        self.image_up_button.SetHelpText(
            "Verschiebt die markierte Bilddatei eine Position nach oben."
        )
        self.image_up_button.Bind(wx.EVT_BUTTON, self.on_image_move_up)
        button_row.Add(self.image_up_button, 0, wx.ALL, 5)

        self.image_down_button = wx.Button(panel, label="Nach u&nten")
        self.image_down_button.SetName("Markiertes Bild in der Liste nach unten verschieben")
        self.image_down_button.SetHelpText(
            "Verschiebt die markierte Bilddatei eine Position nach unten."
        )
        self.image_down_button.Bind(wx.EVT_BUTTON, self.on_image_move_down)
        button_row.Add(self.image_down_button, 0, wx.ALL, 5)

        self.image_clear_button = wx.Button(panel, label="Liste le&eren")
        self.image_clear_button.SetName("Gesamte Bilderliste leeren")
        self.image_clear_button.SetHelpText("Entfernt alle Bilder aus der Liste.")
        self.image_clear_button.Bind(wx.EVT_BUTTON, self.on_image_clear)
        button_row.Add(self.image_clear_button, 0, wx.ALL, 5)

        sizer.Add(button_row, 0, wx.ALIGN_LEFT)

        self.image_to_pdf_button = wx.Button(panel, label="Zu &PDF zusammenfügen...")
        self.image_to_pdf_button.SetName("Bilder zu PDF-Datei zusammenfügen")
        self.image_to_pdf_button.SetHelpText(
            "Fügt alle Bilder aus der Liste in der angezeigten Reihenfolge "
            "zu einer einzigen PDF-Datei zusammen und fragt nach einem "
            "Speicherort."
        )
        self.image_to_pdf_button.Bind(wx.EVT_BUTTON, self.on_image_to_pdf)
        sizer.Add(self.image_to_pdf_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)
        self.image_add_button.MoveAfterInTabOrder(self.image_listbox)
        self.image_remove_button.MoveAfterInTabOrder(self.image_add_button)
        self.image_up_button.MoveAfterInTabOrder(self.image_remove_button)
        self.image_down_button.MoveAfterInTabOrder(self.image_up_button)
        self.image_clear_button.MoveAfterInTabOrder(self.image_down_button)
        self.image_to_pdf_button.MoveAfterInTabOrder(self.image_clear_button)
        return panel

    def on_image_add(self, event):
        with wx.FileDialog(
            self.notebook,
            message="Bilddateien auswählen",
            wildcard=(
                "Bilddateien (*.png;*.jpg;*.jpeg;*.bmp;*.tif;*.tiff)|"
                "*.png;*.jpg;*.jpeg;*.bmp;*.tif;*.tiff"
            ),
            style=wx.FD_OPEN | wx.FD_MULTIPLE | wx.FD_FILE_MUST_EXIST,
        ) as dlg:
            if dlg.ShowModal() == wx.ID_CANCEL:
                return
            paths = dlg.GetPaths()
            for p in paths:
                self.image_paths.append(p)
                self.image_listbox.Append(os.path.basename(p))
            self.status_callback(
                "{0} Bild(er) zur Liste hinzugefügt.".format(len(paths))
            )

    def on_image_remove(self, event):
        idx = self.image_listbox.GetSelection()
        if idx == wx.NOT_FOUND:
            self.status_callback("Bitte zuerst ein Bild in der Liste markieren.")
            return
        name = self.image_listbox.GetString(idx)
        self.image_listbox.Delete(idx)
        del self.image_paths[idx]
        self.status_callback("Bild entfernt: {0}".format(name))

    def on_image_move_up(self, event):
        idx = self.image_listbox.GetSelection()
        if idx == wx.NOT_FOUND or idx == 0:
            return
        self._image_swap(idx, idx - 1)
        self.image_listbox.SetSelection(idx - 1)
        self.status_callback("Bild nach oben verschoben.")

    def on_image_move_down(self, event):
        idx = self.image_listbox.GetSelection()
        if idx == wx.NOT_FOUND or idx >= len(self.image_paths) - 1:
            return
        self._image_swap(idx, idx + 1)
        self.image_listbox.SetSelection(idx + 1)
        self.status_callback("Bild nach unten verschoben.")

    def _image_swap(self, i, j):
        self.image_paths[i], self.image_paths[j] = self.image_paths[j], self.image_paths[i]
        name_i = self.image_listbox.GetString(i)
        name_j = self.image_listbox.GetString(j)
        self.image_listbox.SetString(i, name_j)
        self.image_listbox.SetString(j, name_i)

    def on_image_clear(self, event):
        self.image_listbox.Clear()
        self.image_paths = []
        self.status_callback("Bilderliste geleert.")

    def on_image_to_pdf(self, event):
        if not HAVE_PIL:
            wx.MessageBox(
                "Diese Funktion benötigt das zusätzliche Paket "
                "'Pillow'. Bitte 'pip install Pillow' ausführen und "
                "das Programm neu starten.",
                "Paket fehlt",
                wx.OK | wx.ICON_ERROR,
            )
            return
        if not self.image_paths:
            wx.MessageBox(
                "Bitte mindestens ein Bild zur Liste hinzufügen.",
                "Hinweis",
                wx.OK | wx.ICON_INFORMATION,
            )
            return
        output_path = self._ask_save_path("PDF aus Bildern speichern als")
        if not output_path:
            return
        try:
            count = images_to_pdf(self.image_paths, output_path)
            self.status_callback(
                "Erfolg: {0} Bild(er) zu PDF zusammengefügt, gespeichert "
                "unter {1}".format(count, output_path)
            )
            wx.MessageBox(
                "Die Bilder wurden zu einer PDF-Datei zusammengefügt und "
                "gespeichert unter:\n{0}".format(output_path),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Erstellen der PDF aus Bildern: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Komprimieren -----------------------------------------------------
    def _build_compress_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Verkleinert die Dateigröße der ausgewählten PDF-Datei, "
                "indem eingebettete Bilder stärker komprimiert werden. "
                "Die Bildqualität kann dabei sichtbar abnehmen."
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        quality_label = wx.StaticText(panel, label="Komprimierungs&stufe:")
        sizer.Add(quality_label, 0, wx.LEFT | wx.TOP, 10)

        self.compress_quality_choice = wx.RadioBox(
            panel,
            label="Komprimierungsstufe",
            choices=[
                "Niedrig (kleinste Datei, geringste Qualität)",
                "Mittel (ausgewogen)",
                "Hoch (größere Datei, bessere Qualität)",
            ],
            majorDimension=1,
            style=wx.RA_SPECIFY_ROWS,
        )
        self.compress_quality_choice.SetSelection(1)
        self.compress_quality_choice.SetName("Komprimierungsstufe auswählen")
        self.compress_quality_choice.SetHelpText(
            "Wählen Sie, wie stark die PDF-Datei komprimiert werden soll. "
            "Niedrig ergibt die kleinste Datei bei geringster Bildqualität, "
            "Hoch ergibt eine größere Datei bei besserer Bildqualität."
        )
        sizer.Add(self.compress_quality_choice, 0, wx.ALL, 10)

        self.compress_button = wx.Button(panel, label="&Komprimieren und speichern als...")
        self.compress_button.SetName("PDF-Datei komprimieren und speichern")
        self.compress_button.SetHelpText(
            "Komprimiert die ausgewählte PDF-Datei und fragt anschließend "
            "nach einem Speicherort für das Ergebnis."
        )
        self.compress_button.Bind(wx.EVT_BUTTON, self.on_compress)
        sizer.Add(self.compress_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)
        self.compress_quality_choice.MoveAfterInTabOrder(quality_label)
        self.compress_button.MoveAfterInTabOrder(self.compress_quality_choice)
        return panel

    def on_compress(self, event):
        if not self._require_source():
            return
        quality_map = {0: "niedrig", 1: "mittel", 2: "hoch"}
        quality = quality_map[self.compress_quality_choice.GetSelection()]
        output_path = self._ask_save_path("Komprimierte PDF speichern als")
        if not output_path:
            return
        try:
            original_size, new_size = compress_pdf(
                self.current_path, output_path, image_quality=quality
            )
            original_kb = original_size / 1024.0
            new_kb = new_size / 1024.0
            if original_size > 0:
                percent = 100.0 * (1.0 - (new_size / float(original_size)))
            else:
                percent = 0.0
            self.status_callback(
                "Erfolg: Komprimiert von {0:.0f} KB auf {1:.0f} KB "
                "({2:.0f}% kleiner), gespeichert unter {3}".format(
                    original_kb, new_kb, percent, output_path
                )
            )
            wx.MessageBox(
                "Die Datei wurde komprimiert.\n"
                "Vorher: {0:.0f} KB\nNachher: {1:.0f} KB\n"
                "Ersparnis: {2:.0f}%\n\nGespeichert unter:\n{3}".format(
                    original_kb, new_kb, percent, output_path
                ),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Komprimieren: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Wasserzeichen ------------------------------------------------------
    def _build_watermark_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Fügt auf jeder Seite der ausgewählten PDF-Datei einen "
                "diagonalen, halbtransparenten Wasserzeichen-Text ein, "
                "zum Beispiel 'Entwurf' oder 'Vertraulich'."
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        label = wx.StaticText(panel, label="Wasserzeichen-&Text:")
        sizer.Add(label, 0, wx.LEFT | wx.TOP, 10)

        self.watermark_text_field = wx.TextCtrl(panel)
        self.watermark_text_field.SetName("Text für das Wasserzeichen")
        self.watermark_text_field.SetHelpText(
            "Geben Sie den Text ein, der diagonal auf jede Seite der PDF-"
            "Datei eingefügt werden soll, zum Beispiel 'Entwurf'."
        )
        sizer.Add(self.watermark_text_field, 0, wx.ALL | wx.EXPAND, 10)

        self.watermark_button = wx.Button(panel, label="&Wasserzeichen einfügen und speichern als...")
        self.watermark_button.SetName("Wasserzeichen einfügen und Ergebnis speichern")
        self.watermark_button.SetHelpText(
            "Fügt den eingegebenen Text als Wasserzeichen auf allen Seiten "
            "ein und fragt anschließend nach einem Speicherort."
        )
        self.watermark_button.Bind(wx.EVT_BUTTON, self.on_watermark)
        sizer.Add(self.watermark_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)
        self.watermark_button.MoveAfterInTabOrder(self.watermark_text_field)
        return panel

    def on_watermark(self, event):
        if not self._require_source():
            return
        if not HAVE_FITZ:
            wx.MessageBox(
                "Diese Funktion benötigt das zusätzliche Paket "
                "'PyMuPDF'. Bitte 'pip install pymupdf' ausführen und "
                "das Programm neu starten.",
                "Paket fehlt",
                wx.OK | wx.ICON_ERROR,
            )
            return
        text = self.watermark_text_field.GetValue().strip()
        if not text:
            wx.MessageBox(
                "Bitte einen Wasserzeichen-Text eingeben.",
                "Hinweis",
                wx.OK | wx.ICON_INFORMATION,
            )
            return
        output_path = self._ask_save_path("PDF mit Wasserzeichen speichern als")
        if not output_path:
            return
        try:
            add_watermark_text(self.current_path, output_path, text)
            self.status_callback(
                "Erfolg: Wasserzeichen eingefügt, gespeichert unter {0}".format(
                    output_path
                )
            )
            wx.MessageBox(
                "Das Wasserzeichen wurde eingefügt. Datei gespeichert "
                "unter:\n{0}".format(output_path),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Einfügen des Wasserzeichens: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Seitenzahlen ---------------------------------------------------
    def _build_page_numbers_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Fügt auf jeder Seite der ausgewählten PDF-Datei eine "
                "fortlaufende Seitenzahl ein."
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        start_label = wx.StaticText(panel, label="&Erste Seitenzahl:")
        sizer.Add(start_label, 0, wx.LEFT | wx.TOP, 10)

        self.page_number_start = wx.SpinCtrl(panel, min=1, max=100000, initial=1)
        self.page_number_start.SetName("Erste zu verwendende Seitenzahl")
        self.page_number_start.SetHelpText(
            "Legt fest, mit welcher Zahl die Nummerierung auf der ersten "
            "Seite beginnt."
        )
        sizer.Add(self.page_number_start, 0, wx.ALL, 10)

        position_label = wx.StaticText(panel, label="&Position auf der Seite:")
        sizer.Add(position_label, 0, wx.LEFT | wx.TOP, 10)

        self.page_number_position = wx.RadioBox(
            panel,
            label="Position auf der Seite",
            choices=["Unten Mitte", "Unten Rechts", "Unten Links"],
            majorDimension=1,
            style=wx.RA_SPECIFY_COLS,
        )
        self.page_number_position.SetName("Position der Seitenzahl auswählen")
        self.page_number_position.SetHelpText(
            "Wählen Sie, an welcher Stelle am unteren Rand der Seite die "
            "Seitenzahl eingefügt werden soll."
        )
        sizer.Add(self.page_number_position, 0, wx.ALL, 10)

        self.page_number_button = wx.Button(panel, label="Seiten&zahlen einfügen und speichern als...")
        self.page_number_button.SetName("Seitenzahlen einfügen und Ergebnis speichern")
        self.page_number_button.SetHelpText(
            "Fügt fortlaufende Seitenzahlen auf allen Seiten ein und fragt "
            "anschließend nach einem Speicherort."
        )
        self.page_number_button.Bind(wx.EVT_BUTTON, self.on_add_page_numbers)
        sizer.Add(self.page_number_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)
        self.page_number_position.MoveAfterInTabOrder(self.page_number_start)
        self.page_number_button.MoveAfterInTabOrder(self.page_number_position)
        return panel

    def on_add_page_numbers(self, event):
        if not self._require_source():
            return
        if not HAVE_FITZ:
            wx.MessageBox(
                "Diese Funktion benötigt das zusätzliche Paket "
                "'PyMuPDF'. Bitte 'pip install pymupdf' ausführen und "
                "das Programm neu starten.",
                "Paket fehlt",
                wx.OK | wx.ICON_ERROR,
            )
            return
        start_number = self.page_number_start.GetValue()
        position_map = {0: "unten-mitte", 1: "unten-rechts", 2: "unten-links"}
        position = position_map[self.page_number_position.GetSelection()]
        output_path = self._ask_save_path("PDF mit Seitenzahlen speichern als")
        if not output_path:
            return
        try:
            add_page_numbers(
                self.current_path, output_path, start_number=start_number, position=position
            )
            self.status_callback(
                "Erfolg: Seitenzahlen eingefügt, gespeichert unter {0}".format(
                    output_path
                )
            )
            wx.MessageBox(
                "Die Seitenzahlen wurden eingefügt. Datei gespeichert "
                "unter:\n{0}".format(output_path),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Einfügen der Seitenzahlen: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Signieren (Datum und Unterschrift) --------------------------------
    def _build_sign_panel(self, parent):
        import datetime

        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Fügt ein Datum und/oder eine Unterschrift in die "
                "ausgewählte PDF-Datei ein. Die Unterschrift kann entweder "
                "als Bilddatei (zum Beispiel eine eingescannte Unterschrift) "
                "oder als getippter Name in Schreibschrift-Optik eingefügt "
                "werden. Ist die gewählte Stelle bereits mit Text oder "
                "einem Bild belegt, wird automatisch eine freie Stelle "
                "weiter oben auf derselben Seite gesucht. Findet sich auch "
                "dort nichts, wird die Seite am unteren Rand verlängert, "
                "sodass dort garantiert Platz entsteht. Nach dem Einfügen "
                "wird in Worten mitgeteilt, wo genau Datum und/oder "
                "Unterschrift gelandet sind."
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        # -- Datum --------------------------------------------------------
        date_box = wx.StaticBox(panel, label="Datum")
        date_sizer = wx.StaticBoxSizer(date_box, wx.VERTICAL)

        self.sign_date_checkbox = wx.CheckBox(date_box, label="&Datum einfügen")
        self.sign_date_checkbox.SetName("Datum einfügen aktivieren")
        self.sign_date_checkbox.SetHelpText(
            "Wenn aktiviert, wird das unten eingetragene Datum auf den "
            "Zielseiten eingefügt."
        )
        self.sign_date_checkbox.SetValue(True)
        date_sizer.Add(self.sign_date_checkbox, 0, wx.ALL, 5)

        date_label = wx.StaticText(date_box, label="Datums&text:")
        today_text = datetime.date.today().strftime("%d.%m.%Y")
        self.sign_date_field = wx.TextCtrl(date_box, value=today_text)
        self.sign_date_field.SetName("Datumstext für die Unterschrift")
        self.sign_date_field.SetHelpText(
            "Enthält vorausgefüllt das heutige Datum. Der Text kann frei "
            "geändert werden, zum Beispiel auf ein anderes Datumsformat."
        )
        date_sizer.Add(date_label, 0, wx.LEFT | wx.TOP, 5)
        date_sizer.Add(self.sign_date_field, 0, wx.ALL | wx.EXPAND, 5)

        sizer.Add(date_sizer, 0, wx.ALL | wx.EXPAND, 10)

        # -- Unterschrift ---------------------------------------------------
        sig_box = wx.StaticBox(panel, label="Unterschrift")
        sig_sizer = wx.StaticBoxSizer(sig_box, wx.VERTICAL)

        self.sign_mode_choice = wx.RadioBox(
            sig_box,
            label="Art der Unterschrift",
            choices=[
                "Keine Unterschrift (nur Datum)",
                "Bilddatei verwenden",
                "Getippter Name (Schreibschrift-Optik)",
            ],
            majorDimension=1,
            style=wx.RA_SPECIFY_ROWS,
        )
        self.sign_mode_choice.SetName("Art der Unterschrift auswählen")
        self.sign_mode_choice.SetHelpText(
            "Wählen Sie, ob keine Unterschrift, eine Bilddatei mit einer "
            "eingescannten Unterschrift oder ein getippter Name in "
            "Schreibschrift-Optik verwendet werden soll."
        )
        self.sign_mode_choice.SetSelection(0)
        self.sign_mode_choice.Bind(wx.EVT_RADIOBOX, self.on_sign_mode_change)
        sig_sizer.Add(self.sign_mode_choice, 0, wx.ALL | wx.EXPAND, 5)

        # Bilddatei-Auswahl
        image_row = wx.BoxSizer(wx.HORIZONTAL)
        self.sign_image_display = wx.TextCtrl(
            sig_box, style=wx.TE_READONLY, value="Keine Bilddatei ausgewählt"
        )
        self.sign_image_display.SetName("Pfad der ausgewählten Unterschrift-Bilddatei")
        self.sign_image_display.SetHelpText(
            "Zeigt den Pfad der ausgewählten Unterschrift-Bilddatei an."
        )
        image_row.Add(self.sign_image_display, 1, wx.ALL | wx.EXPAND, 5)

        self.sign_image_button = wx.Button(sig_box, label="&Bilddatei auswählen...")
        self.sign_image_button.SetName("Unterschrift-Bilddatei auswählen")
        self.sign_image_button.SetHelpText(
            "Öffnet einen Dialog, um eine Bilddatei mit einer Unterschrift "
            "auszuwählen (PNG, JPG)."
        )
        self.sign_image_button.Bind(wx.EVT_BUTTON, self.on_choose_signature_image)
        image_row.Add(self.sign_image_button, 0, wx.ALL, 5)
        sig_sizer.Add(image_row, 0, wx.EXPAND)
        self.sign_signature_image_path = None

        # Getippter Name
        name_label = wx.StaticText(sig_box, label="&Name für Schreibschrift-Unterschrift:")
        self.sign_name_field = wx.TextCtrl(sig_box)
        self.sign_name_field.SetName("Name für die Schreibschrift-Unterschrift")
        self.sign_name_field.SetHelpText(
            "Geben Sie den Namen ein, der in Schreibschrift-Optik als "
            "Unterschrift eingefügt werden soll."
        )
        sig_sizer.Add(name_label, 0, wx.LEFT | wx.TOP, 5)
        sig_sizer.Add(self.sign_name_field, 0, wx.ALL | wx.EXPAND, 5)

        sizer.Add(sig_sizer, 0, wx.ALL | wx.EXPAND, 10)

        # -- Zielseite und Position -----------------------------------------
        target_box = wx.StaticBox(panel, label="Zielseite und Position")
        target_sizer = wx.StaticBoxSizer(target_box, wx.VERTICAL)

        self.sign_target_choice = wx.RadioBox(
            target_box,
            label="Zielseite(n)",
            choices=["Auf allen Seiten", "Auf einer bestimmten Seite/Seiten"],
            majorDimension=1,
            style=wx.RA_SPECIFY_ROWS,
        )
        self.sign_target_choice.SetName("Zielseiten für Datum und Unterschrift auswählen")
        self.sign_target_choice.SetHelpText(
            "Wählen Sie, ob Datum und Unterschrift auf allen Seiten oder "
            "nur auf bestimmten Seiten eingefügt werden sollen."
        )
        self.sign_target_choice.SetSelection(1)
        self.sign_target_choice.Bind(wx.EVT_RADIOBOX, self.on_sign_target_change)
        target_sizer.Add(self.sign_target_choice, 0, wx.ALL | wx.EXPAND, 5)

        target_pages_label = wx.StaticText(
            target_box, label="Seiten&zahlen (z. B. 1 oder 1,3 oder die letzte Seite):"
        )
        self.sign_target_pages_field = wx.TextCtrl(target_box, value="1")
        self.sign_target_pages_field.SetName("Seitenzahlen für Datum und Unterschrift")
        self.sign_target_pages_field.SetHelpText(
            "Geben Sie die Seitenzahlen ein, auf denen Datum und "
            "Unterschrift eingefügt werden sollen. Mehrere Zahlen durch "
            "Komma trennen, Bereiche mit Bindestrich angeben, zum Beispiel "
            "1,3,5-8. Wird nur verwendet, wenn oben 'Auf einer bestimmten "
            "Seite/Seiten' ausgewählt ist."
        )
        target_sizer.Add(target_pages_label, 0, wx.LEFT | wx.TOP, 5)
        target_sizer.Add(self.sign_target_pages_field, 0, wx.ALL | wx.EXPAND, 5)

        position_label = wx.StaticText(target_box, label="&Position auf der Seite:")
        self.sign_position_choice = wx.RadioBox(
            target_box,
            label="Position auf der Seite",
            choices=["Unten Rechts", "Unten Mitte", "Unten Links"],
            majorDimension=1,
            style=wx.RA_SPECIFY_COLS,
        )
        self.sign_position_choice.SetName("Position von Datum und Unterschrift auswählen")
        self.sign_position_choice.SetHelpText(
            "Wählen Sie, an welcher Stelle am unteren Rand der Seite "
            "Datum und Unterschrift eingefügt werden sollen."
        )
        target_sizer.Add(position_label, 0, wx.LEFT | wx.TOP, 5)
        target_sizer.Add(self.sign_position_choice, 0, wx.ALL, 5)

        sizer.Add(target_sizer, 0, wx.ALL | wx.EXPAND, 10)

        self.sign_button = wx.Button(panel, label="&Signieren und speichern als...")
        self.sign_button.SetName("Datum und Unterschrift einfügen und Ergebnis speichern")
        self.sign_button.SetHelpText(
            "Fügt Datum und/oder Unterschrift gemäß den obigen "
            "Einstellungen ein und fragt anschließend nach einem "
            "Speicherort für das Ergebnis."
        )
        self.sign_button.Bind(wx.EVT_BUTTON, self.on_sign)
        sizer.Add(self.sign_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)

        # Anfangszustand: Bildauswahl und Namensfeld passend zur Auswahl
        # ein-/ausblenden.
        self._update_sign_mode_controls()
        self._update_sign_target_controls()

        # Logische Tab-Reihenfolge
        self.sign_date_field.MoveAfterInTabOrder(self.sign_date_checkbox)

        self.sign_image_display.MoveAfterInTabOrder(self.sign_mode_choice)
        self.sign_image_button.MoveAfterInTabOrder(self.sign_image_display)
        self.sign_name_field.MoveAfterInTabOrder(self.sign_image_button)

        self.sign_target_pages_field.MoveAfterInTabOrder(self.sign_target_choice)
        self.sign_position_choice.MoveAfterInTabOrder(self.sign_target_pages_field)
        return panel

    def _update_sign_mode_controls(self):
        mode = self.sign_mode_choice.GetSelection()
        use_image = (mode == 1)
        use_name = (mode == 2)
        self.sign_image_display.Enable(use_image)
        self.sign_image_button.Enable(use_image)
        self.sign_name_field.Enable(use_name)

    def on_sign_mode_change(self, event):
        self._update_sign_mode_controls()

    def _update_sign_target_controls(self):
        use_specific = (self.sign_target_choice.GetSelection() == 1)
        self.sign_target_pages_field.Enable(use_specific)

    def on_sign_target_change(self, event):
        self._update_sign_target_controls()

    def on_choose_signature_image(self, event):
        with wx.FileDialog(
            self.notebook,
            message="Unterschrift-Bilddatei auswählen",
            wildcard="Bilddateien (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg",
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        ) as dlg:
            if dlg.ShowModal() == wx.ID_CANCEL:
                return
            self.sign_signature_image_path = dlg.GetPath()
            self.sign_image_display.SetValue(self.sign_signature_image_path)
            self.status_callback(
                "Unterschrift-Bilddatei ausgewählt: {0}".format(
                    os.path.basename(self.sign_signature_image_path)
                )
            )

    def on_sign(self, event):
        if not self._require_source():
            return
        if not HAVE_FITZ:
            wx.MessageBox(
                "Diese Funktion benötigt das zusätzliche Paket "
                "'PyMuPDF'. Bitte 'pip install pymupdf' ausführen und "
                "das Programm neu starten.",
                "Paket fehlt",
                wx.OK | wx.ICON_ERROR,
            )
            return

        # Datum
        date_text = None
        if self.sign_date_checkbox.GetValue():
            date_text = self.sign_date_field.GetValue().strip()
            if not date_text:
                wx.MessageBox(
                    "Bitte einen Datumstext eingeben oder das Häkchen "
                    "'Datum einfügen' entfernen.",
                    "Hinweis",
                    wx.OK | wx.ICON_INFORMATION,
                )
                return

        # Unterschrift
        mode = self.sign_mode_choice.GetSelection()
        signature_image_path = None
        signature_name_text = None
        if mode == 1:
            if not self.sign_signature_image_path:
                wx.MessageBox(
                    "Bitte eine Bilddatei für die Unterschrift auswählen.",
                    "Hinweis",
                    wx.OK | wx.ICON_INFORMATION,
                )
                return
            signature_image_path = self.sign_signature_image_path
        elif mode == 2:
            signature_name_text = self.sign_name_field.GetValue().strip()
            if not signature_name_text:
                wx.MessageBox(
                    "Bitte einen Namen für die Schreibschrift-Unterschrift "
                    "eingeben.",
                    "Hinweis",
                    wx.OK | wx.ICON_INFORMATION,
                )
                return

        if not date_text and not signature_image_path and not signature_name_text:
            wx.MessageBox(
                "Bitte mindestens ein Datum oder eine Unterschrift "
                "auswählen.",
                "Hinweis",
                wx.OK | wx.ICON_INFORMATION,
            )
            return

        # Zielseiten
        try:
            reader = PdfReader(self.current_path)
            if reader.is_encrypted:
                raise PdfReadError("Datei ist passwortgeschützt.")
            total = len(reader.pages)
            if self.sign_target_choice.GetSelection() == 0:
                target_pages = resolve_sign_target_pages(SIGNATURE_TARGET_ALL, total)
            else:
                target_pages = resolve_sign_target_pages(
                    self.sign_target_pages_field.GetValue(), total
                )
        except Exception as e:
            wx.MessageBox(str(e), "Fehler in der Eingabe", wx.OK | wx.ICON_ERROR)
            return

        position_map = {0: "unten-rechts", 1: "unten-mitte", 2: "unten-links"}
        position = position_map[self.sign_position_choice.GetSelection()]

        output_path = self._ask_save_path("Signierte PDF speichern als")
        if not output_path:
            return
        try:
            count, page_results = sign_pdf(
                self.current_path,
                output_path,
                target_pages,
                date_text=date_text,
                signature_image_path=signature_image_path,
                signature_name_text=signature_name_text,
                position=position,
            )
            summary = self._build_sign_summary(page_results)
            self.status_callback(
                "Erfolg: Datum/Unterschrift auf {0} Seite(n) eingefügt, "
                "gespeichert unter {1}. {2}".format(
                    count, output_path, summary
                )
            )
            wx.MessageBox(
                "Datum und/oder Unterschrift wurden auf {0} Seite(n) "
                "eingefügt. Datei gespeichert unter:\n{1}\n\n"
                "{2}".format(count, output_path, summary),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Signieren: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    def _build_sign_summary(self, page_results):
        """
        Baut aus den Seiten-Ergebnissen von sign_pdf eine in Worten
        vorlesbare Zusammenfassung, wo Datum/Unterschrift tatsächlich
        gelandet sind. Bei wenigen Zielseiten (bis 5) wird jede Seite
        einzeln genannt. Bei vielen Zielseiten wird die Position für den
        Normalfall (nicht verlängerte Seiten) zusammengefasst und nur die
        tatsächlich verlängerten Seiten werden zusätzlich einzeln genannt,
        damit die Meldung auch bei langen Dokumenten überschaubar bleibt.
        """
        if not page_results:
            return ""

        if len(page_results) <= 5:
            lines = [
                "Seite {0}: {1}.".format(r["page"], r["description"])
                for r in page_results
            ]
            return " ".join(lines)

        extended_results = [r for r in page_results if r["extended"]]
        normal_results = [r for r in page_results if not r["extended"]]

        parts = []
        if normal_results:
            parts.append(
                "Auf {0} Seite(n) wurde die übliche Position verwendet: "
                "{1}.".format(
                    len(normal_results), normal_results[0]["description"]
                )
            )
        if extended_results:
            pages_text = ", ".join(str(r["page"]) for r in extended_results)
            parts.append(
                "Auf Seite(n) {0} war dort kein Platz, diese Seite(n) "
                "wurden am Ende um etwa {1:.1f} cm verlängert, dort "
                "befindet sich die Einfügung.".format(
                    pages_text, PAGE_EXTENSION_HEIGHT / 28.3465
                )
            )
        return " ".join(parts)

    # -- Text exportieren -----------------------------------------------
    def _build_text_extract_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Extrahiert den gesamten Text der ausgewählten PDF-Datei "
                "und speichert ihn als einfache TXT-Datei. Jede Seite wird "
                "durch eine Trennzeile mit der jeweiligen Seitenzahl "
                "gekennzeichnet."
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        self.text_extract_button = wx.Button(panel, label="Text als &TXT-Datei exportieren...")
        self.text_extract_button.SetName("Text der PDF-Datei als TXT-Datei exportieren")
        self.text_extract_button.SetHelpText(
            "Liest den gesamten Text der ausgewählten PDF-Datei aus und "
            "speichert ihn als TXT-Datei an einem gewählten Speicherort."
        )
        self.text_extract_button.Bind(wx.EVT_BUTTON, self.on_extract_text)
        sizer.Add(self.text_extract_button, 0, wx.ALL, 10)

        panel.SetSizer(sizer)
        return panel

    def on_extract_text(self, event):
        if not self._require_source():
            return
        with wx.FileDialog(
            self.notebook,
            message="Text speichern als",
            wildcard="Textdateien (*.txt)|*.txt",
            style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT,
        ) as dlg:
            if dlg.ShowModal() == wx.ID_CANCEL:
                return
            output_path = dlg.GetPath()
            if not output_path.lower().endswith(".txt"):
                output_path += ".txt"
        try:
            count = extract_text_from_pdf(self.current_path, output_path)
            self.status_callback(
                "Erfolg: Text aus {0} Seite(n) exportiert, gespeichert "
                "unter {1}".format(count, output_path)
            )
            wx.MessageBox(
                "Der Text wurde aus {0} Seite(n) extrahiert und "
                "gespeichert unter:\n{1}".format(count, output_path),
                "Erfolgreich",
                wx.OK | wx.ICON_INFORMATION,
            )
        except Exception as e:
            self.status_callback("Fehler beim Extrahieren des Texts: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)

    # -- Schwarze Scanrahmen entfernen ------------------------------------
    def _build_remove_border_panel(self, parent):
        panel = wx.Panel(parent)
        sizer = wx.BoxSizer(wx.VERTICAL)

        self._add_source_box(panel, sizer)

        text = wx.StaticText(
            panel,
            label=(
                "Entfernt schwarze Rahmen, die beim Scannen von Dokumenten "
                "entstehen können. Nach dem Auswählen der PDF-Datei wird "
                "mit 'Datei prüfen' zuerst überprüft, ob überhaupt schwarze "
                "Rahmen vorhanden sind. Werden Rahmen gefunden, kann die "
                "bereinigte Datei anschließend gespeichert werden. Seiten "
                "ohne Rahmen bleiben dabei unverändert."
            ),
        )
        text.Wrap(600)
        sizer.Add(text, 0, wx.ALL | wx.EXPAND, 10)

        btn_box = wx.StaticBox(panel, label="Aktionen")
        btn_sizer = wx.StaticBoxSizer(btn_box, wx.HORIZONTAL)

        self.check_border_button = wx.Button(panel, label="Datei &prüfen")
        self.check_border_button.SetName("Schwarze Rahmen prüfen")
        self.check_border_button.SetHelpText(
            "Prüft die ausgewählte PDF-Datei auf schwarze Scanrahmen und "
            "zeigt das Ergebnis in einem Meldungsfenster an."
        )
        self.check_border_button.Bind(wx.EVT_BUTTON, self.on_check_border)
        btn_sizer.Add(self.check_border_button, 0, wx.ALL, 5)

        self.remove_border_button = wx.Button(panel, label="Rahmen entfernen und &speichern...")
        self.remove_border_button.SetName("Schwarze Rahmen entfernen und speichern")
        self.remove_border_button.SetHelpText(
            "Entfernt die schwarzen Rahmen von allen betroffenen Seiten "
            "und speichert die bereinigte PDF-Datei unter einem neuen Namen."
        )
        self.remove_border_button.Bind(wx.EVT_BUTTON, self.on_remove_border)
        btn_sizer.Add(self.remove_border_button, 0, wx.ALL, 5)

        sizer.Add(btn_sizer, 0, wx.ALL | wx.EXPAND, 10)
        panel.SetSizer(sizer)
        self.remove_border_button.MoveAfterInTabOrder(self.check_border_button)
        return panel

    def on_check_border(self, event):
        if not self._require_source():
            return
        if not HAVE_FITZ or not HAVE_NUMPY:
            wx.MessageBox(
                "Für diese Funktion werden die Pakete 'PyMuPDF' und 'numpy' "
                "benötigt. Bitte installieren und das Programm neu starten.",
                "Paket fehlt",
                wx.OK | wx.ICON_ERROR,
            )
            return
        self.status_callback("Prüfe auf schwarze Rahmen …")
        try:
            borders = check_black_borders(self.current_path)
        except Exception as e:
            self.status_callback("Fehler bei der Prüfung: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)
            return

        if not borders:
            self.status_callback("Prüfung abgeschlossen: kein schwarzer Rahmen gefunden.")
            wx.MessageBox(
                "In der ausgewählten PDF-Datei wurde kein schwarzer "
                "Scanrahmen gefunden. Es ist keine Bearbeitung nötig.",
                "Kein schwarzer Rahmen gefunden",
                wx.OK | wx.ICON_INFORMATION,
            )
        else:
            pages_text = ", ".join(str(p) for p in sorted(borders))
            self.status_callback(
                "Prüfung abgeschlossen: schwarze Rahmen auf Seite(n) {0} "
                "gefunden.".format(pages_text)
            )
            wx.MessageBox(
                "Schwarze Scanrahmen wurden auf {0} Seite(n) gefunden:\n"
                "Seite(n): {1}\n\n"
                "Klicken Sie auf 'Rahmen entfernen und speichern', um die "
                "bereinigte Datei zu speichern.".format(
                    len(borders), pages_text
                ),
                "Schwarze Rahmen gefunden",
                wx.OK | wx.ICON_INFORMATION,
            )

    def on_remove_border(self, event):
        if not self._require_source():
            return
        if not HAVE_FITZ or not HAVE_NUMPY:
            wx.MessageBox(
                "Für diese Funktion werden die Pakete 'PyMuPDF' und 'numpy' "
                "benötigt. Bitte installieren und das Programm neu starten.",
                "Paket fehlt",
                wx.OK | wx.ICON_ERROR,
            )
            return
        output_path = self._ask_save_path(
            "Bereinigte PDF speichern als",
            default_name=os.path.splitext(
                os.path.basename(self.current_path)
            )[0] + "_ohne_Rahmen.pdf",
        )
        if not output_path:
            return
        self.status_callback("Entferne schwarze Rahmen …")
        try:
            total, treated = remove_black_borders(self.current_path, output_path)
            if treated == 0:
                self.status_callback(
                    "Abgeschlossen: kein schwarzer Rahmen gefunden, "
                    "Datei wurde unverändert gespeichert."
                )
                wx.MessageBox(
                    "In der ausgewählten Datei wurden keine schwarzen "
                    "Rahmen gefunden. Die Datei wurde unverändert "
                    "gespeichert unter:\n{0}".format(output_path),
                    "Kein Rahmen gefunden",
                    wx.OK | wx.ICON_INFORMATION,
                )
            else:
                self.status_callback(
                    "Erfolg: Rahmen auf {0} von {1} Seite(n) entfernt, "
                    "gespeichert unter {2}".format(treated, total, output_path)
                )
                wx.MessageBox(
                    "Schwarze Rahmen wurden auf {0} von {1} Seite(n) "
                    "entfernt. Die bereinigte Datei wurde gespeichert "
                    "unter:\n{2}".format(treated, total, output_path),
                    "Erfolgreich",
                    wx.OK | wx.ICON_INFORMATION,
                )
        except Exception as e:
            self.status_callback("Fehler beim Entfernen der Rahmen: {0}".format(e))
            wx.MessageBox(str(e), "Fehler", wx.OK | wx.ICON_ERROR)



# ---------------------------------------------------------------------------
# Hauptfenster
# ---------------------------------------------------------------------------

class MainFrame(wx.Frame):
    def __init__(self):
        super().__init__(
            None,
            title="{0} - PDF-Werkzeug".format(APP_NAME),
            size=(820, 640),
        )
        self.SetName("Soft-Team-PDF-Engine Hauptfenster")

        self._build_menu()

        self.notebook = wx.Notebook(self)
        self.notebook.SetName("Hauptbereich mit Werkzeug-Reitern")
        self.notebook.SetHelpText(
            "Wechseln Sie mit Strg+Tab oder den Pfeiltasten zwischen den "
            "Werkzeug-Reitern."
        )

        self.merge_panel = MergePanel(self.notebook, self.set_status)
        self.notebook.AddPage(self.merge_panel, "PDF &zusammenfügen")

        # ToolsPanel legt seine Werkzeug-Reiter (Trennen, Seiten entfernen,
        # Extrahieren, Drehen, Passwort, Informationen, PDF zu Bild, Bild
        # zu PDF, Komprimieren, Wasserzeichen, Seitenzahlen, Signieren,
        # Text exportieren) direkt im selben Notebook an - es gibt keine
        # verschachtelten Reiter mehr. Da nun alle 14 Reiter auf einer
        # Ebene liegen, sind die Tastenkürzel (&) so gewählt, dass sie
        # über alle Reiter hinweg eindeutig sind.
        self.tools_panel = ToolsPanel(self.notebook, self.set_status)

        self.status_bar = self.CreateStatusBar(1)
        self.status_bar.SetStatusText("Bereit.")

        sizer = wx.BoxSizer(wx.VERTICAL)
        sizer.Add(self.notebook, 1, wx.EXPAND)
        self.SetSizer(sizer)

        self.Bind(wx.EVT_CLOSE, self.on_close)
        self.notebook.SetFocus()
        self.Centre()

    def _build_menu(self):
        menu_bar = wx.MenuBar()

        file_menu = wx.Menu()
        about_item = file_menu.Append(wx.ID_ABOUT, "&Über Soft-Team-PDF-Engine\tF1")
        file_menu.AppendSeparator()
        exit_item = file_menu.Append(wx.ID_EXIT, "&Beenden\tStrg+Q")
        menu_bar.Append(file_menu, "&Datei")

        self.SetMenuBar(menu_bar)
        self.Bind(wx.EVT_MENU, self.on_about, about_item)
        self.Bind(wx.EVT_MENU, self.on_exit, exit_item)

    def set_status(self, message):
        self.status_bar.SetStatusText(message)

    def on_about(self, event):
        info = wx.adv.AboutDialogInfo()
        info.SetName(APP_NAME)
        info.SetVersion(APP_VERSION)
        info.SetDescription(
            "Ein barrierefreies Werkzeug zum Bearbeiten von PDF-Dateien.\n"
            "Funktionen: Zusammenfügen, Trennen, Seiten entfernen, "
            "Seitenbereich extrahieren, Drehen, Passwortschutz, Metadaten, "
            "PDF zu Bild, Bild zu PDF, Komprimieren, Wasserzeichen, "
            "Seitenzahlen, Signieren (Datum/Unterschrift), Textexport."
        )
        wx.adv.AboutBox(info)

    def on_exit(self, event):
        self.Close()

    def on_close(self, event):
        self.Destroy()


class SoftTeamPdfApp(wx.App):
    def OnInit(self):
        frame = MainFrame()
        frame.Show()
        self.SetTopWindow(frame)
        return True


def main():
    app = SoftTeamPdfApp()
    app.MainLoop()


if __name__ == "__main__":
    main()
