同じ請求書 PDF を Windows とスマートフォンで開いても、改行もフォントもロゴもまったく同じ位置に着地します。PDF ファイルとは、番号付きオブジェクトの集合とバイトオフセットのインデックスであり、PDFium Component 内部の PDFium エンジンのようなリーダーは、それを末尾から開きます。startxref、xref テーブル、カタログ、ページツリー、コンテンツストリーム、そしてピクセルの順です
では PDF ファイルとは何で、なぜどこでも同じに見えるのでしょうか。Word や RTF の文書はテキストとスタイルを保存し、レイアウトを開く側のマシンに委ねます。フォントが欠けていたりプリンタドライバが違ったりすると、ページは流れ直します。PDF はその代わりに完成した結果を保存します。すべてのグリフと行がポイント単位の座標空間で絶対位置を持ち、フォントプログラムはファイルの中を旅し、画像は固有のカラーデータと圧縮フィルタを運びます。表示時に組版を行うものは何もないため、どの OS の準拠レンダラでも同じページを描きます。この形式は 1993 年、PostScript のイメージングモデルを土台に Acrobat とともに PDF 1.0 として始まり、改訂のたびに以下で説明する機構の一部が加わってきました
- PDF 1.1 から 1.4 で、暗号化、対話型フォーム、論理構造付きのデジタル署名、透明性が加わりました
- PDF 1.5 でオブジェクトストリームとクロスリファレンスストリームが、PDF 1.6 で AES 暗号化が加わりました
- PDF 1.7 は ISO 32000-1:2008 となり、PDF 2.0 は 2020 年に改訂された ISO 32000-2:2017 となりました
- PDF/A(ISO 19005)、PDF/X(ISO 15930)、PDF/UA(ISO 14289)は、アーカイブ、印刷制作、アクセシビリティ向けにその土台へ制約を課します
リーダーが PDF ファイルを開くとき、何が起きるのか
PDF を開くとは、ファイルの最後のバイトから始まりビットマップで終わる、探索の連鎖です。その連鎖のほぼすべてのリンクは辞書、つまり ISO 32000-1 §7.3 の基本オブジェクト型の 1 つであり、辞書同士は 7 0 R(オブジェクト 7、世代 0)のような間接参照で互いを指します。したがってファイルは、ページの列というよりオブジェクトのグラフとして振る舞います。ヘッダー、ボディ、クロスリファレンステーブル、トレイラーの配置はPDF ファイル構造の概観で示し、行ごとに注釈を付けた最小の PDF が、テキストエディタの中で obj、endobj、stream がどんな姿かを注釈付き実例で見せます。すべてのリーダーがたどる順序は次のとおりです
- ファイルの終端へシークし、
startxrefキーワードの後に書かれたバイトオフセットを読む - そのオフセットの相互参照データをロードする。すべてのオブジェクト番号をファイル内の位置へマップするデータです
- トレイラーを読み、
/Rootをたどってドキュメントカタログへ行く - カタログの
/Pagesエントリをページツリーの下へたどり、要求されたページオブジェクトへ行く - ページのコンテンツストリームを復号し、そのオペレータを順に解釈する
- そのオペレータが名指すフォント、画像、フォーム XObject を
/Resources経由で解決する - パス、グリフのアウトライン、画像をデバイス解像度でピクセルへラスタライズする
ステップ 1:startxref とクロスリファレンステーブル
リーダーは PDF を先頭から走査することは決してありません。整形式のファイルは、キーワード startxref、48211 のような 10 進数、そして %%EOF で終わり、その数値が相互参照データのバイトオフセットです。xref を後ろ向きに素朴に探すと startxref の内側で止まるため、キーワード全体で検索します。古典的なテーブルは固定幅のエントリを使い(§7.5.4)、他をパースせずに任意のオブジェクト番号のエントリへ跳べます。各エントリのバイトレイアウトは構造の概観で扱っています。テーブルの後のトレイラーは /Size と /Root、オプションで /Encrypt、/ID、/Prev を名指します
すべてのエントリは絶対的なバイト位置なので、行末を書き直すエディタのような、バイトをずらす何ものかは、インデックスを間違った場所を指させたままにします。修復パスを持つリーダーは obj キーワードを走査して再構築し、持たないリーダーはファイルを拒否します。壊れた PDF があるビューアでは開けて別のビューアでは開けない理由はこれです。startxref のオフセットに何が座っているかを見れば、どちらのケースか分かります。古典的なテーブルならキーワード xref、PDF 1.5 のクロスリファレンスストリームなら N 0 obj ヘッダー、それ以外なら古びたオフセットです。以下のヘルパーは素の RTL ストリームで組む自前のコードです
uses
System.SysUtils, System.Classes;
// 自前のヘルパー:Offset は startxref の後に書かれた数値
// その位置にリーダーが何を見つけるかを分類する
function DescribeXrefAt(const FileName: string; Offset: Int64): string;
var
FS: TFileStream;
Buf: TBytes;
Head: string;
begin
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
if Offset >= FS.Size then
Exit('offset beyond end of file: the file needs a repair pass');
FS.Position := Offset;
SetLength(Buf, 32);
SetLength(Buf, FS.Read(Buf[0], Length(Buf)));
finally
FS.Free;
end;
Head := TEncoding.ASCII.GetString(Buf);
if Head.StartsWith('xref') then
Result := 'classic cross-reference table'
else if Head.Contains(' obj') then
Result := 'cross-reference stream (PDF 1.5 or later)'
else
Result := 'stale offset: the file needs a repair pass';
end;
クロスリファレンスストリーム(§7.5.8)は、/Type /XRef を持ち、各列のバイト幅を与える /W 配列と、バイトオフセットかオブジェクトストリーム(§7.5.7)の内側を指すバイナリ行を運ぶ圧縮オブジェクトです。トレイラーのキーはその辞書へ移るため、ファイルには trailer キーワードが一切現れません。HotPDF では THotPDF に UseXRefStream を設定するとこの形式を書きます。メール添付や印刷店へのアップロードなど、自分の管理外へ出ていくファイルには、PDF 1.5 より前のリーダーもパースできる古典的なテーブルが今も安全な選択です。アーカイブについては PDF/A-1 が決着をつけます。ISO 19005-1 は PDF 1.4 を土台とし、その §6.1.4 はクロスリファレンスストリームを使ってはならないと述べるため、HotPDF は PDF/A-1 プロファイルが有効なときは常に古典的なテーブルを書きます
48 0 obj
<< /Type /XRef /Size 49 /Root 1 0 R
/W [1 3 1] /Index [0 49]
/Filter /FlateDecode /Length 162 >>
stream
...binary rows: 1 + 3 + 1 bytes each...
endstream
endobj
startxref
48211
%%EOF
/Root から正しいページへ:カタログとページツリー
トレイラーの /Root はドキュメントカタログを指し、カタログの /Pages はページツリーの根を指します。中間の /Pages ノードはそれぞれ、配下の葉ページの数を /Count に持つため、5,000 ページを探すリーダーは降りながらカウントを引き算し、部分木をまるごと開かずにスキップします。ISO 32000-1 §7.7.3 はバランスの取れたツリーを要求しません。10,000 エントリの単一の /Kids 配列も合法です。ただし検索は遅くなるだけです。葉だけを読むコードには 1 つの罠があります。/Resources、/MediaBox、/CropBox、/Rotate は継承可能(§7.7.3.4)なので、自分の /MediaBox を持たないページでも、値が祖先に住んでいるだけで有効です
12 0 obj
<< /Type /Page
/Parent 3 0 R
/MediaBox [0 0 612 792]
/Resources << /Font << /F1 7 0 R >> >>
/Contents 13 0 R
>>
endobj
メディアボックスはページに描かれるすべてのものの座標空間を定めます。原点は左下、Y は上向き、1 単位は 1/72 インチ、PDF 1.6 の /UserUnit があればそれで拡大されます。PDFium Component は、番号でページを要求すると、この降下の全体を実行します。TPdf.PageMetrics は、継承されたボックスをすでに解決済みの状態でページサイズをポイント単位で返し、ページ回転も一緒に運びます。したがって以下のリスティングは、葉ページが自分のボックスを持たないファイルでも正しいサイズを報告します
uses
System.SysUtils, PDFium;
// ページ数と全ページのサイズをポイント単位で印字する
procedure ListPages(const FileName: string);
const
Degrees: array[TRotation] of Integer = (0, 90, 180, 270);
var
Pdf: TPdf;
I: Integer;
M: TPdfPageMetrics;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True; // xref データ、トレイラー、カタログをパースする
if not Pdf.Active then // ロード失敗なら Active は False のまま
raise Exception.Create('Cannot open ' + FileName);
Writeln(Pdf.PageCount, ' pages');
for I := 1 to Pdf.PageCount do
begin
M := Pdf.PageMetrics[I]; // 1 基点。ページをツリー経由でロードする
Writeln(Format('Page %d: %.1f x %.1f pt, /Rotate %d',
[I, M.Width, M.Height, Degrees[M.Rotation]]));
end;
finally
Pdf.Free;
end;
end;
PDF は画面にページをどう描くのか
ページの可視コンテンツは小さなプログラムです。1 つ以上のコンテンツストリームに収められた後置記法のオペレータが、リーダーが /Filter、たいていは /FlateDecode で名指されたデコーダを実行した後に、順に解釈されます。BT がテキストオブジェクトを開き、Tf がフォントリソースとサイズを選び、Td がテキスト位置を移動し、Tj が文字列を描画し、ET がオブジェクトを閉じます。パスは m、l、c、re で作られ、f で塗られか S で線画されます。グラフィックスステート(§8.4)は、変換マトリクス、色、クリッピングパスのように、これらのオペレータが暗黙に読むものを保持します。q がコピーをプッシュし、Q がポップするため、このペアの内側で設定した変換は後続のコンテンツへ漏れません
q
1 0 0 1 72 700 cm % 原点を (72, 700) へ移動
BT
/F1 12 Tf % 12 ポイントのフォントリソース F1
0 0 Td
(Invoice 2026-0417) Tj
ET
0.2 0.2 0.8 RG % 線画色。q/Q でスコープされる
0 -6 m 140 -6 l S
Q
/F1 のような名前は、ページの /Resources への鍵です。フォント辞書はフォント記述子(§9.8)を通って埋め込みフォントプログラムへ至り、画像は JPEG や Flate のデータが復号されれば 1 つの Do で描かれます。ラスタライズは最後に来ます。レンダラは変換マトリクスをすべてのパスとグリフのアウトラインに適用し、ポイントをデバイスピクセルへ写し、アンチエイリアス付きで結果を塗ります。この時点でページはまだベクターデータなので、400 パーセントへのズームはビットマップを引き伸ばす代わりに、コンテンツストリームをより大きなスケールで再実行します。目標解像度は呼び出し側が選びます。画面なら 1 インチあたり 96 か 150 ピクセル、印刷なら 300 以上です。PDFium Component ではこの段階が TPdf.RenderPage で、出力サイズをピクセル単位で受け取ります
uses
System.SysUtils, Vcl.Graphics, PDFium;
// FileName の 1 ページ目を Dpi ピクセル/インチでレンダリングし、BMP として保存する
procedure RenderFirstPage(const FileName: string; Dpi: Integer);
var
Pdf: TPdf;
Bmp: TBitmap;
W, H: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
if not Pdf.Active then
raise Exception.Create('Cannot open ' + FileName);
Pdf.PageNumber := 1; // 1 基点
// PageWidth と PageHeight はポイント単位(1/72 インチ)
W := Round(Pdf.PageWidth * Dpi / 72);
H := Round(Pdf.PageHeight * Dpi / 72);
Bmp := Pdf.RenderPage(0, 0, W, H, ro0, [reAnnotations]);
try
Bmp.SaveToFile(ChangeFileExt(FileName, '-p1.bmp'));
finally
Bmp.Free; // 関数形式は新しいビットマップを返す
end;
finally
Pdf.Free;
end;
end;
ページは正しく見えるのに、コピペすると文字化けするのはなぜか
コピペが失敗するのは、ファイルがどのグリフを描くかを記録していても、それがどの文字を表すかを記録していないときです。描画に必要なのは、埋め込みフォントプログラムからのグリフ形状だけです。抽出には Unicode が要り、それは標準エンコーディングか、フォント辞書上の /ToUnicode CMap から来ます(§9.10.3)。/Encoding /Identity-H の複合フォントは生のグリフ ID をコンテンツストリームへ書き込むため、ToUnicode マップがなければ、その ID はどの文字とも関係が定義されません。画面は完璧に見えるのに、クリップボードはシンボルで満たされます。サブセットフォントは文書が使うグリフだけを保持するため、この事態を起こしやすく、/BaseFont /AAAAAA+ArialMT(§9.6.4)のような名前の 6 文字接頭辞で見分けられます。CJK フォントと壊れた抽出についてはPDF フォント処理ガイドが深く扱っています
増分更新と署名のバイトレンジ
署名は、PDF が追記専用で編集されることに依存しています。増分更新(§7.5.6)は、最後の %%EOF の後にオブジェクトと新しいクロスリファレンスセクションを追加します。構造の概観では /Prev チェーンと、削除されたコンテンツがなぜ前のリビジョンに生き残るのかを説明しています。署名辞書の /ByteRange は [0 a b c] と書かれ、CMS 署名が座る /Contents 16 進文字列を除くすべてをハッシュします。したがって署名されたリビジョンはバイト b + c で終わり、検証器はそこでファイルを切り詰めて再構築します。後のリビジョンはその点の後に着地します。一方、完全な書き直し、リニアライザー、最適化パスは、署名者がハッシュしたバイトを動かし、既存の署名を一斉に無効化します
相互参照データのパースとページツリーの歩行から、レンダリングとテキスト抽出まで、このパイプライン全体が Delphi や Lazarus のアプリケーションに必要なときは、PDFium Component for Delphi and Lazarus が、上のリスティングで使った TPdf ドキュメントコンポーネントと、画面表示のための TPdfView ビューアコントロールに、それをまとめて収めます