golden-luckyの日記

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

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

昨日の記事では、PDFのページに表示されるコンテンツはPDFのドキュメント構造を掘っていくと手に入れることができて、それはこんな姿をしているぞ、というところまで話が進みました。

$ 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のコンテンツストリーム

ドキュメント構造をルートからたどっていって、最後に/Contentsが指し示す参照番号のオブジェクトに含まれている上記のような文字列は、PDFの「コンテンツストリーム」と呼ばれています。 コンテンツストリームは、PDFに表示されるべき「テキスト」とか「グラフィックス」とかを格納するための仕組みだといえます。

じゃあ、これをどう読めば描画結果になるかというと、簡単に言えばスタックマシンで実行します。 第1回のPDFの歴史のあたりの話から丁寧に読んでいる人はここでピンとくるかもしれませんが、「PDFがPostScriptのバイナリ版」みたいな説はここに由来します。

ただし、PostScriptとPDFのコンテンツストリームとでは、スタックマシンで扱う命令の種類はけっこう違います。 上記の文字列の中に見えるqとかQとかcmとかBTとかみたいなのがPDFの仕様で定義されている「オペレータ」です。 それ以外の数字とかは変数や定数で、これらを先頭からPDFコンテンツストリーム用のスタックマシンで実行すれば、描画結果が得られるという仕掛けになっています。

なお、コンテンツストリームは、ふつうは全体が何らかのアルゴリズムで圧縮されています。 圧縮のアルゴリズムが何であるかは、このオブジェクトの/Filterエントリで指定されています。 この例の場合は/FlateDecodeで、これは、いわゆるZIPの圧縮に使われているアルゴリズムです。

圧縮されてるので、そのままの姿を端末で表示すると端末が死んだりします。 なのでhpdftではコンテンツストリームについては圧縮を復元した状態を表示するようにしていて、したがって、上記の文字列をそのままPDFの仕様に沿って読めばPDFのページ上のコンテンツを復元できるはず、です。

PDFのテキストセクション

いま興味があるのは、コンテンツストリームのうち、「テキスト」を表現している部分だけです。 PDFの仕様では「BTオペレータからETオペレータまで」の部分を「テキストセクション」と呼んでおり、テキストとして取り出すべき情報はすべてそこにあります。

そこで、まずは上記のコンテンツストリームから、先頭のテキストセクションだけ手作業で抜き出してみます(見やすいように改行とインデントを施しました)。

