1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PDFの同名しおりを辞書にしない:結合後の順序・深さ・移動先をpypdfで確認する

1
Posted at

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リーダー上でしおりやリンクをクリックする試験はしていません。移動先座標、ズーム、クリック範囲、フォーム、署名、アクセシビリティ構造、他のリーダーでの挙動までは保証しません。

タイトルをキーにする前に、重複があり得るかを確かめ、実際のダウンロードから順序と深さを残した行を取り出す。そのうえでページ内容・しおり・リンクの期待値を分けておけば、検査そのものが情報を落としていないかも確認できます。
英語版では、結合後のナビゲーションをページ順とは別に検査する方法を扱っています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?