こんにちは、Legalscape コンテンツバリューチームでエンジニアをしている清水です。
Legalscape では書籍の PDF を大量に扱っており、指定したページを切り出して返す PDF 抽出のエンドポイントを Node.js のバックエンドで動かしています。
ここで使っているのが pdf-lib です。
ある入稿書籍で、この抽出処理が落ちる不具合が起きました。
エラーメッセージだけを見ると pdf-lib のバグに見えます。
しかし調べていくと、原因は別のところにありました。
この記事では、その調査の過程をたどります。
起きたエラー
抽出エンドポイントが、特定の書籍でだけ次のエラーを出していました。
_this.catalog.Pages is not a function
TypeError: _this.catalog.Pages is not a function
at PDFDocument.getPages ...
at PDFDocument.copyPages ...
pdf-lib で別 PDF にページをコピーする copyPages() の内部で、catalog.Pages() というメソッドが「関数ではない」と言われています。
PDF ファイルは、辞書オブジェクト(キーと値の集まり)がツリー状に参照し合った構造をしていて、その頂点にあるのがカタログ(catalog dictionary)です。
カタログから /Pages(ページツリー)を辿るのが Pages() です。
今回は _this.catalog がカタログとして認識されておらず、Pages() を呼べずに失敗していました。
原因はカタログの /Type 欠落
PDF ファイルの末尾には trailer と呼ばれる領域があり、PDF リーダーはここを起点にファイルを読み始めます。
この trailer にある /Root エントリが指す辞書を、リーダーはカタログとして読み込みます。
ISO 32000-1:2008 の 7.7.2 節(Document Catalog)にこう書かれています。
The root of a document's object hierarchy is the catalog dictionary, located by means of the Root entry in the trailer of the PDF file.
このカタログ辞書には、本来 /Type /Catalog という一行が含まれます。
<< /Type /Catalog /Pages 2 0 R ... >>
/Type は、その辞書がどの種類のオブジェクトなのかを表すエントリです。
カタログ辞書ならその値は Catalog でなければなりません。
そしてこのエントリは、仕様上 Required(必須)と定められています。
ISO 32000-1:2008 の Table 28(カタログ辞書のエントリ一覧)は、/Type をこう定義しています。
(Required) The type of PDF object that this dictionary describes; shall be Catalog for the catalog dictionary.
壊れた PDF のカタログ辞書を覗くと、この /Type /Catalog という一行がありませんでした。
生成元は、ある組版ソフトでした。
Required と定められたエントリが欠けているので、これは仕様違反です。
ところがこの PDF は、Legalscape の閲覧側では問題なく表示できていました。
落ちるのは pdf-lib だけです。
仕様違反なのは PDF 側なのに、なぜブラウザのビューアなど他のリーダーでは普通に開けるのでしょうか。
/Root と /Type という 2 つの経路
「あるオブジェクトがカタログである」という事実は、PDF では 2 つの経路で表現されています。
一つは trailer の /Root です。
これは「◯番のオブジェクトがカタログだ」と外側から指し示す参照です。
もう一つは、カタログ辞書自身が持つ /Type /Catalog です。
これは「この辞書はカタログである」と内側から宣言するエントリです。
trailer << /Root 1 0 R ← 1 番のオブジェクトがカタログ(外からの参照) >> 1 0 obj ← /Root が指す先 << /Type /Catalog ← この辞書はカタログ(内側の宣言) /Pages 2 0 R >> endobj
仕様どおりの PDF なら、この 2 つは必ず一致します。
裏を返すと、経路が 2 つあるので、片方が欠けてももう片方だけでカタログだと分かる、という冗長性があります。
今回の壊れた PDF は、この片方が欠けた状態でした。
/Root は正しくそのオブジェクトを指しているのに、指された先に /Type /Catalog という宣言が無いのです。
今回はカタログの判定をどちらの経路に頼るかによって挙動が変わっているようだと推測しました。
pdf-lib は /Type を先に読む
pdf-lib がカタログを特定する処理は、読み込み時と利用時の 2 つのタイミングに分かれています。
そして、この 2 つがうまくつながっていません。
① 読み込み時に /Type からクラスを決める
pdf-lib は辞書を読み込んだ瞬間に、その辞書自身の /Type だけを見て、対応する JS のクラスを選びます。
/Type /Catalog なら PDFCatalog、/Type /Pages なら PDFPageTree、どれでもなければ素の PDFDict です。
この時点では、その辞書が trailer の /Root から指されているかどうかをまだ見ていません。
② 利用時に /Root からカタログを引く
その後、PDFDocument のコンストラクタが次の代入を行います。
this.catalog = context.lookup(context.trailerInfo.Root)
ここでは、/Root という外からの参照によって、その先がカタログだと分かっています。
ところが pdf-lib は、①で選んだクラスを検証も作り直しもせず、TypeScript のキャストで PDFCatalog とみなすだけです。
実行時には①で作られた素の PDFDict がそのまま入ります。
そして後で catalog.Pages()(PDFCatalog にしか無いメソッド)を呼んだ瞬間に TypeError になります。
②で /Root の指す先がカタログだと判明しているのに、それを使って PDFCatalog として扱うようにするフォールバックが入っていない、という実装の問題です。
言い換えると、pdf-lib は /Type を先に信じてしまい、後から判明する /Root で訂正できません。
寛容なリーダーは /Root から解決する
一方、pdf.js(有名なブラウザのPDFビューア)や qpdf は、/Root の側から解決します。
カタログとは /Root が指すオブジェクトのことだと定め、その中の /Pages を単なる辞書のキー参照として読むので、/Type を見る必要がありません。
だから /Type /Catalog が欠けていても動きます。
カタログとして扱うワークアラウンド
読み込み直後に、trailer の /Root が指す辞書を特定し、それが PDFCatalog でなければ /Type /Catalog を補って作り直し、差し替えます。
正しい /Root を使って、欠けている /Type を後から埋める操作にあたります。
この回避策で、実際の壊れた書籍を抽出し、その結果を pdf.js で開けるところまで確認できました。
そもそも PDF を直せないのか
ここまではコード側の対応の話です。
そもそも壊れた PDF は仕様に準拠していないと言えます。
元データを直せば、コード変更なしで解決できるのではないでしょうか。
プログラムでの修復(カタログに /Type /Catalog を補って保存し直す)は問題なくできました。
では手作業ならどうか。
Acrobat で開いて保存し直せば直るのか。
ここが今回の調査でいちばん意外だったところです。
| 操作 | 結果 |
|---|---|
| 名前を付けて保存 | /Type なしのまま |
| 再保存(Web 表示用の最適化あり) | /Type なしのまま |
| プリフライトで PDF/A-2b 変換 | OutputIntents は足すが /Type なしのまま |
Acrobat は、どの操作をしても /Type を補完しませんでした。
カタログ辞書のキーを元のまま温存し、規格変換をかけても直しません。
これは Adobe が手を抜いているのではなく、思想が一貫していると見るべきでしょう。
Acrobat も内部的にはカタログを trailer の /Root から解決していて、/Type エントリを必須と扱っていない、と考えれば辻褄が合います。
それなら表示も変換も正常に動きますし、書き出すときも作者が書いたデータとしてそのまま写すはずです。
組版ソフトが /Type を省いた出力をしても問題として認識されにくいのも、根は同じです。
主要なリーダーが /Root から解決する限り、この欠落はどこにも表面化しません。
この欠落で唯一困っていたのが、/Type を先に読む pdf-lib でした。
さいごに
ライブラリのバグに見えたものは、PDF 仕様の必須エントリの欠落と、/Type を先に読む pdf-lib の設計が噛み合った結果でした。
「他のツールで開けるのだから、正しいファイルのはずだ」という直感は、PDF に関してはあてになりません。
閲覧できることと仕様に準拠していることは別物で、そのギャップは寛容な処理系によって普段は隠されており、 /Type を先に読むライブラリを使ったときに、初めて表面化します。
PDF を扱う実装で似たエラーに出会った方の参考になれば幸いです。
さいごに
Legalscapeではエンジニア全方面で募集中です。ドキュメント処理やAIを用いた開発に興味がある方、弊社に興味が湧いた方がいればぜひお気軽にご連絡ください。