Fix #29: Eigenes PDF-Rendering mit PDFBox statt PDFViewFX

Ersetzt die PDFView-basierte Vorschau durch direktes Rendering einzelner Seiten
mit PDFBox (Loader.loadPDF + PDFRenderer.renderImageWithDPI bei 120 DPI).
BufferedImage wird über SwingFXUtils.toFXImage in eine JavaFX-Image konvertiert
und in einer ImageView angezeigt. fit-to-view entsteht nativ durch Binding von
fitWidth/fitHeight an den StackPane-Bereich bei preserveRatio=true. Keine
Scrollbalken, keine Zoom-Einschraenkungen, Seitenanfang immer sichtbar.

Lazy Rendering mit In-Memory-Cache fuer bereits gerenderte Seiten; asynchrones
Oeffnen und Rendering auf pdf-preview-worker-Thread; "latest preview request
wins"-Prinzip bleibt erhalten. pdfviewfx-Abhaengigkeit aus adapter-in-gui pom
entfernt, pdfbox stattdessen explizit aufgenommen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 16:05:02 +02:00
parent 673023d921
commit 591c7ff94c
2 changed files with 209 additions and 128 deletions
@@ -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.
*
* <p>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.
* <p>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.
*
* <p>Es gilt das Prinzip „Latest Preview Request Wins": Veraltete Lade-Ergebnisse
* werden verworfen, sobald eine neue Anforderung eingeht.
* <p>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<Integer, Image>}) gehalten, sodass wiederholte Navigation kein
* erneutes Rendering erfordert. Der Cache wird beim Wechsel der Quelldatei geleert.
*
* <p>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.
*
* <h2>Fehlerfälle</h2>
* <ul>
* <li>Quelldatei nicht vorhanden → Meldungstext im Vorschaubereich</li>
* <li>PDF nicht lesbar → Meldungstext im Vorschaubereich</li>
* <li>PDF passwortgeschützt → Meldungstext im Vorschaubereich</li>
* <li>Keine Selektion → neutraler Platzhaltertext</li>
* </ul>
*
* <h2>Threading</h2>
* <p>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<Integer, Image> 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.
* <p>
* 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.
* <p>
* 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;
}
}