2019-05-20  Java プログラミング

JavaFXでPDFを表示する

Java には Apache PDFBox という便利なライブラリーがあります。Apache PDFBox を使うと、 PDF の生成だけでなく、 PDF を JPEG や PNG などの画像に変換や PDF を自作アプリケーションに表示することも簡単に実現できます。

今回は、 JavaFX アプリケーションで PDF を表示する方法を紹介します。

Apache PDFBoxを用意する

はじめに、 Apache PDFBox ライブラリーを用意します。必要な JAR ファイルは pdfbox-*.jar と fontbox-*.jar の 2 つです。今回はバージョン 2.0.15 を使用したので pdfbox-2.0.15.jar と fontbox-2.0.15.jar でした。

Apache PDFBox では多くのライブラリーやツールが提供されています。https://pdfbox.apache.org/download.cgi からダウンロードする場合は、 画面を下に少しスクロールさせた Libraries of each subproject の見出しのところから pdfbox-2.0.15.jar と fontbox-2.0.15.jar の 2 つをダウンロードして、 開発プロジェクトに追加してください。

Apache PDFBox は、 Maven Repository や jcenter にも登録されています。私は、 開発に Gradle プロジェクト形式を使用しているので build.gradle に pdfbox と fontbox のアーティファクトを記述しました。

build.gradle
apply plugin: 'eclipse' apply plugin: 'java' repositories { jcenter() } dependencies { compile 'org.apache.pdfbox:pdfbox:2.0.15' compile 'org.apache.pdfbox:fontbox:2.0.15' }

Gradle を使用していない場合は build.gradle は無視して構いません。Apache PDFBox のサイトから直接ダウンロードした pdfbox-2.0.15.jar と fontbox-2.0.15.jar を参照ライブラリとしてプロジェクトに追加すれば大丈夫です。

PDFを画面に表示する方法

Apache PDFBox ライブラリーには PDFRenerer というクラスが含まれています。この PDFRenderer クラスには renderImage メソッドと renderPageToGraphics メソッドがあります。

  • renderImage メソッドは PDF を描画した結果の java.awt.image.BufferedImage を返します。
  • renderPageToGraphics メソッドは引数に指定した java.awt.Graphics2D に PDF を描画します。

JavaFX の javafx.scene.canvas.Canvas や javafx.scene.canvas.GraphicsContext に直接描画するメソッドは残念ながら用意されていません。BufferedImage または Graphics2D を使用して JavaFX の Canvas に描画する方法を考える必要があります。

FXGraphics2Dを試したけど…

Graphics2D インターフェースを通して JavaFX の Canvas に描画できる FXGraphics2D というライブラリーがあります。FXGraphics2D は Graphics2D インターフェースを通して JavaFX の Canvas を描画することができる素晴らしいライブラリーです。

ですが、 今回は FXGraphics2D の使用を見送りました。いろいろと試してみたのですが、 FXGraphics2D では十分な性能が出せなかったためです。

FXGraphics2D を使うと BufferedImage を用意することなく直接 Canvas に描画できるので、 高速に描画できることを期待したのですが、 別のところにボトルネックがありました。「Canvas は JavaFX アプリケーションスレッド (UI スレッド) で描画しなければならない」 という制約です。

FXGraphics2D の描画オーバーヘッドが小さくても、 UI スレッドで描画してしまうと、 ページ切替用のスライダー操作やウィンドウのリサイズ操作など他の UI 操作がもたついてしまいます。

UI 操作レスポンスを維持するためには、 やはり、 ワーカースレッドでオフスクリーンに描画しておいて UI スレッドでは描画済みのオフスクリーン ・ バッファを画面に転写する、 という手法を使いたいところです。

さらに調べてみると Canvas をシーングラフに追加していない場合は、 (UI スレッドではなく) ワーカースレッドで Canvas に描画することができることが分かりました。シーングラフに追加していないというのは BorderPane などの親ノードにまだ Canvas を追加していない状態のことです。

この方法も試してみました。ワーカースレッドで Canvas を生成して[FXGraphics][]を使って描画して、 描画が終わったら UI スレッドで親ノードに Canvas を add します。残念ながら、 このアプローチも上手くはいきませんでした。Canvas インスタンスを繰り返し生成するという効率の悪さ、 親ノードに Canvas を追加するたびに layoutChildren が呼ばれて再レイアウトがおこなわれるといった具合で動作がもっさりしています。

