golden-luckyの日記

ツイッターより長くなるやつ

PDFから「使える」テキストを取り出す(第2回)

昨日は、PDFの本来の用途は「人間がPDFをビューワーで開いて読む」ことなので、そこから文字を抜き出すのは一筋縄ではいかない、という話をしました。 ではどうすればPDFファイルの中からテキストを取り出せるの、というのが今日の話の出発点です。

まず昨日の記事で、「PDFには国際的な規格があり、これはAdobeから『PDFリファレンスマニュアル』という形で無償で入手できる」という話をしたことを思い出してください。 昨日は話のついでみたいな感じで書きましたが、実を言うと、このリファレンスの中に、「PDFファイルの中に書き込まれているグリフを表示するための情報からUnicodeなテキストを取り出す手法」がちゃんと書いてあるのです。 具体的には、『PDFリファレンスマニュアル第6版』の §5.9 "Extraction of Text Content"に、その情報が一応整理されています。

ただし、言うまでもないんですが、そこだけ読んでも何もわからないと思います。 何をどうすればいいと書いてあるのかを多少でも読み取るためには、そもそもPDFファイルの構造がどんなふうになっているのかを知る必要があります。

そこで、今日は「PDFファイルの構造」について話をします。 リファレンスマニュアルの§5.9に照らしてPDFからテキストを抜き出す方法についてはまた明日。

PDFのドキュメント構造

この記事を読んでいるような人にはよく知られているように、PDFファイルの主要部分は「ドキュメント構造」と呼ばれる論理構造です。 モノの本を読むと、ほかにもごちゃごちゃした話がいっぱい登場しますが、それらは効率的な描画とか一部分の変更とかに必要になる話で、ビューワーを作ったり、あるいはPDFを書き出すアプリケーションを作る人にとって意味があるやつです。 PDFのページの上に表示されている要素が欲しいだけなら、この「ドキュメント構造」の部分を読み解くことでだいたい手に入ります。

で、そのドキュメント構造、全体に「木構造」をしています。 木構造の「葉」や「節」にあたるのが、実際のページの中身や、その表示に必要なリソース、あるいはメタ情報なんかです。

これら「葉」や「節」は、まとめて「オブジェクト」と呼ばれます。 すべてのオブジェクトには「参照番号」がついており、その参照番号でオブジェクトからオブジェクトへのリンクが表現されていて、これにより木構造が形作られるという感じです。

わかったような、わからないような感じだと思うので、以降ではPDFのドキュメント構造のようすを実際に覗いてみましょう。

PDFのドキュメント構造をpdfToolboxで見る

PDFファイルはバイナリです。なので、テキストファイルを開いて構造を見てみる、というわけにはいきません。 なんらかのツールが必要になります。ぼくが知っているドキュメント構造を眺められるツールとしては、Callasソフトウェア製のpdfToolboxというアプリケーションがあります。

このpdfToolboxで「PDFの調査」というコマンドを実行すると、ドキュメント構造をこんな感じに閲覧できます。

f:id:golden-lucky:20191202111802p:plain

「文書ルートカタログ」という文字列が見えると思いますが、これが木構造のルートに相当します。 そのルートの下にある「Pages」が、各ページの中身となるコンテンツにつながるパスです。その下に「Kids」が何階層かあって、それらを幅優先で集めると、その順番でPDFの全ページが再現できます。 面白いですね。

PDFのドキュメント構造をhpdftで見る

pdfToolboxは有料です。けっこういいお値段がしますが、ドキュメント構造を眺める以外にもたくさんの実用的な作業ができるので、PDFを仕事で使う人、PDFに興味がある人なら買って損はないはずです。

とはいえ、昨日の記事でもふれたように、PDFは仕様がすべて公開されています。しかもPDF 1.x系なら無料です。 ドキュメント構造を眺めるくらいなら、この仕様を片手に自分でパーサを書くこともできます。 なので、書きました。

以降では、このhpdftでPDFのドキュメント構造を探訪していきます。

まずはルートを表示してみましょう。 ルートにも参照番号があるので、それを見つけることが自力でドキュメント構造を探訪するときの出発点です。

ルートの参照番号は、PDFファイルの末尾付近にあるトレイラーという部分を読み解くと見つかります。 hpdftでは--trailerというオプションでトレイラーのようすを表示できます。

$ hpdft --trailer NML-book.pdf
[(/Type,/XRef),(/Root,1),(/Info,2),
(/ID,43361acc723afdc07c2f55c7a56ebea9, 43361acc723afdc07c2f55c7a56ebea9),
(/Size,2401.0),(/W,1.0, 3.0, 2.0),(/Filter,/FlateDecode),(/Length,6232.0)]

中身を細かくは説明しませんが、 (/Root,1) というエントリがあるので、なんとなく「ルートの参照番号は1」とわかりますね。 ちなみに、常にルートの参照番号が1というわけではないので注意してください。 ルートの参照番号を知るにはトレイラーを読み解くしかありません。