BT
  /F1
  12.4811 Tf
  125.585 -462.55 Td
  [(#1)] TJ
  /F2
  13.2657 Tf
  19.932 0 Td
  [<0b450a3a0c2403c3029403bb0715037103cd03bb029403ef03da03bf03bd0377062c0ac5>] TJ
ET

このうち、テキストをページ上に書き出す機能を担うオペレータは、TJというやつです。 ページ上にテキストを描画するオペレータは、TJ以外にもたくさんあるんですが、このテキストセクションでは幸いにもTJしか使われていないみたいですね。

TJは、引数として配列を1つとり、その配列に含まれているテキストをいい感じにページに描画する、というオペレータです。 PDFのスタックマシンはPostScriptと同様に後置記法なので、TJの前にあるのが引数の配列です。 このテキストセクションには2つのTJオペレータがあり、それぞれ[(#1)]および[<0b450a3a0c..(省略)>]が引数の配列です。

さて、勘の良い人は気づいているかもしれませんが、最初の[(#1)]については、#および1という2つの「文字」からなるテキストそのものが引数として指示されているように見えます。 一方、2つめの[<0b450a3a0c..(省略)>]は、ちょっとこれだけを見てもどうやって文字を読み解けばいいのかわかりません。 つまり、同じテキストセクションにあり、同じオペレータの引数でありながら、両者では「文字」の読み方がぜんぜん異なるということです。

PDFのフォントリソース

実は、コンテンツストリームでTJオペレータなどで描画を指示されるテキストをどうやって読むかは、そのときのスタックマシンの状態に依存します。 したがって、上記のような文字列からTJの前にある[...]の部分を読み取ればテキストが得られる、というわけではありません。 スタックマシンを実装してコンテンツストリームをまじめに読むことになります。

説明だけすると、上記のテキストセクションに出てくる/F1とか/F2とかが「フォントリソース」を示しており、これによって以降のテキストの読み方が変化します。 そして、フォントリソースはコンテンツストリームの中を探しても見つからず、そのコンテンツストリームの場所を間接的に指示していたオブジェクトの中で、やはり間接的に指示されています。

ちょっと複雑に聞こえるかもしれませんが、要するに参照番号を逆向きに1つ戻るだけです。 いま見ているテキストセクションがあるコンテンツストリームがあるのはオブジェクト66で、その場所を示しているのはオブジェクト3でした。 オブジェクト3はこれです。

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

見てのとおり、/Resourcesというエントリがあります。 このエントリが指し示しているオブジェクト67が、テキストセクションに登場するフォントリソースを探しに行くべきオブジェクトということです。

オブジェクト67を見てみましょう。

$ hpdft -r 67 NML-book.pdf
[
/ColorSpace: 4
/XObject:
  /Im24: 55
/Font:
  /F1: 56
  /F2: 58
  /F4: 59
  /F5: 60
  /F6: 62
  /F7: 64
  /F8: 65
/ProcSet: /PDF, /Text, /ImageC, /ImageB, /ImageI]

なんかそれっぽいのが出てきましたね! どうやら、/F1については参照番号56、/F2については参照番号58をそれぞれ見ればいいようです。

まずは/F1のほうから調べます。

$ hpdft -r 56 NML-book.pdf
[
/Type: /Font
/Subtype: /Type1
/Widths: 2307
/FirstChar: 2.0
/LastChar: 121.0
/Encoding: 2351
/BaseFont: /IPQGNU+LucidaSans-Demi
/FontDescriptor: 2352]

いろいろな情報が含まれているんですが、いま知りたいのは「テキストの読み方」で、これを知るには/Encodingで示されているオブジェクト2351をあたります。

$ hpdft -r 2351 NML-book.pdf
[
/BaseEncoding: /WinAnsiEncoding
/Differences: 2.0, /fi, 30.0, /grave, /quotesingle, 39.0, /quoteright, 96.0, /quoteleft]

オブジェクト2351には、/BaseEncoding/Differencesという2つのエントリがありました。 これらの情報が、/F1というフォントリソースで示されている「テキストの読み方」、つまりこの場合には、「[(#1)]を文字にする方法」になります。

まず、/WinAnsiEncodingというのは、Windowsの標準的なエンコーディング方式を示す名前として、PDFの仕様で定義されているものです。 ちなみに、いままで説明をさぼっていますが、スラッシュで始まる文字列はPDFの仕様では定義済みの名前を表します。詳しいことはPDFの仕様を見てね。

/Differencesとして示されている数字や名前が何かというと、これは/BaseEncodingに対する差分を表します。 つまりこの場合には、Windowsの標準的なエンコーディング方式を使うけど、いくつかの文字については個別に指定しておくからそっちを使ってくれ、という指示です。 /Differencesエントリの読み方を説明するのはめんどくさいので、これも詳しいことは仕様を見てね。

さて、/WinAnsiEncodingというのは、たまたま主要部分がASCIIの印字可能な文字のコードとほとんど一緒なエンコーディング方式です。 そのため、この例のフォントリソース/F1では、TJなどの引数に指定されている値はおおむねASCIIの文字コードそのものになります。 [(#1)]#および1という「文字」そのものに見えたのは、TJにASCIIのような文字コードが指定できるからではなく、このような背景による結果です。

PDFの「テキスト」はフォント中のグリフの場所

そろそろ今なにをしていたのかわからなくなってきたと思うので、いま読もうとしているテキストセクションを再掲しておきます。

BT
  /F1
  12.4811 Tf
  125.585 -462.55 Td
  [(#1)] TJ
  /F2
  13.2657 Tf
  19.932 0 Td
  [<0b450a3a0c2403c3029403bb0715037103cd03bb029403ef03da03bf03bd0377062c0ac5>] TJ
ET

[(#1)]のほうはなんとなく読めたということにして、次は[<0b450a3a0c..(省略)>] TJのほうを見てみます。 こちらは16進表記の何かに見えますが、UnicodeのコードポイントでもUTF-8エンコードされた値でもなさそうだし、いったいどう解釈すればいいのでしょうか?

とりあえず、/F2というフォントリソースがあるとされているオブジェクト58のようすを確かめます。

$ hpdft -r 58 NML-book.pdf
[
/Type: /Font
/Subtype: /Type0
/BaseFont: /LPZEOE+HiraKakuPro-W6-Identity-H
/Encoding: /Identity-H
/DescendantFonts: 57

今度は/Encodingが参照番号ではなく/Identity-Hという名前になっています。 実は、この/Identity-Hというのがなかなか曲者で、これは「フォントの指示そのままにテキストを描画してね」という意味合いになります。「そのまま」なので"Identity"なのです。

ここで再び、「文字」は「グリフ」ではない、という話を思い出してください。 /Identity-Hで指示されているのは、文字の読み方ではなく、「テキストを描画するときのグリフの見つけ方」です。 「フォントにおけるグリフの場所」は、いわゆる文字コードではなく、一般にはCIDとかGIDといった値として、フォントごとに定められています。 「そのフォントが定めているやり方で、TJみたいなオペレータによって指示されている数値をCIDとかGIDとみなし、その場所にあるグリフをPDFのページに描画すればよい」、というのが/Identity-Hの意味というわけです。

CID/GIDからUnicodeの値を知る

ここまでのおさらい。

  1. フォントリソースを見ることで、/Identity-Hというエンコーディング方式に従えばテキストが再現できるとわかった
  2. /Identity-Hでは、TJなどのテキスト描画のためのオペレータの引数の値をそのまま、フォントからグリフを見つけ出すときの値として使える

これって、「テキストセクションを解析して文字を手に入れたい」という要望にとってはデッドエンドに思えますよね。 フォントからグリフを取り出せるとして、それが何の「文字」なのか、どうやって知ればいいのでしょう?

この状況を打破するには、フォント中のグリフを指す値から文字コードを解決する方法、より狭く言えば「CID/GIDUnicodeの対応関係のテーブル」が必要です。 そのような対応関係のテーブルは、一般にはCMapとかcmapと呼ばれていて、やはりAdobeが仕様を公開しています。

ここで気が付いてほしいのは、文字からグリフを探すとき、つまりPDFの生成時にもCMapが必要だということです。 つまり、PDF生成時に使ったCMapがわかれば、そのPDFのコンテンツストリームから「文字」を探し当てられます。

PDFから制作に使われたCMapを探し当てるには、大きくわけると次の2つの方針があります。

  1. PDFの中から生成に使ったCMapの名前を探す
  2. PDFの中に埋め込まれたCMapに相当する情報を探す

言い換えると、PDFの中を探って文字を取り出せるかどうかは、これらの情報を「PDFの制作者がPDFのどこかに用意してくれているか」によって決まるということです。 逆にいうと、このへんを考慮せずに作られたPDFでは、どんなに内部を解析しても意図通りには文字を取り出せません。

1の方針で作られているPDFでは、フォントリソースの/CIDSystemInfoというエントリでCMapが指示されています。 いろいろな種類があるのですが、日本語だとまあだいたいAdobe-Japan1というやつです。

2の方針で作られているPDFでは、フォントリソースの/ToUnicodeというエントリに、そのような対応表の「材料」を示したオブジェクトの参照番号が指示されています。 その材料をもとにCMapを再現することで、そのPDFから「文字」が取り出せます。

昨日の記事で触れた『PDFリファレンスマニュアル』の§5.9は、このへんの話をひととおり整理した内容なのでした。 PDFのストリームコンテンツを処理するスタックマシンを実装し、§5.9に従ってCMapをなんとかすれば、PDFから文字を取り出せます。

長くなってしまったので、今日はここで終わります。 まだ「文字」の話しかしていないことからわかるように、ここから「文字列」を取り出すにはもうひとがんばり必要になります。 明日はその話をする予定です。