PDFの中身を仕様を片手に読んでみる

こんにちは。Engineering Managerの小林です。 Legalscapeでは各種コンテンツの利活用性を高めるために、すべてのコンテンツをHTMLにする取り組みをしており、これを構造化と呼んでいます。 構造化の対象はWebコンテンツに限らず書籍にも及ぶため、PDFもHTMLに変換しており、PDFデータの理解は必須と言えます。 今回は、シンプルなPDFの中身を仕様を参照しつつ解説していこうと思います。

使用する資料

PDFはChatGPTに用意してもらいました。 chatgpt.com

PDFの仕様は以下から閲覧できます。 https://opensource.adobe.com/dc-acrobat-sdk-docs/pdflsdk/index.html#pdf-reference

生成されたPDFはバージョンが1.4なので、1.4のものを参考に解説していきます。

中身を見てみる

それでは中身を見ていきましょう。 こんな感じです

%PDF-1.4
%<93><8C><8B><9E>  ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<

>> 
  /Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20251110125310+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20251110125310+00'00') /Producer (ReportLab PDF Library - www.reportlab.com) 
  /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 187
>>
stream
GasJK]a=fq&;9pC`KY*L1V$b2[Xqss,:rTirdj,imbY]t'BBK\OF_7lp7#4%WEA>c#PNc:Oe@Il_-dB+N.[MFnEWcqFtk`p29@o:7(q'WX6]"]Pe\0:kIgp[Hmh8;(#8*HaE^lThN1H_?W9Z!FOm]k-",+3]A7;udm.8LIAs=XY/?Lm57M!a$;EJ-~>endstream
endobj
xref
0 9
0000000000 65535 f 
0000000073 00000 n 
0000000114 00000 n 
0000000221 00000 n 
0000000333 00000 n 
0000000536 00000 n 
0000000604 00000 n 
0000000887 00000 n 
0000000946 00000 n 
trailer
<<
/ID 
[<194d1d3ead7ea96ee36274e424444d6d><194d1d3ead7ea96ee36274e424444d6d>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)

/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1223
%%EOF

解説

%

文字列やstream(後述)外の%はコメントを表し行末までがコメントとなります。 typescriptなどの // と同じですね。

ヘッダ

最初の2行は%から始まっているのでただのコメントかと思いきや、これらはヘッダです。 shebang に似ていますね。 仕様の「3.4.1 File Header」が該当します。

%PDF-1.4
% ReportLab Generated PDF document http://www.reportlab.com

1行目は見ての通り、バージョンを記載しています。 2行目は、「PDFがバイナリデータを含む場合には、文字コードが128以上のバイナリ文字からなる文字列を含むコメントを、ヘッダ行の直後に置くことを推奨する」とされており、ファイル転送ソフトがテキストかバイナリかを識別するのに役立つそうです。

Indirect Objects

3行目に早速よくわからないのが登場しましたね。

1 0 obj

これはIndirect Objectsというもので、Objectsに名前を付けて後で別の箇所から参照するためのものです。 ObjectsというのはBoolean valuesやStringsなどで構成される要素で、プログラミング言語でいうprimitive valuesのようなものです。 Indirect Objectsは変数みたいなものですね。 仕様書では「3.2.9 Indirect Objects」で定義されています。

Indirect Objectsは

${object number} ${generation number} obj
  ${body}
endobj

という形式で記述されます。object numberとgeneration numberが対になって唯一のindirect objectを指します。 generation numberの詳細は割愛しますが、PDFを差分更新するときに既存のindirect objectを書き換えず上書きするような用途で使用します。

Indirect Objectsの中身を参照するには、例えばobject numberが12、generation numberが0のものの場合は

12 0 R

と記述します。(後ほど)

1 0 obj

では、実際に3行目から始まるIndirect Objectでは何を定義しているのでしょうか。

1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>

ここで、<< ... >> はDictionary Objectsを、 スラッシュから始まる文字列はNaming Objectsを表します。 Naming Objectsとは、typescriptで言うSymbolのようなものです。 つまり、 1 0 obj の中身をtypescriptで表すと

const obj_1_0 = {
  [Symbol("F1")]: "2 0 R",
  [Symbol("F2")]: "3 0 R"
}

みたいなイメージです。 (keyのSymbolをそのまま表記すると見づらいので、以下では便宜的に普通のstringで表現します。)

ここで、'2 0 R'、 '3 0 R' は先ほど説明したようにIndirect Objectsを参照するための記法です。なので、より正確には

const obj_1_0 = {
  F1: getObject(2, 0),
  F2: getObject(3, 0),
}

みたいなイメージですね。

2 0 R3 0 R

では、 2 0 R は何なんでしょうか? 2 0 obj を探せば良いですね。

2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj

ここでもDicitionary Objectsを定義しています。内容はパッと見、フォントに関係していそうです。 Dictionary Objectsは慣習的にTypeエントリ(とSubTypeエントリ)でそのDictionaryの種類を明示します。 今回は、 Typeが /Font、 SubTypeが /Type1 となっています。 PDFでは様々なTypeのフォント形式を扱うことができるのですが、ここではType1というフォント形式を使うと宣言しているわけです。 他に使用できるフォント形式にはTrueTypeフォントなどがあります。

Type1 Fontsについては「5.5.1 Type 1 Fonts」で定義されています。 Type1 FontsではBaseFontがrequiredとなっており、ここでは Halvetica がセットされています。 HalveticaはPDFでは重要なフォントであり、standard fontsと呼ばれる14個のうちの一つです。 standard fontsは、そのフォント自身または適切な代替フォントをビューアアプリケーションで利用可能であることが保証されており、つまりはフォント埋め込みを行わなくても文字化けしないことが保証されます。

Nameエントリは obsolescent だそうで、あまり気にしなくて良さそうです。

3 0 obj はBaseFontが Halvetica-bold に変わっているだけですね。

4 0 obj

1~3までのIndirect Objectsが分かったところで、4 0 objに進んでみましょう。 またDictionary Objectsですが、今度はTypeエントリが Page となっています。 Pageは「3.6.2 Page Tree」で定義されます。

4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<

>> 
  /Type /Page
>>
endobj

typescriptで表すとこうです。

const obj_4_0 = {
  Contents: getObject(8, 0),
  MediaBox: [0, 0, 595.2756, 841.8898 ],
  Parent: getObject(7, 0),
  Resources: {
    Font: getObject(1, 0),
    ProcSet: [Symbol("PDF"), Symbol("Text"), Symbol("ImageB"), Symbol("ImageC"), Symbol("ImageI")],]
  },
  Rotate: 0,
  Trans: {},
  Type: Symbol("Page"),
}