Canvas を繰り返し生成しない方法にも挑戦しました。画面に表示する Canvas とワーカースレッドで描画する Canvas の 2 つをあらかじめ用意しておき、 ワーカースレッドで描画した Canvas の内容を画面表示用の Canvas に転写する方法です。Canvas クラスには描画されている内容を javafx.scene.image.WritableImage として取得する snapshot メソッドがあります。Canvas の描画コンテキスト (GraphicsContext) には WritableImage を描画するための drawImage メソッドもあります。

ワーカースレッドの Canvas で snapshot をとって、 UI スレッドで画面表示用の Canvas に drawImage で転写する、 これができれば上手くいくはず! しかし、 この目論見も外れてしまいました。snapshot メソッドが期待通りに動いてくれません。snapshot メソッドは Canvas がシーングラフに追加されている状態で UI スレッドから呼び出さないと機能しないのです。これでは役に立ちません。

ここで FXGraphics2D を使うのを諦めました。

素直にBufferedImageを使おう

[FXGrapchis2D][]で WritableImage を drawImage で描画を試行するあたりで薄々と感づいてはいたんです。

「WritableImage を用意するなら、 それはもう Canvas 直接描画じゃないよね?」

というわけで、 ワーカースレッドでオフスクリーン描画するには、 やはり WritableImage や BufferedImage が必要になると考えなおしました。

PDFRenderer クラスには renderPageToGraphics メソッドだけでなく renderImage メソッドがあったことをふと思い出します。このメソッドは BufferedImage を引数として渡すことはできず、 戻り値として BufferedImage を返してくれます。

なんだか悪い予感がします。

ソースコードを読んでみると、 renderImage を呼び出すたびに、 毎回、 BufferedImage を内部で生成しているじゃないですか。これでは十分な性能が出ないので、 別の方法を考えることにします。

最終的に辿り着いた方法がこれです。

  1. BufferedImage を自分で用意する。
  2. BufferedImage#createGraphics() で取得した Graphics2D に対して PDFRenderer#renderPageToGraphics() で描画する。
  3. SwingFXUtils.toFXImage() で BufferedImage の内容を WritableImage にコピーする。
  4. UI スレッドで GraphicsContext#drawImage() を呼び出し、 WritableImage を Canvas に描画する。

完成したソースコードと解説

