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.gradleapply 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
を内部で生成しているじゃないですか。これでは十分な性能が出ないので、 別の方法を考えることにします。
最終的に辿り着いた方法がこれです。
BufferedImage
を自分で用意する。BufferedImage#createGraphics()
で取得したGraphics2D
に対してPDFRenderer#renderPageToGraphics()
で描画する。SwingFXUtils.toFXImage()
でBufferedImage
の内容をWritableImage
にコピーする。- UI スレッドで
GraphicsContext#drawImage()
を呼び出し、WritableImage
をCanvas
に描画する。
完成したソースコードと解説
PdfView.javapackage 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 つのプロパティを用意しました。
プロパティ | 型 | 説明 |
---|---|---|
documentProperty | PDDocument | PDF ドキュメントを表わすプロパティです。このプロパティの値を変更すると表示される PDF が変わります。 |
pageIndexProperty | int | PDF ドキュメントの現在のページ ・ インデックスを表すプロパティです。ページ ・ インデックスは 0 から始まるため、 ページ番号よりも 1 小さい値になります。このプロパティの値を変更すると表示される PDF のページが変わります。 |
maxPageIndexProperty | int | PDF ドキュメントの最終ページ ・ インデックスを表す読み取り専用プロパティです。この値は、 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.javapackage 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 著) を使わせていただきました。