ドキュメントスキャナで紙の本をスキャンし、PDF化したものをiPad Airに入れて持ち歩き、i文庫HDなどで読んでいます。いわゆる自炊ってやつです。スキャナは主にScanSnap iX500を使っていて、どの本でもカラーの300dpiでスキャンしています。圧出率は標準の3にしています。

解像度が高すぎるから最適化したい

ただ、自炊したPDFをそのままiPadに入れると無駄に解像度が高く、ストレージの容量を食いつぶしてしまいます。また、各ページを表示するときに縮小処理が行われるため、スクロールも遅くなります。

そこで、あらかじめ解像度をiPadの画面と同じ1536×2048ピクセルまで縮小したPDFを作っておき、これを持ち歩くようにしています。A5サイズ以下の本の場合は、iPadを横長に持って見開きで読むことが多いので、見開き表示したときの1ページ分のサイズである1024×1536ピクセルまで縮小しています。どっちのサイズに縮小するかはページごとに決めます。版型の小さい本でも、ジャケットや折り込み図などサイズの大きいページは1536×2048ピクセルにしておきたいためです。

JPEGにバラしてPDFに組み直すと、元PDFの設定情報が失われる

以前「自炊PDFを第3世代iPadに最適化する その3」で紹介した方法では、pdfimagesで一旦PDFから全JPEGを書き出し、ImageMagickで縮小したあと、pdftkで再度PDFに組み立て直していました。

しかしこの方法だと、当然ながらオリジナルPDFの設定情報はすべて失われてしまいます。OCRをかけて埋め込んだ透明テキストやページ番号の設定に始まり、綴じ方の右開き設定、PDFを開いたときの見開き設定や倍率、各ページの回転方向やトリミングなどすべて失われるため、必要であれば縮小版のPDFに再度設定する必要がありました。

もし、PDFの画像データだけを縮小版に置き換えるようなことができれば、この問題は解決します。

iTextで画像データだけを差し替えれば解決

JavaでPDFを操作するオープンソースのライブラリiTextを使えば、PDFに含まれるJPEGだけを入れ替えて保存し直すこともできることが分かったので、今回はその方法を試してみます。(ちなみに、iTextには .NET Framework用のiTextSharpもあるので、Windows系ならそっちを使う手もありそうです。)

以下、具体的なソースコードのサンプルを記述します。

ページのJPEGデータにたどり着く

サンプルでは、main()で全処理をやることにします。

まず、第1引数で指定したPDFファイルをPdfReaderで開き、ページ数分ループします。PDFのページ数は0からではなく1から始まります。

public static void main(String[] args) throws Exception {
    PdfReader reader = new PdfReader(args[0]);
    for (int page = 1; page <= reader.getNumberOfPages(); page++) {
        ……
    }
}

forループの中では、最初に各ページのリソースを取り出します。

PdfDictionary res = reader.getPageResources(page);

リソースのオブジェクト構成は、ScanSnapで作ったPDFの場合、下図のような感じになっています。

  • リソース
    • /ColorSpace 《Dictionary》
    • /Font 《Dictionary》
    • /ProcSet [/PDF /Text /ImageC]
    • /XObject 《Dictionary》
      • /Im0 181 0 R
        • /Type /XObject
        • /Subtype /Image
        • /Length 278791
        • /Filter /DCTDecode
        • /BitsPerComponent 8
        • /ColorSpace 182 0 R
        • /Width 1200
        • /Height 1776

なので、リソースから「/XObject」取り出し、さらにその中から「/Im0」を取り出します。名前がいかにも連番っぽいので、本当は決め打ちしない方がいいかもしませんが、ここでは話を簡単にするために、どのページも「/Im0」という名前になっているものとします(たぶん、ScanSnapを使っている限りはこれでも大丈夫かと)。「/Im0」はストリームオブジェクトへの参照です。オブジェクト番号を頼りに、参照先のストリームを取得します。

PdfDictionary xobj = res.getAsDict(PdfName.XOBJECT);
PdfIndirectReference im0 = xobj.getAsIndirectObject(new PdfName("Im0"));
PRStream stream = (PRStream) reader.getPdfObject(im0.getNumber());

JPEGを書き出す

ストリームが得られたら、それをPdfImageObjectでラップして、JPEGのバイト配列を取り出していったんファイルに書き出します。

Path tmpJpg = Paths.get("tmp.jpg");
PdfImageObject imgObj = new PdfImageObject(stream);
Files.write(tmpJpg, imgObj.getImageAsBytes());

JPEGの解像度を下げる

画像の縮小は、java.awt.geom.AffineTransformなどを使えばJavaだけでもできるようですが、ここでは画質を重視してImageMagickを使います。また、JavaからImageMagickを呼び出すにはJMagickやim4javaなどを使う方法もあるようですが、ここでも話を簡単にするために、コマンドラインを文字列で組み立ててRuntimeで別プロセスとして呼び出すことにします。

オリジナルのJPEGのピクセルサイズを見て、変換後のピクセル数を決めます。概ねA5サイズとなる2539ピクセルを超えていたら1536×2048ピクセルに、それ以下なら1024×1536ピクセルにします。もしオリジナルが横長だったら、縦横のピクセル数を逆にします。ピクセル数指定の末尾の「>」はリダイレクトではなく「最大で」の意味です。縦横比を維持してこのサイズをはみ出さないようにおさめてくれという指定です。

int width = ((PdfNumber) stream.get(PdfName.WIDTH)).intValue();
int height = ((PdfNumber) stream.get(PdfName.HEIGHT)).intValue();
String resizeOpt = null;
if (width > 2539 || height > 2539) {
    resizeOpt = width < height ? "1536x2048>" : "2048x1536>";
} else {
    resizeOpt = width < height ? "1024x1536>" : "1536x1024>";
}

