PDF を Bitmap にラスタライズする

やりたいこと

PDF ドキュメントを 1 ページずつバラして、ビットマップの連番ファイルに保存するプログラムJava で書きたい。

やったこと

以下のライブラリを試した。

  1. Ghostscript + Ghost4J
  2. Apache PDFBox

初めに試したのが Ghostscript + Ghost4J である。一応うまくは動いた。

だが、このアプローチは思いっきりネイティブ依存であり、Write once, run anywhere というわけにはいかない。 たとえば Windows 環境ならば gsdll64.dll とかをどこかから拾ってきて Path を通しておく必要があったりする。 実行環境の OS が変わった途端に何が起こるかわかったものではない。

そのうえ、Ghostscript のライセンスは基本的にめんどくさいことで有名な GPL のため、売り物に組み込むのは躊躇ためらわれた。

次に試したのが Apache PDFBox だ。 こちらは Pure Java であり、Apache ライセンスである。 ライブラリとしては発展途上のようだが、PDF のラスタライズだけならば充分使い物になる。 マルチバイト文字や画像が貼り込まれた PDF であっても特に問題はなかった。

実装

とりあえず書いたコードがこれ。 勉強がてら RxJava と組み合わせているが、まだ不慣れなため読みづらいコードになってしまった(このあと少し書き直した)。

3 パターンの指定方法に対応している。

  1. 入力PDFファイルのパス・出力ディレクトリのパス・解像度をすべて指定するパターン。
  2. PDFのパスに加えて出力ディレクトリのパスを指定するパターン。 解像度は 300dpi 固定となる。
  3. PDFのパスのみを指定するパターン。 この場合、出力ファイルは PDF と同じディレクトリに吐かれる。また、解像度は 300 dpi に固定される。

Gradle

build.gradle:

dependencies {
    compile 'com.google.guava:guava:20.0'
    compile group: 'org.apache.pdfbox', name: 'pdfbox', version: '2.0.8'
    compile 'io.reactivex.rxjava2:rxjava:2.1.6'
    testCompile 'junit:junit:4.12'
}

Java

Main.java:

import io.reactivex.BackpressureStrategy;
import io.reactivex.Flowable;
import io.reactivex.exceptions.Exceptions;
import io.reactivex.schedulers.Schedulers;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.IntStream;

import javax.imageio.ImageIO;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        String path = "/Users/tercel/Documents/designPattern.pdf";

        PdfRasterizer.rasterize(path, ".")
            .subscribeOn(Schedulers.newThread())    // 別スレッドで並列で動かす
            .observeOn(Schedulers.computation())    // 
            .subscribe(new Subscriber<String>() {
                private Subscription subscription;
    
                @Override
                public void onSubscribe(Subscription subscription) {
                    this.subscription = subscription;
                    this.subscription.request(1L);
                }
    
                @Override
                public void onNext(String bmpFilePath) {
                    System.out.println(bmpFilePath);
                    this.subscription.request(1L);
                }
    
                @Override
                public void onError(Throwable t) {
                    t.printStackTrace();
                }
    
                @Override
                public void onComplete() {
                    System.out.println("BMP生成完了");
                }
            });
        
        Thread.sleep(10_000L); // しばらく待つ
        System.out.println("メインスレッド終了");
    }
}

class PdfRasterizer {
    /**
     * 指定した PDF ファイルをビットマップに分解しつつ、
     * 分解後のビットマップファイルのフルパスをひたすら垂れ流す Flowable を返す。
     * 
     * @param pdfPath 入力 PDF ファイルのパス
     * @return Flowable
     */
    public static Flowable<String> rasterize(final String pdfPath){
        return rasterize(pdfPath, Paths.get(pdfPath).getParent().toString());
    }
    
    /**
     * 指定した PDF ファイルをビットマップに分解しつつ、
     * 分解後のビットマップファイルのフルパスをひたすら垂れ流す Flowable を返す。
     * 
     * @param pdfPath 入力 PDF ファイルのパス
     * @param outputDirectoryPath 出力 BMP ファイルのパス
     * @return Flowable
     */
    public static Flowable<String> rasterize(final String pdfPath, final String outputDirectoryPath) {
        final int defaultResolution = 300;
        return rasterize(pdfPath, outputDirectoryPath, defaultResolution);
    }

