diff --git a/pdf-umbenenner-adapter-in-gui/pom.xml b/pdf-umbenenner-adapter-in-gui/pom.xml index 186406a..9aa6e7c 100644 --- a/pdf-umbenenner-adapter-in-gui/pom.xml +++ b/pdf-umbenenner-adapter-in-gui/pom.xml @@ -39,7 +39,7 @@ javafx-controls win - + org.openjfx javafx-swing @@ -47,11 +47,10 @@ win - + - com.dlsc.pdfviewfx - pdfviewfx - 3.3.2 + org.apache.pdfbox + pdfbox diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java index 1d8fe0d..8504fff 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java @@ -1,23 +1,31 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun; +import java.awt.image.BufferedImage; import java.io.File; import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicLong; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; -import com.dlsc.pdfviewfx.PDFView; import javafx.application.Platform; +import javafx.embed.swing.SwingFXUtils; import javafx.geometry.Insets; import javafx.geometry.Pos; -import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.ProgressIndicator; -import javafx.scene.control.ScrollPane; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; @@ -27,27 +35,34 @@ import javafx.scene.layout.VBox; /** * Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei. * - *

Die Komponente zeigt eine einzelne Seite der PDF-Datei vollständig eingepasst - * (fit-to-view) an. Das Laden erfolgt auf einem Hintergrund-Worker-Thread; UI-Updates - * laufen ausschließlich über den JavaFX Application Thread. Der Zoom wird anhand der - * aktuellen Anzeigefläche und eines A4-Seiten-Fallbacks berechnet, sodass die Seite - * ohne Scrollbalken vollständig sichtbar ist. Bei Größenänderungen der Anzeigefläche - * wird der Zoom automatisch neu berechnet. + *

Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis + * in einer {@link ImageView} an. Die Anzeige ist vollständig eingepasst (fit-to-view): + * {@code fitWidth} und {@code fitHeight} der {@link ImageView} sind an die Größe des + * umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das + * Seitenverhältnis. Es entstehen weder Scrollbalken noch Zoom-Artefakte. * - *

Es gilt das Prinzip „Latest Preview Request Wins": Veraltete Lade-Ergebnisse - * werden verworfen, sobald eine neue Anforderung eingeht. + *

Das Laden der PDF-Datei und das Rendering einzelner Seiten erfolgt auf einem + * dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX + * Application Thread. Bereits gerenderte Seiten werden in einem In-Memory-Cache + * ({@code Map}) gehalten, sodass wiederholte Navigation kein + * erneutes Rendering erfordert. Der Cache wird beim Wechsel der Quelldatei geleert. + * + *

Es gilt das Prinzip „Latest Preview Request Wins": Veraltete Lade- und + * Rendering-Ergebnisse werden anhand einer Sequenznummer erkannt und verworfen, + * sobald eine neue Anforderung eingeht. * *

Fehlerfälle

* * *

Threading

*

Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen - * werden. Internes Laden und Seitenverhältnis-Ermittlung laufen auf einem dedizierten - * Worker-Thread. + * werden. Das PDF-Öffnen, die Speicherhaltung des {@link PDDocument} und das + * Rendering einzelner Seiten laufen ausschließlich auf dem Worker-Thread. */ public final class PdfPreviewPane { @@ -59,9 +74,12 @@ public final class PdfPreviewPane { static final String PDF_PASSWORD_PROTECTED_TEXT = "PDF ist passwortgeschützt und kann nicht angezeigt werden"; + /** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */ + private static final float RENDER_DPI = 120f; + private final VBox root = new VBox(4); private final StackPane viewStack = new StackPane(); - private final PDFView pdfView = new PDFView(); + private final ImageView imageView = new ImageView(); private final Label overlayLabel = new Label(PLACEHOLDER_TEXT); private final ProgressIndicator progressIndicator = new ProgressIndicator(); private final Label pageLabel = new Label(); @@ -71,11 +89,18 @@ public final class PdfPreviewPane { /** * Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung - * erhöht diesen Zähler. Lade-Ergebnisse mit veralteter Sequenznummer werden verworfen. + * (Laden oder Seitenwechsel) erhöht diesen Zähler. Lade-/Rendering-Ergebnisse + * mit veralteter Sequenznummer werden verworfen. */ private final AtomicLong currentRequestSequence = new AtomicLong(0); - /** Hintergrund-Thread-Pool für Lade-Aufgaben. */ + /** + * Cache bereits gerenderter Seiten für die aktuell geladene Quelldatei. + * Schlüssel ist die 1-basierte Seitennummer. Wird beim Wechsel der Quelldatei geleert. + */ + private final Map pageCache = new ConcurrentHashMap<>(); + + /** Hintergrund-Thread-Pool für Lade- und Rendering-Aufgaben. */ private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "pdf-preview-worker"); @@ -83,6 +108,18 @@ public final class PdfPreviewPane { return t; }); + /** + * Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread. + * {@code null} wenn kein Dokument geöffnet ist. + */ + private PDDocument currentDocument = null; + + /** + * Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread. + * {@code null} wenn kein Dokument geöffnet ist. + */ + private PDFRenderer currentRenderer = null; + /** Aktuell geladene Quelldatei; null wenn keine Selektion vorliegt. */ private Path currentSourceFile = null; @@ -101,28 +138,12 @@ public final class PdfPreviewPane { public PdfPreviewPane() { sectionTitle.setStyle("-fx-font-weight: bold;"); - // PDFView-Konfiguration: Thumbnails und Toolbar ausblenden für kompakten Modus - pdfView.setShowThumbnails(false); - pdfView.setShowToolBar(false); - pdfView.setId("pdf-preview-view"); - - // Scrollbalken per CSS ausblenden – gilt für den internen ScrollPane der Skin - pdfView.setStyle( - ".scroll-bar:vertical { -fx-pref-width: 0; visibility: hidden; }" + - ".scroll-bar:horizontal { -fx-pref-height: 0; visibility: hidden; }"); - - // Nach der Skin-Installation Scrollbalken im internen ScrollPane auch per Policy deaktivieren - pdfView.skinProperty().addListener((obs, oldSkin, newSkin) -> { - if (newSkin != null) { - Platform.runLater(() -> { - Node found = pdfView.lookup(".scroll-pane"); - if (found instanceof ScrollPane sp) { - sp.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - sp.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - } - }); - } - }); + imageView.setId("pdf-preview-image-view"); + imageView.setPreserveRatio(true); + imageView.setSmooth(true); + // Fit-to-view: ImageView füllt den verfügbaren Bereich unter Wahrung des Seitenverhältnisses + imageView.fitWidthProperty().bind(viewStack.widthProperty()); + imageView.fitHeightProperty().bind(viewStack.heightProperty()); overlayLabel.setId("pdf-preview-overlay-label"); overlayLabel.setStyle("-fx-text-fill: #555555;"); @@ -136,16 +157,13 @@ public final class PdfPreviewPane { progressIndicator.setMaxWidth(60); progressIndicator.setMaxHeight(60); - // Stack: PDFView hinter dem Overlay; Overlay überlagert PDFView bei Fehlern/Laden - viewStack.getChildren().addAll(pdfView, overlayLabel, progressIndicator); + // Stack: ImageView hinter dem Overlay; Overlay überlagert das Bild bei Fehlern/Laden + viewStack.getChildren().addAll(imageView, overlayLabel, progressIndicator); + StackPane.setAlignment(imageView, Pos.CENTER); StackPane.setAlignment(overlayLabel, Pos.CENTER); StackPane.setAlignment(progressIndicator, Pos.CENTER); VBox.setVgrow(viewStack, Priority.ALWAYS); - // Bei Größenänderungen der PDF-Ansicht Zoom neu berechnen - pdfView.widthProperty().addListener((obs, ov, nv) -> updateZoom()); - pdfView.heightProperty().addListener((obs, ov, nv) -> updateZoom()); - prevButton.setId("pdf-preview-prev-button"); prevButton.setOnAction(e -> navigateToPreviousPage()); @@ -178,6 +196,8 @@ public final class PdfPreviewPane { /** * Lädt die angegebene Quelldatei asynchron und zeigt Seite 1 an. * Startet eine neue Vorschau-Anforderung und verwirft etwaige laufende Anforderungen. + * Der Seiten-Cache wird geleert und ein etwaiges bereits geöffnetes PDF-Dokument + * wird geschlossen. *

* Muss auf dem JavaFX Application Thread aufgerufen werden. * @@ -191,11 +211,13 @@ public final class PdfPreviewPane { currentSourceFile = sourceFile; currentPage = 0; totalPages = -1; + pageCache.clear(); requestLoad(sourceFile); } /** * Leert die Komponente und zeigt den neutralen Platzhaltertext. + * Das aktuell geöffnete PDF-Dokument wird asynchron auf dem Worker-Thread geschlossen. *

* Muss auf dem JavaFX Application Thread aufgerufen werden. */ @@ -203,9 +225,12 @@ public final class PdfPreviewPane { currentSourceFile = null; currentPage = 0; totalPages = -1; + pageCache.clear(); // Neue Sequenznummer: laufende Requests werden verworfen currentRequestSequence.incrementAndGet(); - pdfView.unload(); + // Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird + executor.submit(this::closeCurrentDocumentOnWorker); + imageView.setImage(null); showPlaceholder(); updateNavigationButtons(); } @@ -223,11 +248,16 @@ public final class PdfPreviewPane { } /** - * Beendet den internen Executor sauber. Muss beim Schließen der Anwendung - * aufgerufen werden. + * Beendet den internen Executor sauber und schließt das eventuell noch offene + * PDF-Dokument. Muss beim Schließen der Anwendung aufgerufen werden. */ public void shutdown() { - executor.shutdownNow(); + try { + executor.submit(this::closeCurrentDocumentOnWorker); + } catch (RuntimeException ignored) { + // Executor wurde bereits beendet – keine Aktion erforderlich + } + executor.shutdown(); } // --- Test-Accessoren ------------------------------------------------------ @@ -263,50 +293,40 @@ public final class PdfPreviewPane { if (!enabled || currentPage <= 1) { return; } - int targetPage = currentPage - 1; - pdfView.setPage(targetPage - 1); - currentPage = targetPage; - updatePageLabel(); - updateNavigationButtons(); - // Zoom nach dem Rendering der neuen Seite neu berechnen - Platform.runLater(this::updateZoom); + goToPage(currentPage - 1); } private void navigateToNextPage() { if (!enabled || totalPages <= 0 || currentPage >= totalPages) { return; } - int targetPage = currentPage + 1; - pdfView.setPage(targetPage - 1); + goToPage(currentPage + 1); + } + + /** + * Wechselt zur angegebenen Seite. Bereits gerenderte Seiten werden direkt aus dem + * Cache angezeigt; ansonsten wird ein Rendering-Auftrag auf den Worker-Thread gelegt. + * + * @param targetPage Ziel-Seite (1-basiert, muss im gültigen Bereich liegen) + */ + private void goToPage(int targetPage) { currentPage = targetPage; updatePageLabel(); updateNavigationButtons(); - // Zoom nach dem Rendering der neuen Seite neu berechnen - Platform.runLater(this::updateZoom); - } - // --- Zoom-Berechnung (fit-to-view) ---------------------------------------- - - /** - * Berechnet den optimalen Zoomfaktor anhand der aktuellen Größe von {@code pdfView} - * und setzt ihn. Als Seitenmaß wird A4 im Hochformat (595 × 842 Punkte) als Fallback - * verwendet. Hat keinen Effekt, solange {@code pdfView} noch keine Größe hat. - */ - private void updateZoom() { - double panelWidth = pdfView.getWidth(); - double panelHeight = pdfView.getHeight(); - if (panelWidth <= 0 || panelHeight <= 0) { + Image cached = pageCache.get(targetPage); + if (cached != null) { + imageView.setImage(cached); + showContent(); return; } - double pageWidth = 595; // A4 Breite in Punkten (Fallback) - double pageHeight = 842; // A4 Höhe in Punkten (Fallback) - double zoomByWidth = panelWidth / pageWidth; - double zoomByHeight = panelHeight / pageHeight; - double zoom = Math.min(zoomByWidth, zoomByHeight) * 0.95; - pdfView.setZoomFactor(zoom); + + long seq = currentRequestSequence.incrementAndGet(); + showLoading(); + executor.submit(() -> renderPageOnWorker(targetPage, seq)); } - // --- Asynchrones Laden --------------------------------------------------- + // --- Asynchrones Laden und Rendering -------------------------------------- /** * Startet eine asynchrone Lade-Anforderung für die angegebene Datei. @@ -318,58 +338,128 @@ public final class PdfPreviewPane { long seq = currentRequestSequence.incrementAndGet(); LOG.debug("PDF-Vorschau: Lade {} (Anforderung #{})", file, seq); - // Ladeindikator zeigen (auf FX-Thread, da requestLoad immer auf FX-Thread) showLoading(); updateNavigationButtons(); - executor.submit(() -> loadFileOnWorker(file, seq)); + executor.submit(() -> loadAndRenderFirstPageOnWorker(file, seq)); } /** - * Überprüft die Datei auf dem Worker-Thread und übergibt das Ergebnis an den FX-Thread. + * Öffnet die PDF-Datei, ermittelt die Seitenzahl und rendert die erste Seite. + * Läuft ausschließlich auf dem Worker-Thread. * * @param file die zu ladende Datei * @param seq die Sequenznummer dieser Anforderung */ - private void loadFileOnWorker(Path file, long seq) { + private void loadAndRenderFirstPageOnWorker(Path file, long seq) { File ioFile = file.toFile(); if (!ioFile.exists()) { LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen – Datei nicht gefunden: {}", file); - Platform.runLater(() -> { - if (currentRequestSequence.get() == seq) { - showError(FILE_NOT_FOUND_TEXT); - updateNavigationButtons(); - } - }); + publishError(seq, FILE_NOT_FOUND_TEXT); return; } - // Laden auf FX-Thread: PDFView.load() muss auf dem FX-Thread aufgerufen werden, - // da es JavaFX-Properties aktualisiert. - Platform.runLater(() -> { - if (currentRequestSequence.get() != seq) { - return; // Veraltet – verwerfen - } - try { - pdfView.load(ioFile); - // Seitenzahl nach dem Laden ermitteln - PDFView.Document doc = pdfView.getDocument(); - int pages = (doc != null) ? doc.getNumberOfPages() : 1; - totalPages = Math.max(1, pages); + // Vorheriges Dokument schließen bevor ein neues geöffnet wird + closeCurrentDocumentOnWorker(); + + try { + PDDocument doc = Loader.loadPDF(ioFile); + currentDocument = doc; + currentRenderer = new PDFRenderer(doc); + + int pages = Math.max(1, doc.getNumberOfPages()); + BufferedImage buffered = + currentRenderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB); + Image fxImage = SwingFXUtils.toFXImage(buffered, null); + + final int totalPagesFinal = pages; + Platform.runLater(() -> { + if (currentRequestSequence.get() != seq) { + return; // Veraltet – verwerfen + } + totalPages = totalPagesFinal; currentPage = 1; + pageCache.put(1, fxImage); + imageView.setImage(fxImage); showContent(); updateNavigationButtons(); updatePageLabel(); - // Zoom nach dem ersten Rendering berechnen - Platform.runLater(this::updateZoom); - LOG.debug("PDF-Vorschau: Rendering angestoßen – {} Seite(n)", totalPages); + LOG.debug("PDF-Vorschau: Rendering abgeschlossen – {} Seite(n)", totalPagesFinal); + }); + } catch (InvalidPasswordException ipe) { + LOG.warn("PDF-Vorschau: PDF ist passwortgeschützt: {}", file, ipe); + closeCurrentDocumentOnWorker(); + publishError(seq, PDF_PASSWORD_PROTECTED_TEXT); + } catch (Exception e) { + LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen: {}", file, e); + closeCurrentDocumentOnWorker(); + publishError(seq, PDF_UNREADABLE_TEXT); + } + } + + /** + * Rendert eine einzelne Seite des aktuell geöffneten Dokuments. + * Läuft ausschließlich auf dem Worker-Thread. + * + * @param page 1-basierte Seitennummer + * @param seq die Sequenznummer dieser Anforderung + */ + private void renderPageOnWorker(int page, long seq) { + PDFRenderer renderer = currentRenderer; + if (renderer == null) { + // Dokument wurde zwischenzeitlich geschlossen – nichts zu tun + return; + } + try { + BufferedImage buffered = renderer.renderImageWithDPI(page - 1, RENDER_DPI, ImageType.RGB); + Image fxImage = SwingFXUtils.toFXImage(buffered, null); + Platform.runLater(() -> { + if (currentRequestSequence.get() != seq) { + return; // Veraltet – verwerfen + } + pageCache.put(page, fxImage); + if (currentPage == page) { + imageView.setImage(fxImage); + showContent(); + } + }); + } catch (Exception e) { + LOG.warn("PDF-Vorschau: Rendering von Seite {} fehlgeschlagen", page, e); + publishError(seq, PDF_UNREADABLE_TEXT); + } + } + + /** + * Schließt das aktuell geöffnete PDF-Dokument, falls vorhanden. Läuft ausschließlich + * auf dem Worker-Thread und ist idempotent. + */ + private void closeCurrentDocumentOnWorker() { + PDDocument doc = currentDocument; + currentDocument = null; + currentRenderer = null; + if (doc != null) { + try { + doc.close(); } catch (Exception e) { - String msg = classifyLoadException(e); - LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen – {}", msg, e); - showError(msg); - updateNavigationButtons(); + LOG.debug("PDF-Vorschau: Schließen des Dokuments schlug fehl", e); } + } + } + + /** + * Übergibt eine Fehlermeldung auf den FX-Thread. Veraltete Meldungen werden verworfen. + * + * @param seq Sequenznummer der Anforderung, zu der die Meldung gehört + * @param message anzuzeigende Fehlermeldung + */ + private void publishError(long seq, String message) { + Platform.runLater(() -> { + if (currentRequestSequence.get() != seq) { + return; + } + showError(message); + updateNavigationButtons(); }); } @@ -379,8 +469,8 @@ public final class PdfPreviewPane { overlayLabel.setText(PLACEHOLDER_TEXT); overlayLabel.setVisible(true); overlayLabel.setManaged(true); - pdfView.setVisible(false); - pdfView.setManaged(false); + imageView.setVisible(false); + imageView.setManaged(false); progressIndicator.setVisible(false); progressIndicator.setManaged(false); pageLabel.setText(""); @@ -391,8 +481,8 @@ public final class PdfPreviewPane { progressIndicator.setManaged(true); overlayLabel.setVisible(false); overlayLabel.setManaged(false); - pdfView.setVisible(false); - pdfView.setManaged(false); + imageView.setVisible(false); + imageView.setManaged(false); } private void showContent() { @@ -400,16 +490,16 @@ public final class PdfPreviewPane { progressIndicator.setManaged(false); overlayLabel.setVisible(false); overlayLabel.setManaged(false); - pdfView.setVisible(true); - pdfView.setManaged(true); + imageView.setVisible(true); + imageView.setManaged(true); } private void showError(String message) { overlayLabel.setText(message); overlayLabel.setVisible(true); overlayLabel.setManaged(true); - pdfView.setVisible(false); - pdfView.setManaged(false); + imageView.setVisible(false); + imageView.setManaged(false); progressIndicator.setVisible(false); progressIndicator.setManaged(false); pageLabel.setText(""); @@ -428,12 +518,4 @@ public final class PdfPreviewPane { pageLabel.setText(""); } } - - private static String classifyLoadException(Exception e) { - String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(java.util.Locale.ROOT); - if (msg.contains("password") || msg.contains("encrypted") || msg.contains("encrypt")) { - return PDF_PASSWORD_PROTECTED_TEXT; - } - return PDF_UNREADABLE_TEXT; - } }