Parent、Resources.ProcSet、Trans、Rotateについては今回は割愛します。

MediaBoxはページの座標を表し、[左 下 右 上]という順番で指定します。 ここでお気づきの方も多いと思いますが、PDFは左下が原点で右上に伸びていく座標系となっています。 Web系の頭ではちょっと扱いづらいです。

Resources.Fontは描画に使用するフォント辞書を指定します。 1 0 R の中身は先ほど見ましたね。F1,F2というエントリが存在していました。 このページ内でF1というフォントを使用するという命令があれば、Helveticaを使用するということです。

さて、本命の Contents エントリです。きっと内容の描画に関する情報が入っていることでしょう。

8 0 obj

8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 187
>>
stream
GasJK]a=fq&;9pC`KY*L1V$b2[Xqss,:rTirdj,imbY]t'BBK\OF_7lp7#4%WEA>c#PNc:Oe@Il_-dB+N.[MFnEWcqFtk`p29@o:7(q'WX6]"]Pe\0:kIgp[Hmh8;(#8*HaE^lThN1H_?W9Z!FOm]k-",+3]A7;udm.8LIAs=XY/?Lm57M!a$;EJ-~>endstream
endobj

<< ... >> のあたりは先ほどまで見ていた通りですが、今回のobjはstreamなるものが存在しますね。 実はこれはDictionary ObjectsではなくStream Objectsというものになります。 Stream Objectsはバイト列を表現するためのObjectsで、Dictionary Objectsに見える部分はそのプロパティを指定しているというわけです。 今回は長さが187で、ASCII85DecodeとFlateDecodeというフィルタを使用しろというプロパティが設定されています。

フィルタは「3.3 Filters」で定義されます。 名前から処理は想像がつくことと思うので、フィルタの中身は説明せず、streamにこのフィルタを適用するとどうなるかを見ていきましょう。

1 0 0 1 0 0 cm
BT /F1 12 Tf 14.4 TL ET

q
1 0 0 1 76.86614 737.0236 cm
  q
  0 0 0 rg
  BT 1 0 0 1 0 4 Tm /F2 24 Tf 28 TL (Hello) Tj T* ET
  Q
Q

q
  1 0 0 1 76.86614 710.0157 cm
Q

q
  1 0 0 1 76.86614 689.0157 cm
  q
    0 0 0 rg
    BT 1 0 0 1 0 4 Tm /F1 11 Tf 15 TL (World) Tj T* ET
  Q
Q

(読みやすく整形してあります)

これがズバリPDFが文字を描画するための命令列です。 詳しい内容はまた次回のネタにするとしまして、ここでどういう処理が行われるかを簡単に書くと

  1. 変換行列(拡大、回転、移動)、フォントのリセット
  2. (x, y) = (76.86614, 737.0236) の点に移動
  3. Helloを出力、フォントはF2(Halvetica-bold)、フォントサイズは24
  4. (意味のない処理)
  5. (x, y) = (76.86614, 689.0157) の点に移動
  6. Worldを出力、フォントはF1(Halvetica)、フォントサイズは11

といった感じです。

まとめ

PDFの描画はこのように移動して文字を出力ということを繰り返して達成されます。 段落や見出しの概念はなく、構造化データからは程遠いものとなっています。 OCRを活用することで、典型的な形式の文献であれば高精度で構造化されたデータに変換可能ですが、多段組などのちょっと特殊なレイアウトになると精度が下がったりしがちです。 Legalscapeでは、様々な技術を組み合わせてこのような問題に対処し構造化データを作っています。

告知

2025年12月9日(火)に、弊社のオフィスにて交流イベントLegalscape Nightを開催します。

当日は、私たちが普段どんなツールを使い、どのように開発を進めているのかを気軽にお話しできる場にしたいと思っています。 ご応募は以下のページからお待ちしております!

legalscape.notion.site

※Legalscapeテックブログ経由の旨を参加フォームからご登録いただきますようお願いいたします。 参加お申し込みフォームからお申し込みいただいた後、担当より参加確定メールをお送りいたします。応募者多数の場合、ご参加いただけない場合ございますこと大変恐縮ですがご了承ください。その際もご連絡いたします。