AI利用と関係の開示:本稿・コード・架空PDFはCodexが生成し、変換操作とファイル検査もエージェントが実行しました。Yan Wang / LensUp向けに作成した記事です。人間のアカウント所有者が自ら実験・独立検証したという意味ではありません。
PDFのしおりを取り出してtitle → pageの辞書にすると、扱いやすそうに見えます。ただ、複数の文書に同じ「Overview」「Details」があると、結合後には片方が上書きされます。この検査では、しおりのタイトルを一意のキーとして使えません。
そこで今回は、ページ番号を見る前に、巡回順・階層の深さ・移動先ページの識別文字列を一組で残す方法を紹介します。汎用のPDF検証器ではなく、同名しおりを含む小さな架空例の診断です。
まず期待する順序を決める
入力はAlphaとBetaの2文書で、どちらも2ページです。しおりは各文書とも、親がOverview、子がDetailsです。1ページ目には、同じ文書の2ページ目への内部リンクも置きました。出力はファイル名順とは逆のBeta、Alphaの順にして、エージェントがブラウザーから実際に結合・ダウンロードしました。
出力ページに印字された識別子はBETA-1 → BETA-2 → ALPHA-1 → ALPHA-2です。下表のindexは0始まりなので、PDFリーダーでいう「1ページ目」がindex 0にあたります。
| depth | title | page_index | target_token |
|---|---|---|---|
| 0 | Overview | 0 | BETA-1 |
| 1 | Details | 1 | BETA-2 |
| 0 | Overview | 2 | ALPHA-1 |
| 1 | Details | 3 | ALPHA-2 |
期待値には同じtitleが重複して含まれます。この重複を消してしまうと、検査したい対象そのものがなくなります。
リストとして出力する短い診断コード
実行環境はmacOS、Python 3.12.14、pypdf 6.10.0、ReportLab 4.4.9です。まず専用のサンプルを生成し、それを結合します。使える仮想環境がなければ、専用の環境を作ってください。
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install pypdf==6.10.0 reportlab==4.4.9
次のコードをreproduce_source.pyとして、既存ファイルのない作業用ディレクトリに保存します。実行すると、同名のしおりとページ識別子を持つPDFが2つ書き出されます。
"""Standalone reader recipe; writes two synthetic PDFs only when run as a script."""
import io
from pathlib import Path
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
def make_source(name):
stream = io.BytesIO()
pdf = canvas.Canvas(stream, pagesize=A4, invariant=1, pageCompression=1)
pdf.setTitle(f'{name.title()} fictional handbook')
pdf.setAuthor('Synthetic fixture')
for page in (1, 2):
pdf.setFont('Helvetica-Bold', 22)
pdf.drawString(50, 770, f'{name.upper()} / PAGE {page}')
pdf.setFont('Helvetica', 12)
pdf.drawString(50, 735, 'Fictional navigation test. Not a customer document.')
pdf.drawString(50, 708, f'Identity token: {name.upper()}-{page}')
if page == 1:
pdf.bookmarkPage('overview')
pdf.addOutlineEntry('Overview', 'overview', level=0, closed=False)
pdf.setFillColorRGB(0, 0.2, 0.65)
pdf.drawString(50, 660, 'Jump to this handbook\'s Details page')
pdf.linkRect('', 'details', (48, 653, 330, 677), relative=0, thickness=1)
pdf.setFillColorRGB(0, 0, 0)
pdf.drawString(50, 620, 'Expected local target before merge: the second page.')
else:
pdf.bookmarkPage('details')
pdf.addOutlineEntry('Details', 'details', level=1, closed=False)
pdf.drawString(50, 660, f'This is the Details page of {name.title()}.')
pdf.showPage()
pdf.save()
return stream.getvalue()
if __name__ == '__main__':
for name in ('alpha', 'beta'):
Path(f'{name}-handbook.pdf').write_bytes(make_source(name))
python reproduce_source.py
生成したbeta-handbook.pdfとalpha-handbook.pdfをこの順で結合ツールに入れ、結果を同じ作業ディレクトリにmerged.pdfとして保存します。ツール側でファイル順を並べ替えられる場合も、最終的にBeta、Alphaの順になっているか確認してください。エージェントが実測したのはこの結合ツールでの1回だけです。他のツールで期待値と違う結果が出ても、期待値を結果に合わせて書き換えず、まずページ順としおりが保持されているかを確認します。
次をoutline_snapshot.pyとして保存します。
"""Read-only list snapshot; this is a diagnostic dump, not a generic PDF validator."""
import json
import re
import sys
from pypdf import PdfReader
def outline_rows(reader):
tokens = []
for page in reader.pages:
matches = re.findall(r'Identity token: ((?:ALPHA|BETA)-[12])\b', page.extract_text() or '')
if len(matches) != 1:
raise ValueError('This example requires one synthetic identity token per page')
tokens.append(matches[0])
rows = []
def walk(items, depth=0):
for item in items:
if isinstance(item, list):
walk(item, depth + 1)
continue
index = reader.get_destination_page_number(item)
token = tokens[index] if index is not None and 0 <= index < len(tokens) else None
rows.append({'depth': depth, 'title': item.title,
'page_index': index, 'target_token': token})
walk(reader.outline)
return rows
if __name__ == '__main__':
rows = outline_rows(PdfReader(sys.argv[1]))
print(json.dumps(rows, ensure_ascii=False, indent=2))
by_title = {row['title']: row['target_token'] for row in rows}
print(json.dumps({'outline_nodes': len(rows), 'unique_titles': len(by_title),
'lossy_title_dictionary': by_title}, ensure_ascii=False, indent=2))
python outline_snapshot.py merged.pdf
識別子はこの架空PDF専用です。一般の文書に使う場合は、確認したいページを特定できるテキストか、別の識別方法に置き換えてください。スキャン画像ではテキスト抽出が空になることがあるので、この方法は使えません。
pypdfの公式APIによると、outlineはしおりを含むリストとして提供され、get_destination_page_number()で移動先のページを取得できます。子リストに入るたびにdepthを増やし、全ノードを順に追加します。indexを解決できない場合はtarget_tokenをNoneにして、成功したかのような値は補いません。
このコードは診断用に情報を出力するだけです。元のアクション種別やPDFの仕様適合性を判定するものではないので、表示された行だけで「あらゆるPDFのしおりが正常」とは結論づけられません。
辞書にした場合、実際に何が消えるか
保存した実ファイルでは、outline_nodesが4、unique_titlesが2でした。辞書に残ったのは次の2項目です。
{
"Overview": "ALPHA-1",
"Details": "ALPHA-2"
}
消えたのはBetaの2ノードです。PDFから消えたのではなく、診断側が同じtitleをキーにして上書きしたためです。辞書の中身だけを期待値と比べると、結合に問題がなくても誤判定したり、必要なノードの欠落を見逃したりします。
比較するなら、次のコードを同じファイルの末尾に追記し、リストから必要な組を取り出します。この期待値は、この4ページの架空例にだけ当てはまります。
expected = [
(0, "Overview", "BETA-1"),
(1, "Details", "BETA-2"),
(0, "Overview", "ALPHA-1"),
(1, "Details", "ALPHA-2"),
]
actual = [(r["depth"], r["title"], r["target_token"]) for r in rows]
assert actual == expected
しおりとページ内リンクは別に確認する
同じ実ファイルに対し、別の限定的な検査で、生の/Dest配列または単純な/GoToからページ参照を読み取りました。しおりの4ノードに加えて、ページ内リンク2本もindex 0から1、index 2から3へ対応していました。上の短い診断コードはページ内リンクを調べていないので、その検査の代わりにはなりません。
反例は実ファイルをもとにメモリー上で作りました。2つ目のOverviewの移動先だけを変える、しおりは残してリンク先だけを変える、ノードやリンクを消す、ページ順を変える、といった変更で、それぞれ該当する検査が失敗しました。名前付き移動先、URIアクション、アクションチェーンは、この限定検査の対象外です。確認したのは10ケースで、ブラウザーでの実行を10回繰り返したわけではありません。
4ページの出力はPopplerで85 DPIに描画し、対応する入力ページとのRGBピクセル一致と、画像の目視も確認しました。ただし、PDFリーダー上でしおりやリンクをクリックする試験はしていません。移動先座標、ズーム、クリック範囲、フォーム、署名、アクセシビリティ構造、他のリーダーでの挙動までは保証しません。
タイトルをキーにする前に、重複があり得るかを確かめ、実際のダウンロードから順序と深さを残した行を取り出す。そのうえでページ内容・しおり・リンクの期待値を分けておけば、検査そのものが情報を落としていないかも確認できます。
英語版では、結合後のナビゲーションをページ順とは別に検査する方法を扱っています。