PdfView.java
package com.example; import java.awt.Color; import java.awt.Graphics2D; import java.awt.image.BufferedImage; import java.io.IOException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.rendering.PDFRenderer; import javafx.application.Platform; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyIntegerProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.embed.swing.SwingFXUtils; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.image.WritableImage; import javafx.scene.layout.Region; public class PdfView extends Region { private ObjectProperty<PDDocument> documentProperty = new SimpleObjectProperty<PDDocument>(this, "document"); public ObjectProperty<PDDocument> documentProperty() { return documentProperty; } public final PDDocument getDocument() { return documentProperty.get(); } public final void setDocument(PDDocument value) { documentProperty.set(value); } private IntegerProperty pageIndexProperty = new SimpleIntegerProperty(this, "pageIndex"); public IntegerProperty pageIndexProperty() { return pageIndexProperty; } public final int getPageIndex() { return pageIndexProperty.get(); } public final void setPageIndex(int value) { pageIndexProperty.set(value); } private IntegerProperty maxPageIndexProperty = new SimpleIntegerProperty(this, "maxPageIndex"); public ReadOnlyIntegerProperty maxPageIndexProperty() { return maxPageIndexProperty; } public final int getMaxPageIndex() { return maxPageIndexProperty.get(); } public final void setMaxPageIndex(int value) { maxPageIndexProperty.set(value); } private Canvas canvas; private boolean isDirty; private boolean isBusy; private ExecutorService worker; private double width; private double height; private PDDocument document; private int pageIndex; private BufferedImage bimg; private WritableImage wimg; public PdfView() { worker = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r); t.setDaemon(true); return t; }); canvas = new Canvas(); canvas.widthProperty().bind(widthProperty()); canvas.heightProperty().bind(heightProperty()); getChildren().add(canvas); documentProperty.addListener((observable, oldValue, newValue) -> { pageIndexProperty.set(0); if(newValue == null) { maxPageIndexProperty.set(0); } else { maxPageIndexProperty.set(newValue.getNumberOfPages() - 1); } update(); }); pageIndexProperty.addListener((observable, oldValue, newValue) -> { update(); }); widthProperty().addListener((observable, oldValue, newValue) -> { update(); }); heightProperty().addListener((observable, oldValue, newValue) -> { update(); }); } public void update() { isDirty = true; if(!isBusy) { isBusy = true; isDirty = false; width = getWidth(); height = getHeight(); document = documentProperty.get(); pageIndex = pageIndexProperty.get(); worker.submit(() -> { WritableImage img = prepare(); Platform.runLater(() -> { GraphicsContext gc = canvas.getGraphicsContext2D(); gc.clearRect(0, 0, canvas.getWidth(), canvas.getHeight()); if(img != null) { double x = (canvas.getWidth() - img.getWidth()) / 2; double y = (canvas.getHeight() - img.getHeight()) / 2; gc.drawImage(img, x, y); } isBusy = false; if(isDirty) { update(); } }); }); } } protected WritableImage prepare() { if(document == null) { return null; } PDRectangle paper = document.getPage(pageIndex).getMediaBox(); double w; double h; if(paper.getWidth() / paper.getHeight() < width / height) { w = height * paper.getWidth() / paper.getHeight(); h = height; } else { w = width; h = width * paper.getHeight() / paper.getWidth(); } float scale = (float)(h / paper.getHeight()); if(bimg == null || bimg.getWidth() != (int)w || bimg.getHeight() != (int)h) { bimg = new BufferedImage((int)w, (int)h, BufferedImage.TYPE_INT_RGB); wimg = new WritableImage((int)w, (int)h); } Graphics2D graphics = null; try { graphics = bimg.createGraphics(); graphics.setBackground(Color.WHITE); graphics.clearRect(0, 0, (int)w, (int)h); PDFRenderer renderer = new PDFRenderer(document); renderer.renderPageToGraphics(pageIndex, graphics, scale); return SwingFXUtils.toFXImage(bimg, wimg); } catch(IOException e) { throw new RuntimeException(e); } finally { if(graphics != null) { graphics.dispose(); } } } }

この PdfView クラスは PDF を表示できる JavaFX 部品 (ノード) です。

PdfView クラス自身は javafx.scene.layout.Region のサブクラスとして実装し、 Canvas を内包する形にしています。

プロパティ

PdfView には 3 つのプロパティを用意しました。

プロパティ      型      説明
documentPropertyPDDocumentPDF ドキュメントを表わすプロパティです。このプロパティの値を変更すると表示される PDF が変わります。
pageIndexPropertyintPDF ドキュメントの現在のページ ・ インデックスを表すプロパティです。ページ ・ インデックスは 0 から始まるため、 ページ番号よりも 1 小さい値になります。このプロパティの値を変更すると表示される PDF のページが変わります。
maxPageIndexPropertyintPDF ドキュメントの最終ページ ・ インデックスを表す読み取り専用プロパティです。この値は、 PDF のページ数よりも 1 小さい値になります。

prepareメソッド

PDF を描画した WritableImage を返すメソッドです。このメソッドはワーカースレッドで呼びされます。

PDF の縦横比を維持したままノードの中央に表示するために、 適切な幅 (w)、 高さ (h)、 拡大率 (scale) を計算しています。

基本的に BufferedImage、 WritableImage は再利用しますが、 ウィンドウのリサイズなどで Canvas のサイズが変わった場合には BufferedImage と WritableImage を再作成しています。

実際に PDF を描画する処理はわずか 2 行です。こんな簡単に描画できるのは Apache PDFBox のおかげです。

PDFRenderer renderer = new PDFRenderer(document);
renderer.renderPageToGraphics(pageIndex, graphics, scale);

updateメソッド

PDF の描画を要求するメソッドです。このメソッドは JavaFX アプリケーションスレッド (UI スレッド) から呼び出します。PDF ドキュメントを変更したとき、 現在のページ番号が変わったとき、 ノードのサイズが変わったときに呼び出されます。

isDirty は再描画が必要であることを示すフラグです。