サイズが決まったら、コマンドラインを組み立ててRuntime#exec()で呼び出し、Process.waitFor()で終了を待ちます。mogrifyは、変換結果で元のファイルを置き換えるコマンドで、オプションはconvertと同じです。本当はwaitFor()の戻り値が0(正常)でなければエラー処理すべきですが、ここでは省略。

String cmd = "mogrify -quality 50 -resize " + resizeOpt + " " + tmpJpg;
Process proc = Runtime.getRuntime().exec(cmd);
proc.waitFor();

JPEGを入れ替える

Process.waitFor()から制御が返ってきたら、tmp.jpgは中身が縮小版に入れ替わっているはずです。ファイルから全バイト列を読み込んで、ストリームにsetData()します。変換後の幅と高さのピクセル数をストリームに設定する必要があるのですが、それを知るために一旦ImageIO.read()でJPEGファイルを読み込み、BufferedImageクラスからピクセル数を取得しています。

stream.setData(Files.readAllBytes(tmpJpg), false, PRStream.NO_COMPRESSION);
BufferedImage newImg = ImageIO.read(tmpJpg.toFile());
stream.put(PdfName.WIDTH, new PdfNumber(newImg.getWidth()));
stream.put(PdfName.HEIGHT, new PdfNumber(newImg.getHeight()));
stream.put(PdfName.FILTER, PdfName.DCTDECODE);

ちなみに、BufferedImageからもJPEGのバイト配列を取り出すことができるので、最初はそれをストリームにsetData()していたのですが、どこかでJPEGの再変換がかかっているようで、ImageMagickでの変換後よりもバイトサイズが膨らんでしまっていたため、ストリームにセットするバイト列はファイルから直接読み取ったものを使うようにしました。

ループの最後に、テンポラリのJPEGファイルは削除しておきます。

Files.delete(tmpJpg);

PDFを書き出す

forループを抜けて全ページのJPEG置き換えが終わったら、PdfStamperを使って結果を書き出します。ここではout.pdfというファイルに書き出しています。

FileOutputStream out = new FileOutputStream("out.pdf");
PdfStamper stamper = new PdfStamper(reader, out);
stamper.close();
reader.close();

PdfStamperとPdfReaderをクローズして変換処理は終わりです。

サンプルソース全文

import文なども含めた実働サンプルを載せておきます。エラー処理やログ出力などは大胆に省略しているので、実際には適宜組み込む必要があります。自分が自炊したPDFに関しては、このソースでも正常に変換できました。ファイルサイズは、いろんな版型の本・マンガ・雑誌などすべてひっくるめた平均で、オリジナルPDFの45%まで小さくなっています。「その3」の方式より、若干小さめに出ているようです(ImageMagickのバージョンも変わっているので、正確なところは何ともいえませんが……)。

import java.awt.image.BufferedImage;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

import javax.imageio.ImageIO;

import com.itextpdf.text.pdf.PRStream;
import com.itextpdf.text.pdf.PdfDictionary;
import com.itextpdf.text.pdf.PdfIndirectReference;
import com.itextpdf.text.pdf.PdfName;
import com.itextpdf.text.pdf.PdfNumber;
import com.itextpdf.text.pdf.PdfReader;
import com.itextpdf.text.pdf.PdfStamper;
import com.itextpdf.text.pdf.parser.PdfImageObject;

public class OptiPad {
    public static void main(String[] args) throws Exception {
        PdfReader reader = new PdfReader(args[0]);
        for (int page = 1; page <= reader.getNumberOfPages(); page++) {
            PdfDictionary res = reader.getPageResources(page);
            PdfDictionary xobj = res.getAsDict(PdfName.XOBJECT);
            PdfIndirectReference im0 = xobj.getAsIndirectObject(new PdfName("Im0"));
            PRStream stream = (PRStream) reader.getPdfObject(im0.getNumber());

            Path tmpJpg = Paths.get("tmp.jpg");
            PdfImageObject imgObj = new PdfImageObject(stream);
            Files.write(tmpJpg, imgObj.getImageAsBytes());

            int width = ((PdfNumber) stream.get(PdfName.WIDTH)).intValue();
            int height = ((PdfNumber) stream.get(PdfName.HEIGHT)).intValue();
            String resizeOpt = null;
            if (width > 2539 || height > 2539) {
                resizeOpt = width < height ? "1536x2048>" : "2048x1536>";
            } else {
                resizeOpt = width < height ? "1024x1536>" : "1536x1024>";
            }
            String cmd = "mogrify -quality 50 -resize " + resizeOpt + " " + tmpJpg;
            Process proc = Runtime.getRuntime().exec(cmd);
            proc.waitFor();

            stream.setData(Files.readAllBytes(tmpJpg), false, PRStream.NO_COMPRESSION);
            BufferedImage newImg = ImageIO.read(tmpJpg.toFile());
            stream.put(PdfName.WIDTH, new PdfNumber(newImg.getWidth()));
            stream.put(PdfName.HEIGHT, new PdfNumber(newImg.getHeight()));
            stream.put(PdfName.FILTER, PdfName.DCTDECODE);
            Files.delete(tmpJpg);
        }
        FileOutputStream out = new FileOutputStream("out.pdf");
        PdfStamper stamper = new PdfStamper(reader, out);
        stamper.close();
        reader.close();
    }
}

※バージョンメモ

  • JDK 1.8.0_111
  • iText 5.5.10
  • ImageMagick 7.0.3