参照番号がわかれば、その参照番号を持ったオブジェクトを見つけ出して、その中身を取り出せます。 hpdft でも、-rオプションで参照番号を指定することで「オブジェクトのようす」を観察できるようになっています。

参照番号1のオブジェクトのようすを覗いてみましょう。

$ hpdft -r 1 NML-book.pdf
[
/OpenAction: 3, /Fit
/PageMode: /UseOutlines
/PageLabels:
  /Nums: 0.0,
    /S: /D, 4.0,
    /S: /D
/Names: 2124
/Outlines: 2125
/Pages: 2149
/Type: /Catalog]

やはり中身を細かくは説明しませんが、いくつかの「名前と値の組」からなっている雰囲気が読み取れると思います。 さきほどpdfToolboxのGUI画面で見たルートの状況と同じ構造になっていることがなんとなくわかりますよね。

ここで注目してほしいのは、「/Pages: 2149」の部分です。 ルートオブジェクトからは、/Pagesエントリで示される参照番号をたどることで、PDFのページを構成する中身を見つけ出せます。 これが2149を指し示しているので、次は参照番号2149のようすを見てみます。

$ hpdft -r 2149 NML-book.pdf
[
/Type: /Pages
/Count: 122.0
/Kids: 2150, 2186, 2230, 2270
/MediaBox: 0.0, 0.0, 419.53, 595.28]

次は /Kids です。 /Kids には複数の参照番号が並んでいますが、それぞれの下に、PDFのページを構成する中身がまた入れ子になっています。 とりあえず参照番号2150を見てみましょう。

$ hpdft -r 2150 NML-book.pdf
[
/Type: /Pages
/Count: 30.0
/Parent: 2149
/Kids: 2151, 2159, 2169, 2178]

また /Kids が出てきましたね。 参照番号2151のパスを見にいくことにします。

$ hpdft -r 2151 NML-book.pdf
[
/Type: /Pages
/Count: 7.0
/Parent: 2150
/Kids: 3, 2152, 2154, 2156]

まだ /Kids です。 先頭の参照番号3を見てみます。

$ hpdft -r 3 NML-book.pdf
[
/Resources: 67
/Type: /Page
/Parent: 2151
/Contents: 66]

ついに /Kids がなくなり、代わりに /Contents という、それっぽい名前のエントリが出てきました! ためしに、/Contentsが指し示す参照番号66を見てみます。

$ hpdft -r 66 NML-book.pdf
[
/Filter: /FlateDecode
/Length: 381.0,
   q .913 0 0 .913 0 595.276 cm q 462.33906 0 0 655.95015 -3.064 -652.208 cm
/Im24 Do Q 1 G 1 g BT /F1 12.4811 Tf 125.585 -462.55 Td[(#1)]TJ /F2 13.2657
Tf 19.932 0
Td[<0b450a3a0c2403c3029403bb0715037103cd03bb029403ef03da03bf03bd0377062c0ac5>]
TJ ET 0 G 0 g 1 G 1 g BT /F4 10.5206 Tf 395.991 -462.55 Td[(\024)]TJ /F5
10.5206 Tf 7.302 0 Td[(een)]TJ ET 0 G 0 g 1 G 1 g BT /F1 12.4811 Tf 125.585
-485.638 Td[(#2)]TJ /F2 13.2657 Tf 19.932 0
Td[<03cd03bb029403ef03da03bf03bd>]TJ /F1 12.4811 Tf 96.842 0
Td[(in)-318(Ruby)]TJ ET 0 G 0 g 1 G 1 g BT /F6 11.0547 Tf 302.652 -485.638
Td[<0bf0>]TJ /F7 11.0547 Tf 11.055 0 Td[<0e8a>-296<0fe80925>]TJ ET 0 G 0 g
0.925 0.945 0.745 RG 0.925 0.945 0.745 rg BT /F8 25.1057 Tf 83.065 -615.417
Td[(Vol.1,)-301(No.3)]TJ /F8 12.4811 Tf 0 -15.392
Td[(Nov)-301(.)-373(2019)]TJ ET 0 G 0 g Q
]

なにやら何か謎の文字列が出てきました。 これが、PDFのページ上に表示されているものの正体です。 ここまで、常に/Kidsのいちばん左側に指示されているオブジェクトをたどって木構造を降りてきたので、これはPDFの1ページめに相当します。

さて、この文字列をよーく眺めていると、何か察しがつくのではないでしょうか。 そう、この文字列の中には、「PDFのページ上に表示されているテキストっぽいもの」が紛れています。 いかにも文字っぽいアルファベットのようなものも見えますが、それだけでなく、16進表記された数値や、バックスラッシュを前置された8進表記の数値なども、文字を表しているような感じがします。

『PDFリファレンスマニュアル第6版』のセクション5.9 "Extraction of Text Content"には、ここからUnicodeの文字をどうやって取り出せばいいかが書かれているのです。 明日は、いよいよ、その情報を使いながらPDFからテキストを取り出す方法について考えます。