isBusy はオフスクリーン描画実行中であることを示すフラグです。ワーカースレッドでオフスクリーン描画を実行しているときに、 update メソッドを呼び出すと isDirty フラグを設定するだけで他には何もしません。

これによって、 ワーカースレッドの未処理タスクが積み上がることがなくなります。

たとえば、 次ページボタンを押し続けた場合、 「1 ページ目を描画すべし」、 「2 ページ目を描画すべし」、 「3 ページ目を描画すべし」、 「4 ページ目を描画すべし」 と連続で描画要求が発生します。

ですが、 これらの描画要求を順番にすべてワーカースレッドで実行するのは無駄です。ワーカースレッドで 1 ページ目のオフスクリーン描画をしている最中も、 「2 ページ目を描画すべし」、 「3 ページ目を描画すべし」 と現在ページが次々と変わっていく可能性があるからです。現在ページが 3 になった後で 2 ページ目をオフスクリーン描画するタスクを実行しても意味がないですよね。

そこで、 ワーカースレッドで実行するタスクを積み上げるのではなく、 もう一度、 再描画が必要であることを示す isDirty フラグを立てる方法を採っています。

ワーカースレッドでのオフスクリーン描画が完了すると、 描画された WritableImage が Platform.runLater() で JavaFX アプリケーションスレッド (UI スレッド) に渡されます。UI スレッドで Canvas への転写が完了した後、 isDirty フラグをチェックします。

if(isDirty) {
	update();
}

isDirty フラグが true の場合、 それはワーカースレッドでのオフスクリーン描画中にページ番号やノードサイズの変更があり、 再描画が必要になったことを意味しているので、 もう一度 update メソッドを呼び出して再描画を促します。

スレッドセーフ

以下の代入コードの役割についても説明しておきましょう。

width = getWidth();
height = getHeight();
document = documentProperty.get();
pageIndex = pageIndexProperty.get();

フィールド変数 width、 height、 document、 pageIndex に値を代入しているのは、 ワーカースレッドで実行される prepare メソッドの中から安全にこれらの値を参照するためです。

ワーカースレッドで実行される prepare メソッドの中で getWidth メソッドを呼び出すと、 UI スレッドで変化したノードの幅が返されてしまうことがあります。

このような問題が発生しないように、 オフスクリーン描画に必要なパラメーター width、 height、 document、 pageIndex をあらかじめ、 UI スレッドでフィールド変数に保持するようにしています。(isBusy フラグで再入を防止しているので、 ワーカースレッドの処理が完了して isBusy が false に戻されるまで、 これらのフィールド変数が変更されることはありません。)

width、 height、 document、 pageIndex は UI スレッドとワーカースレッドで共有されるフィールドですが、 これらのフィールドに volatile 修飾子を付与する必要はありません。Java メモリー ・ モデル (JMM) では、 呼び出し側スレッドがスレッド開始までに書き込んだ値を開始されたスレッドが読み取れること (可視性) を保証しています。

ExecutorService インターフェースを通してタスクを実行する場合も同様です。Executors クラスのユーティリティ ・ メソッドが返す ExecutorService インターフェースのいくつかは、 スレッドを再利用する ThreadPoolExecutor で実装されています。この場合、 タスクを実行するたびに新しいスレッドが開始されるわけではないので、 スレッド開始時のメモリー整合性という JMM の規定はここでは意味を持たないように思えます。

安心してください。JMM ではなく、 java.util.concurrent パッケージがメモリー整合性特性を持つように実装されています。そのため、 ExecutorService インターフェースを通してタスクを実行する場合でも、 (新しいスレッドを開始するのと同様に) 呼び出し元が書き込んだ値を開始されたタスクで読み取れることが保証されています。

このことは、 java.util.concurrent パッケージの javadoc にも記載されています。

「Java™言語仕様の第 17 章」 は、 共有変数の読み書きなどのメモリー操作に関する happens-before 関係を定義します。あるスレッドによる書込みの結果が別のスレッドによる読取りで認識されることが保証されるのは、 その書込み操作が読取り操作の前に発生 (happens-before) した場合だけです。synchronized 構文と volatile 構文のほか、 Thread.start()メソッドと Thread.join()メソッドが happens-before 関係を形成できます。