    /**
     * 指定した PDF ファイルをビットマップに分解しつつ、
     * 分解後のビットマップファイルのフルパスをひたすら垂れ流す Flowable を返す。
     * 
     * @param pdfPath 入力 PDF ファイルのパス
     * @param outputDirectoryPath 出力 BMP ファイルのパス
     * @param dpi 解像度
     * @return Flowable
     */
    public static Flowable<String> rasterize(final String pdfPath, final String outputDirectoryPath, final int dpi) {
        return Flowable.create(emitter -> {
            try {
                if(emitter.isCancelled()) return;
                if(!checkArgs(pdfPath, outputDirectoryPath, dpi)) return;
                if(!tryMkDir(outputDirectoryPath)) throw new IOException("フォルダの作成に失敗したよ");

                try(final InputStream in = new FileInputStream(pdfPath);
                        final PDDocument doc = PDDocument.load(in)) {
                    final PDFRenderer pdfRenderer = new PDFRenderer(doc);

                    IntStream.range(0, doc.getNumberOfPages()).forEach(i -> {
                        final Path outPath = Paths.get(outputDirectoryPath, 
                                String.join("", getFileNameWithoutExtention(Paths.get(pdfPath)),
                                        "_", String.format("%05d", i), ".bmp"));

                        try(final OutputStream out = Files.newOutputStream(outPath)) {
                            ImageIO.write(pdfRenderer.renderImageWithDPI(i, dpi, ImageType.RGB), "bmp", out);
                            emitter.onNext(outPath.toRealPath().toAbsolutePath().toString());
                        } catch(IOException e) {
                            throw new RuntimeException(e);
                        };
                    });

                    emitter.onComplete();
                }
            } catch(Throwable t) {
                Exceptions.throwIfFatal(t);
                emitter.onError(t);
            }
        }, BackpressureStrategy.BUFFER);
    }

    /**
     * 引数チェック。妥当な引数の場合は true, それ以外は例外をスローし異常終了
     * @param pdfPath 入力 PDF のパス
     * @param outputDirectoryPath 出力ディレクトリパス
     * @param dpi 解像度
     * @return
     * @throws FileNotFoundException
     */
    private static boolean checkArgs(String pdfPath, String outputDirectoryPath, int dpi) throws FileNotFoundException {
        if(!isPDF(pdfPath)) { throw new IllegalArgumentException("PDFじゃないよ"); }
        if(!exists(pdfPath)) { throw new FileNotFoundException("そんなPDFないよ"); }
        if(!isDirectory(outputDirectoryPath)) { throw new IllegalArgumentException("フォルダが指定されてないよ"); }        
        if(dpi < 1) { throw new IllegalArgumentException("解像度の指定がおかしいよ"); }        

        return true;
    }

    /**
     * 指定されたパスが表すファイルが PDF かどうかを判定する。
     * @param path ファイルパス
     * @return ファイルが PDF の場合は true, それ以外は false
     */
    private static boolean isPDF(String path) {        
        return path != null && path.toLowerCase().endsWith(".pdf");
    }

    /**
     * 指定されたファイルまたはディレクトリが存在するかどうかを判定する。
     * @param path ファイルパス
     * @return ファイルまたはディレクトリが存在する場合は true, それ以外は false
     */
    private static boolean exists(String path) {
        return Files.exists(Paths.get(path));
    }

    /**
     * 指定されたパスがディレクトリかどうかを判定する。
     * @param path ファイルパス
     * @return ディレクトリの場合は true, それ以外は false
     */
    private static boolean isDirectory(String path) {
        return path != null && Files.isDirectory(Paths.get(path));
    }

    /**
     * 指定されたパスのうち、拡張子を除いたファイル名を返却する。
     * @param filePath ファイルパス
     * @return 拡張子を除いたファイル名
     */
    private static String getFileNameWithoutExtention(Path filePath) {
        final String fileName = filePath.getFileName().toString();
        final int point = fileName.lastIndexOf('.');
        return 0 < point ? fileName.substring(0, point) : fileName;
    }

    /**
     * ディレクトリの作成を試みる。同名のディレクトリが既に存在する場合は何もしない。
     * また、ディレクトリの作成に失敗した場合も例外をスローしない。
     * @param dirPath ディレクトリパス
     * @return ディレクトリの作成に成功した場合、または既に同名のディレクトリが存在する場合は true, それ以外は false
     */
    private static boolean tryMkDir(String dirPath) {
        if (dirPath == null) {
            return false;
        }

        final Path ret = Paths.get(dirPath);
        if(Files.notExists(ret)) {
            try {
                Files.createDirectories(ret);
            } catch(IOException e) {
                e.printStackTrace();
                return false;
            }
        } else if(!Files.isDirectory(ret)) {
            return false;
        }
        return true;
    }        
}

実行すると、入力ファイル名_00000.bmp, 入力ファイル名_00001.bmp, ... といった連番ファイルが順次吐かれる。 ただ、main() メソッドが少し冗長になってしまったので、このあと少し手直しをしている。

まとめ

ストレスのせいで体に湿疹ができた。医者に行きたい。