├── README.md
└── src
└── pdfviewer
├── PdfViewer.java
├── PdfViewer.fxml
└── Controller.java
/README.md:
--------------------------------------------------------------------------------
1 | PdfViewer
2 | =========
3 |
4 | Simple PDF Viewer in JavaFX using the PDFRenderer library
5 |
6 | A fairly basic JavaFX PDF Viewer. Includes zoom functionality.
7 | Requires JavaFX and the PDFRenderer library from SwingLabs (https://java.net/projects/pdf-renderer)
8 |
--------------------------------------------------------------------------------
/src/pdfviewer/PdfViewer.java:
--------------------------------------------------------------------------------
1 | package pdfviewer;
2 |
3 | import java.io.IOException;
4 |
5 | import javafx.application.Application;
6 | import javafx.fxml.FXMLLoader;
7 | import javafx.scene.Parent;
8 | import javafx.scene.Scene;
9 | import javafx.stage.Stage;
10 |
11 | public class PdfViewer extends Application {
12 | @Override
13 | public void start(Stage primaryStage) throws IOException {
14 | final Parent parent = FXMLLoader.load(getClass().getResource("PdfViewer.fxml"));
15 | primaryStage.setScene(new Scene(parent,600, 800));
16 | primaryStage.show();
17 | }
18 | public static void main(String[] args) {
19 | launch(args);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/pdfviewer/PdfViewer.fxml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/pdfviewer/Controller.java:
--------------------------------------------------------------------------------
1 | package pdfviewer;
2 |
3 | import java.awt.geom.Rectangle2D;
4 | import java.awt.image.BufferedImage;
5 | import java.io.File;
6 | import java.io.IOException;
7 | import java.io.PrintWriter;
8 | import java.io.RandomAccessFile;
9 | import java.io.StringWriter;
10 | import java.nio.ByteBuffer;
11 | import java.nio.channels.FileChannel;
12 | import java.nio.file.Paths;
13 | import java.util.concurrent.ExecutorService;
14 | import java.util.concurrent.Executors;
15 | import java.util.concurrent.ThreadFactory;
16 |
17 | import com.sun.pdfview.PDFFile;
18 | import com.sun.pdfview.PDFPage;
19 |
20 | import javafx.beans.binding.Bindings;
21 | import javafx.beans.binding.IntegerBinding;
22 | import javafx.beans.property.DoubleProperty;
23 | import javafx.beans.property.ObjectProperty;
24 | import javafx.beans.property.SimpleDoubleProperty;
25 | import javafx.beans.property.SimpleObjectProperty;
26 | import javafx.beans.value.ChangeListener;
27 | import javafx.beans.value.ObservableValue;
28 | import javafx.concurrent.Task;
29 | import javafx.concurrent.WorkerStateEvent;
30 | import javafx.embed.swing.SwingFXUtils;
31 | import javafx.event.ActionEvent;
32 | import javafx.event.EventHandler;
33 | import javafx.fxml.FXML;
34 | import javafx.geometry.Insets;
35 | import javafx.geometry.Pos;
36 | import javafx.scene.Node;
37 | import javafx.scene.Scene;
38 | import javafx.scene.control.Button;
39 | import javafx.scene.control.Label;
40 | import javafx.scene.control.Pagination;
41 | import javafx.scene.control.ScrollPane;
42 | import javafx.scene.control.TitledPane;
43 | import javafx.scene.image.Image;
44 | import javafx.scene.image.ImageView;
45 | import javafx.scene.layout.HBox;
46 | import javafx.scene.layout.VBox;
47 | import javafx.stage.FileChooser;
48 | import javafx.stage.FileChooser.ExtensionFilter;
49 | import javafx.stage.Stage;
50 | import javafx.stage.StageStyle;
51 | import javafx.stage.Window;
52 | import javafx.util.Callback;
53 |
54 | public class Controller {
55 |
56 | @FXML private Pagination pagination ;
57 | @FXML private Label currentZoomLabel ;
58 |
59 | private FileChooser fileChooser ;
60 | private ObjectProperty currentFile ;
61 | private ObjectProperty currentImage ;
62 | @FXML private ScrollPane scroller ;
63 | private DoubleProperty zoom ;
64 | private PageDimensions currentPageDimensions ;
65 |
66 | private ExecutorService imageLoadService ;
67 |
68 | private static final double ZOOM_DELTA = 1.05 ;
69 |
70 |
71 | // ************ Initialization *************
72 |
73 | public void initialize() {
74 |
75 | createAndConfigureImageLoadService();
76 | createAndConfigureFileChooser();
77 |
78 | currentFile = new SimpleObjectProperty<>();
79 | updateWindowTitleWhenFileChanges();
80 |
81 | currentImage = new SimpleObjectProperty<>();
82 | scroller.contentProperty().bind(currentImage);
83 |
84 | zoom = new SimpleDoubleProperty(1);
85 | // To implement zooming, we just get a new image from the PDFFile each time.
86 | // This seems to perform well in some basic tests but may need to be improved
87 | // E.g. load a larger image and scale in the ImageView, loading a new image only
88 | // when required.
89 | zoom.addListener(new ChangeListener() {
90 | @Override
91 | public void changed(ObservableValue extends Number> observable, Number oldValue, Number newValue) {
92 | updateImage(pagination.getCurrentPageIndex());
93 | }
94 | });
95 |
96 | currentZoomLabel.textProperty().bind(Bindings.format("%.0f %%", zoom.multiply(100)));
97 |
98 | bindPaginationToCurrentFile();
99 | createPaginationPageFactory();
100 | }
101 |
102 | private void createAndConfigureImageLoadService() {
103 | imageLoadService = Executors.newSingleThreadExecutor(new ThreadFactory() {
104 | @Override
105 | public Thread newThread(Runnable r) {
106 | Thread thread = new Thread(r);
107 | thread.setDaemon(true);
108 | return thread;
109 | }
110 | });
111 | }
112 |
113 | private void createAndConfigureFileChooser() {
114 | fileChooser = new FileChooser();
115 | fileChooser.setInitialDirectory(Paths.get(System.getProperty("user.home")).toFile());
116 | fileChooser.getExtensionFilters().add(new ExtensionFilter("PDF Files", "*.pdf", "*.PDF"));
117 | }
118 |
119 | private void updateWindowTitleWhenFileChanges() {
120 | currentFile.addListener(new ChangeListener() {
121 | @Override
122 | public void changed(ObservableValue extends PDFFile> observable, PDFFile oldFile, PDFFile newFile) {
123 | try {
124 | String title = newFile == null ? "PDF Viewer" : newFile.getStringMetadata("Title") ;
125 | Window window = pagination.getScene().getWindow();
126 | if (window instanceof Stage) {
127 | ((Stage)window).setTitle(title);
128 | }
129 | } catch (IOException e) {
130 | showErrorMessage("Could not read title from pdf file", e);
131 | }
132 | }
133 |
134 | });
135 | }
136 |
137 | private void bindPaginationToCurrentFile() {
138 | currentFile.addListener(new ChangeListener() {
139 | @Override
140 | public void changed(ObservableValue extends PDFFile> observable, PDFFile oldFile, PDFFile newFile) {
141 | if (newFile != null) {
142 | pagination.setCurrentPageIndex(0);
143 | }
144 | }
145 | });
146 | pagination.pageCountProperty().bind(new IntegerBinding() {
147 | {
148 | super.bind(currentFile);
149 | }
150 | @Override
151 | protected int computeValue() {
152 | return currentFile.get()==null ? 0 : currentFile.get().getNumPages() ;
153 | }
154 | });
155 | pagination.disableProperty().bind(Bindings.isNull(currentFile));
156 | }
157 |
158 | private void createPaginationPageFactory() {
159 | pagination.setPageFactory(new Callback() {
160 | @Override
161 | public Node call(Integer pageNumber) {
162 | if (currentFile.get() == null) {
163 | return null ;
164 | } else {
165 | if (pageNumber >= currentFile.get().getNumPages() || pageNumber < 0) {
166 | return null ;
167 | } else {
168 | updateImage(pageNumber);
169 | return scroller ;
170 | }
171 | }
172 | }
173 | });
174 | }
175 |
176 | // ************** Event Handlers ****************
177 |
178 | @FXML private void loadFile() {
179 | final File file = fileChooser.showOpenDialog(pagination.getScene().getWindow());
180 | if (file != null) {
181 | final Task loadFileTask = new Task() {
182 | @Override
183 | protected PDFFile call() throws Exception {
184 | try (
185 | RandomAccessFile raf = new RandomAccessFile(file, "r");
186 | FileChannel channel = raf.getChannel()
187 | ) {
188 | ByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
189 | return new PDFFile(buffer);
190 | }
191 | }
192 | };
193 | loadFileTask.setOnSucceeded(new EventHandler() {
194 | @Override
195 | public void handle(WorkerStateEvent event) {
196 | pagination.getScene().getRoot().setDisable(false);
197 | final PDFFile pdfFile = loadFileTask.getValue();
198 | currentFile.set(pdfFile);
199 | }
200 | });
201 | loadFileTask.setOnFailed(new EventHandler() {
202 | @Override
203 | public void handle(WorkerStateEvent event) {
204 | pagination.getScene().getRoot().setDisable(false);
205 | showErrorMessage("Could not load file "+file.getName(), loadFileTask.getException());
206 | }
207 | });
208 | pagination.getScene().getRoot().setDisable(true);
209 | imageLoadService.submit(loadFileTask);
210 | }
211 | }
212 |
213 | @FXML private void zoomIn() {
214 | zoom.set(zoom.get()*ZOOM_DELTA);
215 | }
216 |
217 | @FXML private void zoomOut() {
218 | zoom.set(zoom.get()/ZOOM_DELTA);
219 | }
220 |
221 | @FXML private void zoomFit() {
222 | // TODO: the -20 is a kludge to account for the width of the scrollbars, if showing.
223 | double horizZoom = (scroller.getWidth()-20) / currentPageDimensions.width ;
224 | double verticalZoom = (scroller.getHeight()-20) / currentPageDimensions.height ;
225 | zoom.set(Math.min(horizZoom, verticalZoom));
226 | }
227 |
228 | @FXML private void zoomWidth() {
229 | zoom.set((scroller.getWidth()-20) / currentPageDimensions.width) ;
230 | }
231 |
232 | // *************** Background image loading ****************
233 |
234 | private void updateImage(final int pageNumber) {
235 | final Task updateImageTask = new Task() {
236 | @Override
237 | protected ImageView call() throws Exception {
238 | PDFPage page = currentFile.get().getPage(pageNumber+1);
239 | Rectangle2D bbox = page.getBBox();
240 | final double actualPageWidth = bbox.getWidth();
241 | final double actualPageHeight = bbox.getHeight();
242 | // record page dimensions for zoomToFit and zoomToWidth:
243 | currentPageDimensions = new PageDimensions(actualPageWidth, actualPageHeight);
244 | // width and height of image:
245 | final int width = (int) (actualPageWidth * zoom.get());
246 | final int height = (int) (actualPageHeight * zoom.get());
247 | // retrieve image for page:
248 | // width, height, clip, imageObserver, paintBackground, waitUntilLoaded:
249 | java.awt.Image awtImage = page.getImage(width, height, bbox, null, true, true);
250 | // draw image to buffered image:
251 | BufferedImage buffImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
252 | buffImage.createGraphics().drawImage(awtImage, 0, 0, null);
253 | // convert to JavaFX image:
254 | Image image = SwingFXUtils.toFXImage(buffImage, null);
255 | // wrap in image view and return:
256 | ImageView imageView = new ImageView(image);
257 | imageView.setPreserveRatio(true);
258 | return imageView ;
259 | }
260 | };
261 |
262 | updateImageTask.setOnSucceeded(new EventHandler() {
263 | @Override
264 | public void handle(WorkerStateEvent event) {
265 | pagination.getScene().getRoot().setDisable(false);
266 | currentImage.set(updateImageTask.getValue());
267 | }
268 | });
269 |
270 | updateImageTask.setOnFailed(new EventHandler() {
271 | @Override
272 | public void handle(WorkerStateEvent event) {
273 | pagination.getScene().getRoot().setDisable(false);
274 | updateImageTask.getException().printStackTrace();
275 | }
276 |
277 | });
278 |
279 | pagination.getScene().getRoot().setDisable(true);
280 | imageLoadService.submit(updateImageTask);
281 | }
282 |
283 | private void showErrorMessage(String message, Throwable exception) {
284 |
285 | // TODO: move to fxml (or better, use ControlsFX)
286 |
287 | final Stage dialog = new Stage();
288 | dialog.initOwner(pagination.getScene().getWindow());
289 | dialog.initStyle(StageStyle.UNDECORATED);
290 | final VBox root = new VBox(10);
291 | root.setPadding(new Insets(10));
292 | StringWriter errorMessage = new StringWriter();
293 | exception.printStackTrace(new PrintWriter(errorMessage));
294 | final Label detailsLabel = new Label(errorMessage.toString());
295 | TitledPane details = new TitledPane();
296 | details.setText("Details:");
297 | Label briefMessageLabel = new Label(message);
298 | final HBox detailsLabelHolder =new HBox();
299 |
300 | Button closeButton = new Button("OK");
301 | closeButton.setOnAction(new EventHandler() {
302 | @Override
303 | public void handle(ActionEvent event) {
304 | dialog.hide();
305 | }
306 | });
307 | HBox closeButtonHolder = new HBox();
308 | closeButtonHolder.getChildren().add(closeButton);
309 | closeButtonHolder.setAlignment(Pos.CENTER);
310 | closeButtonHolder.setPadding(new Insets(5));
311 | root.getChildren().addAll(briefMessageLabel, details, detailsLabelHolder, closeButtonHolder);
312 | details.setExpanded(false);
313 | details.setAnimated(false);
314 |
315 | details.expandedProperty().addListener(new ChangeListener() {
316 |
317 | @Override
318 | public void changed(ObservableValue extends Boolean> observable,
319 | Boolean oldValue, Boolean newValue) {
320 | if (newValue) {
321 | detailsLabelHolder.getChildren().add(detailsLabel);
322 | } else {
323 | detailsLabelHolder.getChildren().remove(detailsLabel);
324 | }
325 | dialog.sizeToScene();
326 | }
327 |
328 | });
329 | final Scene scene = new Scene(root);
330 |
331 | dialog.setScene(scene);
332 | dialog.show();
333 | }
334 |
335 |
336 | /*
337 | * Struct-like class intended to represent the physical dimensions of a page in pixels
338 | * (as opposed to the dimensions of the (possibly zoomed) view.
339 | * Used to compute zoom factors for zoomToFit and zoomToWidth.
340 | *
341 | */
342 |
343 | private class PageDimensions {
344 | private double width ;
345 | private double height ;
346 | PageDimensions(double width, double height) {
347 | this.width = width ;
348 | this.height = height ;
349 | }
350 | @Override
351 | public String toString() {
352 | return String.format("[%.1f, %.1f]", width, height);
353 | }
354 | }
355 |
356 | }
357 |
--------------------------------------------------------------------------------