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 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.
*
* 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
* 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;
- }
}
Fehlerfälle
*
*
*
* Threading
*