golden-luckyの日記

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

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

昨日までで、PDFからテキストを取り出すにあたり、グリフから文字を手に入れるところまでを説明しました。

いや本当のことを言うと、まだ全然説明できてないんです。 でも、文字の話ばかりしていても先に進めないので、今日は(可能な場合には)PDFから文字を入手できるものとし、そこからテキストを再構築する話に進みます。

文字については改めて明後日にでも補足記事を書くかも(このシリーズはいちおう今日と明日で終わる予定)。

PDFオペレータを読むとグリフを置く場所がわかる

昨日に引き続き、次のようなテキストセクションで考えます。 グリフから文字の解決は済んでいるということにして、TJオペレータの引数は文字そのものに置き換えました。

BT
  /F1
  12.4811 Tf
  125.585 -462.55 Td
  [(#1)] TJ
  /F2
  13.2657 Tf
  19.932 0 Td
  [(代数的データ型とパターンマッチの基礎)] TJ
ET

一般にPDFビューワーでは、「ページ上のどこにグリフを置くか」を、ページを抽象化した「テキスト空間」の座標として計算しています。 そして、テキスト空間における現在の位置を更新するオペレータや、テキスト空間を変形させるオペレータがいくつか用意されています。 スタックマシンを実行すると、そうしたオペレータにより、テキストの現在の位置が更新されていきます。 その途中でTJのようなテキスト描画のためのオペレータがあれば、そこに引数のテキストが配置されます。

人間がペンで紙に文字を書くときは、腕の位置を移動させたり、逆に紙を移動させたりしながら、互いに重ならないように文字を配置していくわけですが、それと同じようなことをスタックマシンの実行によりやっている感じです。

で、上記のうちTdというオペレータが、腕の位置を動かす役目のやつです。 引数を2つ取っていることがわかると思いますが、これらが動かす先のxy座標におおむね相当します。

どういうことなのか、上記の例で見てみましょう。

1つめのTdは引数として125.585-462.55を取ります。 これは直観的に解釈すると、「横方向に125.585単位、縦方向に-462.55単位だけ腕を動かす」という感じになります。 そうやって腕を動かした場所に、その次に出てくるTJオペレータによって「#1」というテキストが描画されます。

同様に、2つめのTdの引数は19.9320です。 これは2つめの引数がゼロなので、縦方向には動かないっていうことだな、というのが読み取れます。 横方向に少し移動し、そこで次に出てくるTJオペレータによって「代数的データ型とパターンマッチの基礎」というテキストの各文字が描画される、という具合です。

ちなみにTfというオペレータは、描画するフォントのサイズを変える働きをします。 この例だと、最初の「#1」というテキストの各文字は、「フォントリソース/F1のフォントをサイズ12.4811単位で描画する」ということがわかります。 同様に、次の「代数的データ型とパターンマッチの基礎」というテキストの各文字は、「フォントリソース/F2のフォントをサイズ13.2657単位で描画する」ということがわかります。

グリフを置く場所からテキストを推測する

さて、ここで注意してもらいたいんですが、ここまでに説明したPDFオペレータの動作は「PDFのページにテキストを描画する」ために用意されています。 つまり、そうやって描画されたテキストをどう読めばいいのかは、この仕様からはわかりません。 「各文字の座標からテキストを意味がある順番に再構築」って、いったいどうやればいいんでしょう?

それほど自明な解決方法はないような気がしますが、とりあえずPDFの仕様に戻って考えてみます。

先ほどは、TDオペレータの動きを、「xy座標に沿ってテキストを描画する位置を動かす」という、なんとなくタイプライターっぽいモデルで説明しました。 実は、PDFの仕様では、こういうタイプライターモデルは使われていません。 代わりに、「各文字が描画されるべき座標」を、「テキスト行列」と呼ばれる3x3行列でぐるぐる変換していくことになっています。 Tdオペレータも、実際には、このテキスト行列を更新するためのオペレータとして考えるべきです。

行列計算といっても、テキスト行列のほとんどの要素はゼロで、実際に計算に必要な値は3x3行列の9つある要素のうち6つだけです。 仕様でも、これら6つの値を[a, b, c, d, e, f]という配列で管理するものとしています。

たとえば、コンテンツストリームにt1 t2 Tdというオペレータが登場したとしましょう。 これにより、[a, b, c, d, e, f]というテキスト行列が、[a, b, c, d, a*t1 + c*t2 + e, b*t1 + d*t2 + f]というテキスト行列へと更新されることが単純な手計算でわかります。 なので、このような行列の更新としてTdオペレータの挙動を定義できます。

さらに、こちらのほうがむしろテキストを取り出すという目的にとっては重要なんですが、このテキスト行列の更新を観察することで、「改行が発生するかどうか」を推測できます。 何を根拠にして推測するかというと、とりあえずPDFが横書きだと想定し、「次の2つのいずれかの状況になったら、このTdオペレータによって次のテキスト描画のタイミングで改行が発生する」と決めます。

  • 現在のテキストの横方向の位置が、行列変換によって、それまでよりも小さくなった(改行が発生していなければ右になるはず)
  • 現在のテキストの縦方向の位置が、行列変換によって、それまでと極端に変わった(改行が発生していなければほぼ同じになるはず)

テキスト行列に変化をもたらすオペレータは、TdのほかにTmなどがあります。 それらのオペレータの仕様の記述をにらみながら、同様の推測をすることで、「人間が期待するような順番でテキストを再構築」しようというわけです。

ちなみに今の話に直接は関係ないですが、文字を取り出すのにOCRするアプローチでも、この「人間が読む順番を表現したメタ知識をどうやって与えてあげるか」という問題の解決策は必要になりますね。 コンテンツストリームから取り出すのとは別のヒューリスティックが必要になるような気がします。

PDFのページ上のテキストをhpdftで取り出す

グリフから文字を取り出せるようになり、テキスト行列をだいたい制御できるようになったら、いよいよPDFのページからテキストを取り出せます!

hpdftでは-pオプションでページ番号を指定してテキストを抜き出せるようにしてあります。

$ hpdft -p 1 NML-book.pdf

#1 代数的データ型とパターンマッチの基礎 3een
#2 パターンマッチ in Ruby 辻本 和樹
Vol.1, No.3
Nov . 2019

だいたい期待どおりの改行位置とスペーシングでテキストが抜き出せてますね! ただ、3eenという文字列は本当ならκeenとなってほしいので、CMapの扱いはもうちょっと詰める必要がありそうです。

ちなみに、このくらいであれば、pdftotextのような既存のツールでもだいたい意図したとおりに改行とスペーシングを見つけ出してくれます。 さすが、「κ」もちゃんと変換できてますね。

$ pdftotext -f 1 -l 1 NML-book.pdf
$ cat NML-book.txt
#1 代数的データ型とパターンマッチの基礎 κeen
#2 パターンマッチ in Ruby 辻本 和樹

Vol.1, No.3
Nov . 2019

ただ、もうちょっとテキストが多いページになると、pdftotextの出力結果はわりと破綻してきます。 同じPDFの7ページはこんな感じ。

$ pdftotext -f 7 -l 7 NML-book.pdf
$ cat NML-book.txt
Lambda Note

1.2 SML♯ の REPL で速習 SML

3

本記事には、特に断りがない限り、処理系依存のコードは登場しません。そのた
め、本記事を読むうえでは、規格を満たす処理系であれば上記に限らず何を利用して
も問題ないはずです。
C と SML 以外の言語の話も少しだけ出てきます。それらについては、想定する処理

系をそのつど個別に記載します。

1.2 SML♯ の REPL で速習 SML
パターンマッチの話題に入る前に、SML の基本的な挙動を確認しておきましょう。
幸い SML♯ には REPL(対話的実行環境)があるので、これを利用して以降の解説に必
要な最低限の情報をまとめます。
シェルから smlsharp として SML♯ を起動すると、下記のように REPL が立ち上が
ります。 # が SML♯ のプロンプト記号です。
1
2
3

$ smlsharp
SML# 3.4.0 (2017-08-31 19:31:44 JST) for x86_64-pc-linux-gnu with LLVM 3.7.1
#

hpdfだと同じ範囲はこうなります。

$ hpdft -p 7 NML-book.pdf

Lambda Note 1.2 SML ♯のREPL で速習 SML 3
Vol.1, No.3(2019)-#1
? SML: SML ♯?33.4.0
? C: GCC 8.3.0
本記事には、特に断りがない限り、処理系依存のコードは登場しません。そのた
め、本記事を読むうえでは、規格を満たす処理系であれば上記に限らず何を利用して
も問題ないはずです。
C とSML 以外の言語の話も少しだけ出てきます。それらについては、想定する処理
系をそのつど個別に記載します。
1.2 SML ♯のREPL で速習 SML
パターンマッチの話題に入る前に、 SML の基本的な挙動を確認しておきましょう。
幸い SML♯には REPL (対話的実行環境)があるので、これを利用して以降の解説に必
要な最低限の情報をまとめます。
シェルから smlsharp として SML♯を起動すると、下記のように REPL が立ち上が
ります。 #がSML♯のプロンプト記号です。


1 $smlsharp ?
2 SML# 3.4.0 (2017-08-31 19:31:44 JST) for x86_64-pc- linux-gnu with LLVM 3.7.1
3 #

よく見るとpdftotextには欠けている情報があることもわかるでしょう。

この例だと「κ」の問題などがあるのでどっちもどっちですが、ここまで自力でPDFからのテキスト抜き出しができると、ちょっと工夫して自分に都合よく改行の推測ルーチンを変更したりできます。 さらに、テキストに直接関係しないPDFの他のオペレータなんかも利用して、メタ情報を付加でき足りできます。

明日は、そんな感じに自作のPDFテキスト取り出しツールを実用して売り物の書籍を作った話をします。

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から文字を取り出せます。

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

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からテキストを取り出す方法について考えます。

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

PDFからテキストを取り出すのは、意外と大変です。 それにはいくつかの理由があるのですが、もっとも根本的な点で真っ先に解決が必要になるのは、人間が雑に文字としてみなしている絵(「グリフ」)をコンピューターで扱えるような「文字」にする方法です。

これには2つのアプローチが考えられます。

  1. PDFビューワーでファイルを開いた状態から何とかしてテキストを読み取る
  2. PDFファイルの中身を解析してテキストを抜き出す

このうち2つめの話は明日以降にして、今日は1つめの話をします。

PDFビューワーでファイルを開いた状態から何とかしてテキストを読み取る方法

この方法は、言ってみれば、人間もしくは人間のように振る舞うソフトウェアによりPDFビューワーの表示を「視覚的に読む」ということです。 これはPDFの本来の使い道に即した手法です。 PDFというのは、グリフ(文字の形)をページ上に表示するための汎用の仕組みだからです。

ここで厄介なのが、グリフは文字そのものではない、という点です。 「文字そのもの」と書くと哲学的に聞こえますが、ここでは「さまざまなコンピューター環境の間でやり取りして文字としての意味が保たれるようなもの」くらいの意味だと思ってください。 ようするに、「コンピューターで扱えるように表現されてる文字」です。

このように「文字」を定義すると、「PDFからテキストを取り出す」という目標は、「PDFファイルをビューワーで開いたときに表示される絵から、文字を手に入れる」と言い換えられます。 「文字」をもうちょっと狭く、「なんらかの符号化方式でUnicode文字集合のどれかを表してる情報」だとすれば、「PDFファイルからUTF-8のテキストデータを手に入れる」と言ってもかまわないでしょう。

さて、なんでこんな回りくどい話をしてるんでしょうか。 それは、「人間がPDFをビューワーで開いて読む」というのが、コンピューターにとってはそんなに自明な話ではないことを強調したいからです。 人間は通常、PDFを目で読むわけですが、そのときは「文字」、つまりコンピューターで扱えるような表現としての文字を抜き出しているわけではありません。 そして、PDFはもともと人間が目で見るドキュメントのために作られたデータフォーマットなので、そもそもコンピューターで扱えるような文字を取り出しやすいようにはできていないのです。

人間がPDFを読むように、コンピューターでPDFのページに表れる「グリフ」をいい感じに「文字」にする方法があれば、PDFからテキストを取り出せます。 そのための方法としていま利用できるのは、いわゆるOCRくらいでしょう。

ちなみに、PDFビューワーで開いて文字列を選択してコピペする、みたいな方法は、コンピューターでコピペの操作の対象になるのがそもそも「グリフ」ではなく「文字」なので、人間がPDFを読むのとはまったく違う作業です。 PDFからコピペしたら意図した文字とは違う文字しか取れなかった、という経験がある人も多いと思いますが、これはまさにPDFに表れている「グリフ」は「文字」ではないことによります。 ちなみに、フォントが埋め込まれているかどうかはまた別の話で、フォントは埋め込まれているけど意図通りにコピペできない、みたいな例もよくあります。

というわけで人間のようにグリフから文字を取り出すにはOCRするしかないと思うのですが、これには物理装置の認識精度みたいな手元のパソコンで気軽に手を出しにくい要素も絡むので、あまりちゃんと調査してません。 ただOCRかどうかに限らず、「人間のようにPDFのグリフから文字を取り出す」手法は画像解析やAIの応用が進めばかなり未来がある方法だと思うので、いつか取り組みたいと思います。

PDFの仕様について(ちょっと脇道)

ところで、PDFのビューワーといえばAdobeAcrobatなんですが、これはみんなが想像している以上にすごいツールです。 PDFファイルをできるだけ正しく閲覧するには、現時点ではAdobe社の技術に頼るしかありません。

で、いま「PDFファイルをできるだけ正しく」と言いましたが、正しいとか正しくないとか言えるのは、PDFに国際的に標準化された仕様があるからです。 国際標準化機構、つまりISOによる現在の最新のPDFに関する仕様はISO 32000-2:2017で、これはPDF 2.0というバージョン名で呼ばれています。

ただ正直なところ、PDF 2.0に準拠して生成されたPDFはぼくの周りにでは滅多に見かけません。 なにより、ISO 32000-2:2017の仕様書はISOから購入しなければならず、ぼくはこれを購入してないので、PDF 2.0についての話はこれでおしまいです。 以降、PDFといったら、それはPDF 1.x系列に準拠したPDFのことを指します。

そのPDF 1.x系列は、最終バージョンがPDF 1.7で、これはISOの標準化としてはISO 32000-1:2008に相当します。 それより前、つまりPDF 1.6よりも若いバージョンの仕様は、ISOによって標準化されたものではなく、Adobeによるプロプラな仕様でした。

そもそもPDFは、「さまざまなシステムで同じ見た目で表示できるドキュメント形式」を目指してAdobeが開発したファイル形式です。 PostScriptと同じことができる軽量で高速なドキュメント形式として考案されたらしいんですが、いつの間にかPostScriptそのものより多機能になってしまいましたね…。 Acrobat以前の歴史はわりと面白いので、みんなこの記事を読むといいと思います。

ありがたいことに、AdobeではPDFの仕様に対するリファレンスを無償で配布してくれています。 PDF 1.7についてAdobeが配布しているのは、PDFリファレンスの「第6版」です。 1.7なら第7版になりそうなものなんですが、PDFのバージョンを1.2にしたときにAdobeではPDFリファレンスを第2版にせず第1.1版にしたので、それからずっと1ずれてしまっています。

というわけで、現時点のPDFについて何か調べたいと思ったら、ISO 32000:1:2008であるところのPDF 1.7の仕様を網羅したPDFリファレンス第6版にあたることになります。 PDF形式で配布されているので、必要に応じてAcrobatで開いて見てもいいんですが、1310ページもあると紙でめくったほうが目的の情報に素早く行き当たるので、当社ではすべて印刷してパラパラ眺められるようにしています。

f:id:golden-lucky:20191201001415j:plain

明日は「PDFファイルの中身を解析してテキストを抜き出す」について書ければと思います。

プログラミングとは ― 最強のカレーレシピ ―

「うちの学校でもついにプログラミングの授業が始まったよ」

「それは興味深いね。どんなふうに教えてるの? やっぱりScratchとか?」

「Scratch? ああ、プログラミング言語のことか。プログラミング言語は使わなくていいんだよ」

「え?」

「小学校で学ぶプログラミングっていうのは、プログラミング言語を覚えさせることが目的じゃないからね。システム思考力とかロジカルシンキングって聞いたことあるだろ?」

「あるかないかでいったら、あるよ」

プログラミング言語みたいなのは、単なる技術だ。それは仕事で必要な人だけが覚えればいい。子どもたちに教えるべきことは、プログラミング言語みたいな技術じゃなくて、システム思考やロジカルシンキングの延長といえるプログラミング的思考なんだよ」

「プログラミング的思考っていうのが、システム思考やロジカルシンキングとどう違うのか、いまいちよくわからないんだけど…」

「同じさ。何か問題があるとする。その問題を分析し、分解して、手順や繰り返しとして構成する。コンピューターに向かってプログラミング言語で何かするのは、その先の話さ」

「でも、プログラミングというからには、コンピューターで問題を解くことが前提なんだろ? コンピューター抜きでそれが学べるのかい?」

「もちろんさ。たとえばカレーを作るっていう問題を考えてみよう。まず、材料を過不足なく用意する。それから、調理の手順の明文化だ。プログラミングでいえば、さしずめ、フローチャートを書くってところかな」

「ふむふむ。それで、君のとこのプログラミングの授業ではカレーのレシピを説明してるのかい?」

「そういうわけではない。この例で言えば、プログラミング言語っていうのは『レシピを見てカレーを作る方法』に相当するので、それは授業でやらなくてもいいっていう話だよ」

「うーん、じゃあ、君が言うプログラミング的思考っていうのは、カレーのレシピを考えることに相当するの?」

「だいたいそうだね。ただし、これからはIT社会だ。そこで、コンピューターで解けるように問題を捉えられる力として、プログラミング的思考を教えようということになってる」

「コンピューターで解けるように問題を捉える力っていうのは、確かにとても重要だね」

「だろ。でも、そういう力を培うためにプログラミング言語やコンピューターを子どもに教える必要は、ない」

「そこが引っかかるんだ」

「カレーのレシピを見てカレーを作る方法は、あくまでも料理に必要なスキルだ。プログラミング言語やコンピューターの仕組みを覚えることは、職業プログラマーになりたければ必要だが、あいにく小学校というのは職業プログラマーを養成するところじゃない」

「だが、プログラミング言語やコンピューターの仕組みを知らずに、プログラミング的思考を身につけられるのかな」

「コンピューターっていうのは、単純な作業を繰り返したり、指定された手順を忠実に実行したりするのが得意なんだ。そこに与える手順、つまりレシピを考えることこそが、人間のすることさ。もちろん、レシピをコンピューターに与えるときはコンピューターが理解できるようにプログラミング言語を使う必要があるけれど、それはプログラマーがやればいい」

「なるほどね。前半には同意するよ。だが、そのレシピを考えるにしても、コンピューターやプログラミング言語の知識はかなり必要であるように思えるんだが…」

「多少はあったほうがいいだろうねえ。カレーのレシピを考えるにしても、野菜の名前を知らないわけにはいかないし」

「そういうことじゃないよ。じゃあ、君が好きなカレーのレシピの比喩でこう尋ねよう。これはカレーのレシピだと思うかい?」

タンパク質やアミロースを飽和脂肪酸と共に加熱し、クルクミン、塩化ナトリウムなどを添加する

「このレシピでは、ふつうの人にはカレーを作れないと思うぞ」

「なぜ、そう思う?」

「ふつうの台所で使えるのは、野菜や肉、それにカレールーなんかだ」

「それはつまり、レシピの作者は、ふつうの台所の事情くらいは理解する必要があるということだよね」

「ああ、つまり君は、プログラミング的思考にもコンピューターの理解が必要と言いたいわけか」

「そんなところだ」

「さっきも言ったように、ある程度の知識は必要になるだろう。もっとも、そういう、いわば常識の範囲でのコンピューターの知識については、もう何年も前から小学校で教えているけどね」

「常識の範囲、ねえ。じゃあ、もう一つ。これはカレーのレシピだと思うかい?」

* カレー屋さんにいって好きなカレーを注文する

「これじゃあ、カレーを作ることにならないから、レシピとはいえないよ」

「だが、得られる効能は同一だ。つまり、カレーが必要であるという問題は、これで完全に解決される」

「効能が同一ならいいわけじゃないだろ」

「それでもいいと考える力がプログラミング的思考なのかと思ってたんだが、そうじゃないのかい?」

「ふざけるのもいい加減にしろ」

「ふざけているわけじゃない。それなら、最後にもう一つ質問をしよう。これはカレーのレシピだと思うかい?」

* カレーには野菜と肉が入っている
* カレーはご飯にかけると辛くておいしい
* カレーはうどんにかけても辛くておいしい

「なんだいこれは。これは単にカレーの説明じゃないか。こんなのはカレーの作り方ではないよ」

「そう、これはカレーの作り方ではない。だけど、もしカレーをプログラミングするとしたら、こういうふうに書けるかもしれない

「言っていることがよくわからないよ」

「わからないだろうね。コンピューターを使うことを前提に問題をプログラミングで解決するという行為には、たぶん君が思っている以上に、プログラミング言語とコンピューターそのものに対する勉強が必要なんだ…」

✧✧✧

この先は『プログラミングHaskell 第2版』でお楽しみください。

Haskell 解説本 小史

日本語圏におけるHaskellの解説本には、これまで4回の波がありました。 それを思い出しながら、最後に『プログラミングHaskell 第2版』の紹介をします。

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

第1波

Haskell解説本の1つめの波は、2006年、『入門Haskell』と『ふつうのHaskell』が出版された頃にありました。 このうち、『入門Haskell』は(おそらく)日本初のHaskell本です。

『入門Haskell』(2006年) 『ふつうのHaskell』(2006年)

『ふつうのHaskell』は、書名だけを見ると「特殊な言語」であるHaskellを「ふつう」に説明している本であるように思えるのですが、実はそうでもなくて、淡々と部品の説明をしていく感じの内容です。 そのぶん例題はあまりなくて、最後にWikiエンジンをがっと作ってみるという構成でした(当時はWikiエンジンを作るのが流行っていたのです)。

一方、『入門Haskell』のほうは、冒頭でまず wc コマンドを実装してHaskellプログラミングの全体像をみせたり、最後にはちょっとしたゲームを開発したり、わりと教育的な内容の本でした。 Haskellを多少は使えるようになった今になって考えると、こういう構成で説明できること自体が、とってもHaskellらしさを伝えている本だったなと思います。ぼくはこの本でHaskellに最初にふれました。

第2波

2つめの波は、2009年ごろ、『Real World Haskell』と『プログラミングHaskell』が出版されたタイミングでしょう。

『Real World Haskell』(2009年) 『プログラミングHaskell』(2009年)

『Real World Haskell』は、「実戦」というキャッチコピーが示すように、仕事や実務でHaskellを使うための情報が詰め込まれた本です。 画像解析をしたり、FFI経由で正規表現のCライブラリを使ったり(当時のHaskellには十分な正規表現のサポートがなかった)、ParsecでCSVのパーザを作ったり、ネットワークプログラミングをしたり、分散並行プログラミングをしたりします。 全部は読んでなかったんですが、いま目次を見たところ、STMの話とかもすでに載ってたらしいので、あらためて読み返してみたくなりました。

一方の『プログラミングHaskell』は、『Real World Haskell』の対局にあるような本で、Haskellを通して「プログラミングそのもの」が学べます。 「プログラミングそのもの」ってなんだよって話ですが、この本で学ぶのは、いわゆる関数プログラミングというやつです。 「(変数への代入ではなく)関数の組み合わせで高度なプログラムの全体を構築していく方法をHaskellというプログラミング言語で解説していく」ことが、『プログラミングHaskell』という本の主眼でした。

この『プログラミングHaskell』の改訂が出たというのが、この記事で唯一記憶して頂きたい話です。

第3波

3つめの波は、『すごいHaskellたのしく学ぼう』と『関数プログラミング入門 Haskellで学ぶ原理と技法』が出版された2012年です。

『すごいHaskellたのしく学ぼう』(2012年) 『関数プログラミング入門 Haskellで学ぶ原理と技法』(2012年)

『すごいHaskellたのしく学ぼう』は、おそらくこの記事を読んでいる人全員が少なくとも書名だけは知っている本だと思います。 へたうまなイラストに目がいきがちですが、「Haskellの解説手法」という観点でもひとつの金字塔を打ち立てた本で、その意味でも「すごい」本でした。

具体的には、関手(ファンクター)→アプリカティブ→モナドという解説の流れは、この本が作り出しました。 この説明の流れは、「同じパターンで定義できる関数」をHaskellでどのようにまとめるかについて、『Real World Haskell』のころには知られていなかった知見を反映したものです。

これ以降に出版された解説書では、この流れを意識するか踏襲するかして、モナドが解説されていると思います。 それ以前はどうだったかというと、アプリカティブを経由せずにモナドへと一気に直登するハードモードが一般的でした。 実際、同じ年に翻訳は出たものの原書が出版されたのは1998年である『関数プログラミング入門 Haskellで学ぶ原理と技法』は、このハードモードです。 そのころにはまだアプリカティブが発見されてなかった(よく知られてなかった?)からしかたありません。

では『関数プログラミング入門 Haskellで学ぶ原理と技法』には今では価値がないのかというと、そんなことはなくて、等式変形によって関数を作り出す「運算」の方法を学べます。 この運算、より応用的なHaskell本である『関数プログラミングの楽しみ』や『関数プログラミング 珠玉のアルゴリズムデザイン』では大活躍します。 ただ、次に紹介する第4波において本書の改訂版の翻訳にあたる『Haskellによる関数プログラミングの思考法』が出ているので、いまならそちらを読むほうが楽かもしれません。

第4波

第4の波は、2017年前後のバラエティー豊かなHaskell解説書の登場です。 あまりにバラエティーが豊かなうえに、時期的にも隔たりがあるので、全部ひっくるめるのはちょっと乱暴という自認はあるんですが、とりあえず並べます。

『関数プログラミング実践入門 増補版』(2016年、最初の版は2014年) 『Haskell 教養としての関数型プログラミング』(2017年) 『Haskell入門 関数型プログラミング言語の基礎と実践』(2017年) 『Haskellによる関数プログラミングの思考法』(2017年)

関数プログラミング実践入門』は、実際にはHaskellに限定した本ではありません。 ただ、他の言語におけるモナドの話とかも入ってるのでむしろお得だし、Haskellの実務的な側面に対する解説に価値がありました。 個人的には、GHCを使って開発するときに知りたかったことが書いてあって、とてもうれしい本でした。

追記:2014年に最初の版が出ている『関数プログラミング実践入門』がこの第4波に含まれているのは、ぼくが最初の版を2016年だと勘違いしていたからです。当然、記事も間違っていたのですが、同書の著者である大川さんの指摘で上記の表も修正してあります。

Haskellによる関数プログラミングの思考法』は、Haskellそのものですが、実務的なプログラミングではなく、運算の技法を通して関数プログラミングの何たるかを説明する本です。

Haskell 教養としての関数型プログラミング』と『Haskell入門 関数型プログラミング言語の基礎と実践』については、 ちゃんと読んでいないので内容については何も言えないのですが、 目次を見る限り関手→アプリカティブ→モナドにそってHaskellをひととおり理解できることが目指されているのだろうと感じています。 特に『Haskell入門』のほうは、実務でよく見かける例題が豊富にあるようで、今では古くなってしまった『Real World Haskell』の位置を埋めてくれる本のように思います。

『プログラミングHaskell』が改訂されます

ここからは宣伝です。 第2波で紹介した『プログラミングHaskell』は、原書は2016年に改訂されていたのですが、これがついに翻訳されて発売が開始されました!

www.lambdanote.com

上記では、旧版である『プログラミングHaskell』について、こんなふうに要約してました。

「(変数への代入ではなく)関数の組み合わせで高度なプログラムの全体を構築していく方法をHaskellというプログラミング言語で解説していく」ことが、『プログラミングHaskell』という本の主眼でした。

第2版でも、この主眼はまったく変化していません。 (関数)プログラミングそのものを学ぶための教材としてのHaskellの解説書というコンセプトはそのままに、 Haskellのコードの見た目が数式による表現から等幅フォントによる表現に変わったり、 解説に使われているHaskellの実装がHugsといういまでは滅多に見かけない処理系からGHCになったりしています。 もっとも、処理系に依存した話はもともとあまりないし、これらはあくまでも表面的な変化だといえます。

では、第2版は旧版から表面的な改修を施したものにすぎないのでしょうか?

もちろんそんなことはありません。 第2版で個人的にもっとも進化したなと感じてるのは、型クラスに基づいて諸概念が整理された結果、 「型を使ってプログラミングするとはどういうことか」をより実感できるようになった点だと思っています。

第2版ではプログラミングにおける型の理解が深まると思う

個人的に、Haskellの型って、大きく分けて以下の2種類の用途で使ってるような気がするんですよね (ぼくがHaskellで何かを書いてるときにこう考えてる、という話なので、ピントがずれてる可能性はあります)。

  • データとしての型
  • 定義が同じような形をしている関数を、同じ仲間だと思って扱うための道具としての型(型クラス)

前者は、いわゆる代数的データ型として、Haskellらしいプログラミングのスタイルの根っこにあるやつです。 解きたい問題を「型」でとらえて、その型の上でパターンマッチとかしまくって処理を宣言的に書いていくというのが、ぼくのなかではHaskellらしいプログラミングです。 型のこういう側面については、もちろん初版でも強調されて説明されてたんですが、この第2版では前半を上回る物量でそれを体感できます。 ここで物量と言っているのは、単なるページ数の増加ではなく、例題の増強です。 にもかかわらず、説明のスタイルも、この間の他書における解説の進化に影響されてか、全体にかなりすっきりとした姿になっています。

後者における型については、旧版ではまったくと言っていいほど説明には出てきてませんでした。 実際のところ、Haskellで書き捨てのプログラムを書いているようなときは、あまりこういう観点で型を使う機会はない気がします。 定義の形が同じものを同じように扱うというとLispのマクロが思い浮かびますが、 Lispのマクロも、最初から「よしマクロだ」と意気込んで導入することは少なくて、 なんか関数をいくつか書いてみたら同じような形が現れるのでマクロにできるな、みたいな感じで導入することが多いと思うんですよね。 もちろん、イディオム的に最初からマクロが使えそうだと判明しているケースはあるし、人によってはマクロでプログラムを考えられるのかもしれないので、あくまでも個人の経験に基づく感想です。

で、話を戻すと、Haskellではそういう「なんか同じようなパターンだからまとめたい」も型ベースで扱います(Lispのマクロと背景でなにか繋がってたりするんですかね?)。 とはいえ、すでに触れたように、ぼくみたいなゆるふわなHaskellユーザーが自分でそういうパターンの抽象化を考える必要に迫られる機会はあまりありません。 なにせ型ベースなので、Lispのマクロより数学的な制約があって、そのためモナド則みたいなのを考えないといけないし。

でも、いまのHaskellって、アプリカティブとかFoldableとかTraversableみたいな、型ベースで抽象化済みの仕掛けが標準で入ってるんです。 なので、わりと単純なプログラムでも、ほかの人がそういうやり方で抽象化してくれた土台に乗っかって自分のプログラムを書くことになります。 だから、少なくとも抽象化のノリを理解できていないと、Hoogleで関数を検索するのも難しくなってしまった。

そんなわけで、この第2版でも、モナドの説明がアプリカティブ経由になったり、Foldable/Traversableを説明に取り込んだり、そういうのをしっかりとやっています。 説明を表面的に増やしただけでないので、結果として型の後者の側面にも光が当たることになり、型についての理解が旧版よりも深まってしまう、そんな一冊になってるような気がしています。

ここで買えます

プログラミングHaskell 第2版(紙書籍+電子書籍)www.lambdanote.com

以下はラムダノートのお知らせページからの引用。

「紙書籍+電子書籍」版のお求めでも、「電子書籍のみ」版のお求めでも、いますぐPDFのダウンロードが可能です。紙書籍については、8月22日(日)以降の発送開始を予定しています。書店(オンライン書店を含む)での紙書籍の発売も8月22日以降を予定しております。

なお、直販サイトでのお求めにあたってユーザ登録などは不要です(ただ、ユーザ登録をしていただくと、購入済み電子書籍の再ダウンロードが簡単にできたり、ときどき思いがけない特典があったりします)。

『n月刊ラムダノート Vol.1, No.2』を読むべき1つめの理由

『n月刊ラムダノート』の話をいろいろしたいのだけど、どこから話せばいいのかわからないので、Lispの話をします。

昔、といってもほんの10年ちょっと前のことですが、日本でLispが流行った時期がありました。 「プログラミング言語のパワーには絶対的な差が存在する。その頂点に立つのがLispだ」と言って憚らない『ハッカーと画家』という本が2005年に出版され、それを読んだ多くの人が「よろしい、ならばLisp」と思ったのです。

まあ、ほかにもいろいろな理由があったのだろうし、流行に関係なくLispを使い続けている人はたくさんいたし、いまでもぼくを含め多くの人がLispを日常的に使っているけれど、『ハッカーと画家』の影響によるちょっとしたLispブーム、というのは確かに起きていたと思います。

で、この『ハッカーと画家』を翻訳したのが川合史朗さんでした。 その当時、ぼくは同書を企画した部署にたまたまいて、その制作の様子をちょっと横で見てたりもしたんですが、川合さんはハワイ在住ということもあって面識があるわけでもなく、同書の訳者としての川合さんは実はよく知りません。

ぼくにとって川合さんは、むしろGaucheの作者としての川合さんです。 Gaucheというのは、Lispの二大巨頭のひとつであるSchemeの実装です。LispとかSchemeとかを抜きにしても、日本語の処理が楽にできた高機能なスクリプト言語でした。 すべてがUTF-8になった現在、たいていの言語で日本語の扱いに難儀することはあまりないですが、そのころは日本語に対して何も考えずに正規表現を使えるというだけでGaucheは魅力的なプログラミング環境だったのです。 もちろんPerlRubyはあり、それらを仕事や趣味に使ってはいたんですが、Gaucheの存在を知って使ってみたところ、「S式はいろいろ快適」という事実を思い知ったのでした。

「S式はいろいろ快適」には、もうすこし補足が必要かもしれない。

ぼくの仕事は書籍の編集者なんですが、書籍の編集者というのはXMLをよく扱います。 XMLの構造を扱うのに、ふつうはXSLTというプログラミング言語を使うのですが、ぼくはこれがあまり好きじゃなかった。 XMLは、要するに木構造なので、そのままS式に見えるし、それならS式として扱いたいわけです。 実際、XMLのS式表現として、SXMLというものがあります。 ありがたいことに、GaucheにはSXMLのためのツール群もまるっと用意されていました。

というわけで、ぼくが日常的に使うプログラミング言語はもっぱらGaucheという状態になり、自然とGaucheを中心としたコミュニティにもどきどき顔を出すようになったりしました。Gauche Nightで発表したのは本当に素晴らしい思い出です。 Gaucheのコミュニティは、そのまま他のLisp系言語のコミュニティや関数型言語、特にHaskellのコミュニティなんかとも地続きで、結果として本当にたくさんのすごい人たちに出会うことができました。

いまも、大半のやっつけスクリプトにはGaucheを使います。 そこそこ大きめのプログラムを書くときはHaskellを使うし、ぶっちゃけXMLをいじるのもS式よりArrowのほうが脳が楽なことに気が付いたけれど、それでもプログラムを考えるときは脳内でまずS式をこねこねする癖は抜けないので、自分にとっての母国語は完全にLispであり、Schemeであり、Gaucheであり、その生みの親かつ育ての親こそが川合さんなわけです。

その川合さんが、Lispの記事を、自分がやってる出版社の逐次刊行物で書いてくれました。やったね!

n月刊ラムダノート Vol.1, No.2(2019)(紙書籍+PDF版)www.lambdanote.com

f:id:golden-lucky:20190726123033j:plain

しかも、まさかの「LISP 1.5」です。

Lispは、プログラミング言語としては歴史が古いので、現代的なコンピューターでプログラミングしているとあまり意識しなくて済むような概念や、いまでは別の考え方で把握され解決されているような困難にまつわる話がわりとちょくちょく出てきます。 そうした古の概念であるとか、過去の偉人たちが困難に対峙してきた物語もまた、Lispの魅力のひとつだったりします。

歴史の話、楽しいじゃないですか。 いまぼくらが入門書とか教科書という形で整理された内容を学べるのは素敵なことだけど、なぜ教科書にあるような形で内容が整理されており、それをぼくらが学ぶ必要があるのか、根っこを知ってはじめて見えてくる景色もあるわけです。

そう考えると、Lispのパワーっていうのは、そうやって理論と実践の縁をいったり来たりしながら、後代のプログラマーたちが発見することを別の形ですでに体得しちゃってるかもしれない側面なのかもしれない。 エモい話になりそうなのでこの辺でやめますが、そんなわけで、Lispをちょっとでもかんじると、今のプログラミングにおいて当たり前だったり、理論的には別の手段で解消されたりしている昔の話に、わりとよくぶつかります。

ここで、ぼくのようなLispと同時代を生きてきていない素人にとって辛いのが、そういうときに自力で原典に立ち返ろうと思っても話の文脈がいまいちよく見えないことです。 論文などに書いてある記述を素直に読み、文脈を補完する努力をすればいいのだけど、そこまでの余裕がなかったりするので、「話だけは聞いたことある」みたいなキーワードだけが脳内に積まれていきます。 「LISP 1.5」は、そんな「話だけは聞いたことある」というLispなキーワードのなかでも、かなり上位にあるやつでした。 (勝手な想像ですが、ぼくみたいな「ゆるふわLisper」はほかにもわりと多いような気がします。)

Lispをやっていないとわからないかもなんですが、「LISP 1.5」、Lisperをやっていると、本当に名前だけは耳にするんです。 そもそもLispを発明したのはジョン・マッカーシーで、彼は人工知能研究の一環としてリスト処理のための言語を考案したけど、最初は計算機で動かすプログラミング言語を考えているつもりはなかったらしい。 これをスティーブ・ラッセルが計算機上で実行できるように実装し、それを改良していろいろがんばったのが「LISP 1.5」だったらしい。 「らしい」ばかりなのは、ほんとよく耳にするけど少なくともぼくにとって「LISP 1.5」は文字通り「伝説」の存在だったからです。

ちょっと話はそれますが、Schemerをやっていても、やはり似たような感覚におそわれるキーワードがあります。 そのひとつは、「funarg問題」っていうやつです。 funargは、「function」と「argument」の造語なんですが、たぶん「ふなーぐ」と読みます。 これは要するに、関数の引数に関数を渡すと仮引数が衝突してしまうという話で、 Schemeはこの「funarg問題」を根本的に解消したLispであるという触れ込みがあることから、Schemeを勉強すると必ずといっていいほど目にすることになります。 しかし、プログラミング言語の教科書だと束縛変数の名前を暗に付け替えることで回避してるし、 関数プログラミングをしていても実際にハマることはほとんどないので、なんでSchemeがそこまで「funarg問題に煩わされない」ことを喧伝しているのかピンとこない。

さらに話はそれますが、プログラミング言語を独習したり人に教えたりしてると、「シンボル」が何なのかわからなくならないですか? 文字列みたいに使えるけど書き換え不能なやつ、みたいな理解でいいと思うんですが、これ、言語の処理系ではどうやって実現されてるんでしょうか。 いま、何か言語を作るなら、ルックアップテーブルを使ってシンボルを実現すると思います。 しかし、コンピューター上でシンボルを表現する方法はほかにないんでしょうか。

川合さんによる『LISP 1.5の風景』は、ぼくにとって、これらのモヤモヤをスッキリさせてくれる記事でした。

  • LISP 1.5が伝説たるゆえんは、「データ構造としてリストを使い、その操作と条件分岐と関数適用の仕組みを用意すれば、自分自身の評価が可能な評価器を作れる」をコンピュータで実践したことにあった!
  • funarg問題はLISP 1.5でも解消されていたけど、Schemeでは確かに根本的に解消されていた!
  • シンボルはルックアップテーブルを使わなくても実現できて、それもなんかうまい感じのデータ構造だけで作り出せてしまう!

ほかにも、こんな文字通りの風景が見えてくるようで、読んでいるだけで椅子から転げ落ちそうになります。

  • 初期のLispカッコを使わない表現、つまりS式ではなくM式という「いまのプログラミング言語に近い見ため」だったこと
  • ということは、「LispでつくるLisp」が容易なのはLispシンタックスがデータ構造と同じだからというわけではなく(おかげでパーズは楽だけど)、ラムダ抽象と関数適用を濃縮した言語であることによるのかもしれない
  • というか、むしろ「LispでつくるLisp」ネタから出発してラムダ抽象と関数適用を濃縮し、それをコンピューターで動かすときの問題をひとつずつ克服していった一つの形が「LISP 1.5」だったのではないか

しかも、川合さんのおかげで、今のコンピューターで動かすとちゃんと動くようになっている。手を動かしながら、こういう「なるほどー」を味わえるようになっています。

github.com

そんな川合さんの「LISP 1.5の風景」が掲載されている『n月刊ラムダノート Vol.1, No.2』は、人工知能の代名詞の座をLispから奪ったディープラーニングと、これからその座を奪うかもしれない量子コンピュータの記事が一緒に読めて1500円。ぜひ読んでみてください。

n月刊ラムダノート Vol.1, No.2(2019)(紙書籍+PDF版)www.lambdanote.com

f:id:golden-lucky:20190726123857j:plainf:id:golden-lucky:20190726123909j:plain

購入は(いまのところ)直販サイト限定ですが、ユーザー登録はとくに不要ですし、クレジットカード番号や住所をうちのサイトに直接入力しなくてもAmazon PayやGoogle Payでさくっと通りすがりに購入できます。

www.lambdanote.com