java.util.concurrent とそのサブパッケージ内のすべてのクラスのメソッドは、 これらの保証をより高いレベルの同期にまで拡張します。

  • Runnable を Executor に送信する前のスレッド内のアクションは、 その実行が開始される前に発生します。ExecutorService に送信される Callables についても同様です。

PdfViewをアプリケーションに組み込む

PdfView クラスを使った簡単なサンプルプログラムを完成させましょう。

JavaFX ウィンドウに配置するのは PdfView とページを切り替えるためのスライダー (Slider) の 2 つだけです。PDF ファイルを指定する UI まで用意するとコードが大きくなり見通しが悪くなってしまいますので、 表示する PDF ファイルはソースコードにハードコーディングしちゃいました。

Main.fxml
<?xml version="1.0" encoding="UTF-8"?> <?import javafx.scene.*?> <?import javafx.scene.control.*?> <?import javafx.scene.layout.*?> <?import com.example.PdfView?> <StackPane xmlns="http://javafx.com/javafx/9" xmlns:fx="http://javafx.com/fxml/1"> <BorderPane> <top> <Slider fx:id="slider" prefHeight="20" snapToTicks="true" majorTickUnit="1.0" blockIncrement="1.0"> </Slider> </top> <center> <PdfView fx:id="pdfView" prefWidth="300" prefHeight="400"> </PdfView> </center> </BorderPane> </StackPane>

FXML の内容は難しくないと思います。上部に Slider、 中央に PdfView を配置して、 それぞれ初期サイズを指定しています。

Main.java
package com.example; import java.io.File; import org.apache.pdfbox.pdmodel.PDDocument; import javafx.application.Application; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.Slider; import javafx.stage.Stage; public class Main extends Application { @FXML private Slider slider; @FXML private PdfView pdfView; public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) throws Exception { FXMLLoader loader = new FXMLLoader(getClass().getResource("Main.fxml")); loader.setController(this); Parent root = (Parent)loader.load(); Scene scene = new Scene(root); primaryStage.setScene(scene); primaryStage.show(); slider.maxProperty().bind(pdfView.maxPageIndexProperty()); slider.valueProperty().bindBidirectional(pdfView.pageIndexProperty()); PDDocument document = PDDocument.load(new File("cathedral.pdf")); pdfView.setDocument(document); } }

JavaFX アプリケーションの基礎が分かっていれば、 Main.java を理解するのも難しくないはずです。

今回は、 イベントリスナーではなくバインディングを積極的に使ってみました。以下の 2 行がプロパティーをバインドしている箇所です。

slider.maxProperty().bind(pdfView.maxPageIndexProperty());
slider.valueProperty().bindBidirectional(pdfView.pageIndexProperty());

スライダーの maxProperty を PdfView の maxPageIndexProperty にバインドしています。これによって PDF を開くと PDF のページ番号-1 がスライダーの最大値として設定されます。すなわち、 スライダーを一番右側が PDF の最終ページに対応します。

スライダーの valueProperty と PdfView の pageIndexProperty を双方向バインドしています。スライダーを動かせば自動的に PdfView の pageIndexProperty が変化して PDF が再描画されます。逆に、 PdfView の pageIndexProperty が変化した場合も自動的にスライダー位置が変わります。(今回のサンプルコードでは PdfView 側から pageIndexProperty が変わることはほとんどありません。PDF ドキュメントを設定したときに pageIndexPropery が 0 に設定され、 スライダーが左端に戻るくらいです。)

PDDocument document = PDDocument.load(new File("cathedral.pdf"));
pdfView.setDocument(document);

Apache PDFBox のおかげでとても短いコードで PDF ファイルを開けます。PDDocument.load() で PDDocument のインスタンスを手に入れたら、 あとはそれを PdfView#setDocument() に渡すだけです。

サンプル・アプリケーション

完成したサンプル ・ アプリケーションのソースコードは下記のリンクからダウンロードできます。

サンプル ・ アプリケーションを実行すると PDF が表示されて上部のスライダーで表示するページを切り替えられます。

※ 表示用 PDF として山形浩生さんが翻訳された 「伽藍とバザール」 (Eric S. Raymond 著) を使わせていただきました。

最終更新日 2024-12-13
この記事を共有しませんか?
ブックマーク ポスト