【Python】pypdfとTyperでPDFの結合・分割・並び替えCLIを作ってみた
公開:
PDFの表紙と本文をくっつけたい、章ごとに切り出したい、ページ順を直したい、ということがあります。こうした作業をGUIで毎回やり直すのは地味に面倒です。そこで、PDF操作をpypdfに、コマンド定義をTyperに任せた小さなCLIを作りました。
merge・split・reorderの3つに、手元にPDFが無くても試せるsampleを加えた4サブコマンド構成です。実際に5回のコマンドを実行し、すべて終了コード0で完了するところまで確認しました。この記事では、その実装と使い方を初心者向けに順を追って解説します。
pypdfの基本概念、要件定義、実装、動作確認までを順番に学べる構成です。動画は目次から確認したい場面へ移動でき、本文だけでも手順と考え方が完結します。
動画の内容をテキストで確認する
オープニング。pypdfとTyperを使ってPDF結合・分割・並び替えCLIを作るカリキュラムを始めます。概要紹介。
pypdfとTyperの役割と使い方を学ぶPDF結合・分割・並び替えCLIの要件を整理する 完成コードと実行結果を確認する 最後に実コマンドとファイル状態で完成挙動を確かめる 具体的にやること。
複数のPDFを指定した順に1ファイルへ結合するmergeコマンド ページ範囲を指定して複数ファイルへ切り出すsplitコマンド 指定した順序でページを並べ直すreorderコマンド 動作確認用のPDFを生成するsampleコマンド カンマ区切りのページ指定を1始まりの番号へ展開 実装環境・必須アプリ。
OS:Windows 11 Pro Python:3.13.3シェル:PowerShell 5.1必須アプリ:コードエディター、ターミナル、エクスプローラー パッケージ:pip、pypdf、typer PDFを扱うpypdfとCLIを作るTyperとは。
pypdf:pypdfは、既存のPDFを読み込み、ページ単位で取り出して別のファイルへ書き出せるPython製のPDF操作ライブラリTyper:Typerは、Pythonの関数へ少しの目印を付けるだけでコマンドラインの入口を用意できるCLIフレームワーク pypdfとTyperで作るPDF編集CLIの要点。
カンマで分割して前後の空白を除去 空文字だけになったトークンを捨てる 残りが0件ならBadParameterで中断 PDF結合・分割・並び替えCLIの要件定義。
sampleでcover.pdfとbody.pdfが出力先へ作られるcover.pdfが2ページ、body.pdfが4ページになるcover.pdfとbody.pdfの結合で6ページのPDFが得られる --ranges 1-2,3-4の分割で2ファイルが出力される 分割後の各ファイルが指定範囲どおりのページ数になる --order 4-1で新しい順序が[4, 3, 2, 1]と表示される INTRO: Monaco EditorでPDF結合・分割・並び替えCLIを実装。
コードを1行ずつ入力し、補完と自動インデントを使いながら実行結果を確認します。LINE 001: モジュールの説明文。このファイル全体がpypdfとTyperを使ってPDFを結合・分割・並び替えするCLIであることを説明するdocstringです。
プログラムの目的を読む人に伝える役割を持ちます。LINE 003: 型注釈の遅延評価を有効化。型ヒントの評価を実行時ではなく文字列として遅延させるための宣言です。
以降のlist[str]のような新しい記法の型注釈を古いPythonでも問題なく使えるようにします。LINE 005: Pathクラスの読み込み。ファイルやディレクトリのパスを扱うためのPathクラスを取り込んでいます。
この後の入出力先のパス操作すべてで利用されます。LINE 006: List型の読み込み。typerの引数定義で使う型ヒント用のListを取り込んでいます。
複数ファイルを受け取るmergeコマンドの引数型に使われます。LINE 008: Typerライブラリの読み込み。コマンドライン引数の解析を行うtyperライブラリを取り込んでいます。
この後のCLIアプリ全体の土台になります。LINE 009: pypdfの読み書き機能の読み込み。PDFを読み込むPdfReaderと、新しいPDFを書き出すPdfWriterを取り込んでいます。
以降のPDF操作すべての中心的な機能です。LINE 010: PDF注釈機能の読み込み。PDFにテキストの注釈を追加するためのFreeTextクラスを取り込んでいます。
サンプルPDF作成時のページ番号表示に使われます。LINE 013: Typerアプリの作成開始。コマンドライン全体を管理するTyperアプリのインスタンスを作り始めています。
以降のオプション設定とともにappという名前で保持されます。LINE 014: 補完機能の無効化設定。シェルの自動補完機能を無効にする設定です。
CLIの動作をシンプルに保つための指定になります。LINE 015: ヘルプ文言の設定。コマンド実行時に表示されるヘルプメッセージの内容を指定しています。
このCLIが何をするツールかを利用者に伝えます。LINE 016: Typerアプリ定義の終了。typer.Typerのコンストラクタ呼び出しを閉じる括弧です。
ここまででappオブジェクトの初期化が完了します。LINE 019: サンプル構成の定義。動作確認用に生成するPDFのファイル名とページ数の組み合わせを辞書で定義しています。
sampleコマンドがこの情報をもとにPDFを作成します。RUN 1/8: サンプルPDFの構成を確認する。Typerアプリの入れ物と、動作確認用PDFの構成を表す辞書までを入力した状態です。
ファイル名とページ数の対応が意図どおりか、ここで一度見ておきます。CHECK 1/8: 途中実行に成功。サンプル構成: {'cover': 2, 'body': 4} 合計ページ数: 6 RETURN 01: エディターへ戻りました。
入力済みのコードを維持したまま、次の実装へ進みます。LINE 023: トークン分解関数の定義。カンマ区切りのページ指定文字列を受け取り、文字列のリストへ分解する関数の定義です。
引数specから戻り値の型までを宣言しています。LINE 024: 関数の説明文。この関数がカンマ区切りの指定を空要素を除いたトークン列に分解することを説明しています。
関数の目的を明確にする役割です。LINE 025: 文字列をトークンに分割。カンマで文字列を分割し、前後の空白を取り除いたうえで空文字を除外してリストにしています。
ページ範囲指定の基本単位であるトークンを作り出す処理です。LINE 026: トークンが空かの判定。分割結果のトークンが一つも得られなかったかどうかを判定しています。
指定文字列が空だった場合の異常系を検出します。LINE 027: 空指定エラーの送出。ページ指定が空だった場合に、例とともにわかりやすいエラーメッセージを添えて例外を発生させています。
Typerがこれを受けて利用者に表示します。LINE 028: トークンリストの返却。分解できたトークンのリストを呼び出し元に返しています。
この結果が後続のexpand_tokenでページ番号へ展開されます。RUN 2/8: ページ指定の分解結果を確かめる。カンマ区切りをトークンへ分けるsplit_tokensが完成しました。
空白や余分なカンマを混ぜた指定でも、必要な要素だけが残るか確認します。CHECK 2/8: 途中実行に成功。分解結果: ['1-3', '5'] 空指定の扱い: ページ指定が空です(例: 1-3,5) RETURN 02: エディターへ戻りました。
入力済みのコードを維持したまま、次の実装へ進みます。LINE 031: トークン展開関数の定義。1つのページ指定トークンと総ページ数を受け取り、実際のページ番号リストに展開する関数の定義です。
LINE 032: 関数の説明文。単一ページや範囲指定、降順の範囲まで1始まりのページ番号リストに変換することを説明しています。LINE 033: 解析処理の開始。
トークンの数値変換で発生し得るエラーを捕まえるためのtry文の開始です。以降の処理を保護しています。LINE 034: 範囲指定かどうかの判定。
トークンにハイフンが含まれているかどうかを調べています。範囲指定と単一ページ指定を区別するための分岐です。LINE 035: ハイフンで前後を分割。
トークンをハイフンを区切りに開始側と終了側の文字列に分けています。partitionにより区切り文字自体は使わずheadとtailだけを取り出しています。LINE 036: 開始・終了ページ番号への変換。
文字列として得られた開始と終了のページ番号を整数に変換しています。この後の範囲展開に使う数値を確定させます。LINE 037: 単一指定側の分岐開始。
トークンにハイフンが含まれない場合の処理へ分岐するelse節の開始です。単一ページ指定の扱いへ進みます。LINE 038: 単一ページ指定の変換。
ハイフンがない場合は同じ数値を開始と終了の両方に設定しています。単一ページの指定を範囲展開と同じロジックで扱えるようにする工夫です。LINE 039: 数値変換失敗の捕捉。
int変換に失敗した場合の例外を捕まえています。数字以外の文字が含まれるトークンを検出するための処理です。LINE 040: 解釈不能エラーの送出。
ページ指定を数値として解釈できなかったことを示すエラーメッセージとともに例外を発生させています。利用者に問題のあるトークンを伝えます。LINE 042: 開始と終了の検証開始。
開始ページ番号と終了ページ番号の両方を順番に検証するためのループです。LINE 043: 範囲外判定。各ページ番号が1から総ページ数の範囲内に収まっているかを確認しています。
範囲外のページ指定を検出する条件です。LINE 044: 範囲外エラーの送出開始。ページ番号が有効範囲を外れていた場合に例外を発生させ始めています。
LINE 045: 範囲外エラーメッセージ。対象のページ番号と有効範囲を示すエラーメッセージの内容です。利用者が何が間違っているかを具体的に把握できるようにしています。
LINE 046: エラー送出の終了。raise文の呼び出しを閉じる括弧です。ここまでで範囲外検証によるエラー処理が完結します。
LINE 048: 増減方向の決定。開始が終了以下であれば1を、そうでなければマイナス1をstepとして設定しています。昇順・降順どちらの範囲指定にも対応するための値です。
LINE 049: ページ番号リストの生成。開始から終了までstepの間隔で並ぶ整数のリストを作成して返しています。この結果が実際にPDFへ反映するページ番号になります。
RUN 3/8: 範囲指定と降順指定の展開を見る。1つのトークンをページ番号の並びへ広げるexpand_tokenが動くようになりました。昇順と降順、そして範囲外の指定をまとめて試します。
CHECK 3/8: 途中実行に成功。昇順: [2, 3, 4, 5] 降順: [5, 4, 3, 2] 単一: [4] 範囲外: ページ番号が範囲外です: 9(有効範囲1-6) RETURN 03: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 053: PDF読み込み関数の定義。指定したパスのPDFファイルを開いてPdfReaderとして返す関数の定義です。LINE 054: 関数の説明文。
入力PDFを開くこと、ファイルが存在しない場合は理由を示して処理を中断することを説明しています。LINE 055: ファイル存在確認。指定されたパスが実際にファイルとして存在するかどうかを確認しています。
存在しない場合の異常系を検出する条件です。LINE 056: ファイル未発見エラーの送出。PDFファイルが見つからないことを示すエラーメッセージとともに例外を発生させています。
処理をここで中断させます。LINE 057: PDFの読み込みと返却。パスを文字列に変換したうえでPdfReaderに渡し、読み込んだPDFオブジェクトを呼び出し元へ返しています。
LINE 060: PDF書き出し関数の定義。PdfWriterの内容を指定した出力先へ書き出す関数の定義です。戻り値として書き出し後のページ数を返します。
LINE 061: 関数の説明文。出力先ディレクトリを用意して書き出し、書き出した結果のページ数を返すという処理内容を説明しています。LINE 062: 出力先ディレクトリの作成。
書き出し先の親ディレクトリが存在しない場合は自動的に作成しています。既に存在していてもエラーにならないよう設定されています。LINE 063: 書き込み用ファイルを開く。
出力先のファイルをバイナリ書き込みモードで開いています。withブロックにより処理後は自動的にファイルが閉じられます。LINE 064: PDFの書き出し実行。
PdfWriterに蓄積されたページの内容を、開いたファイルへ実際に書き込んでいます。この処理で新しいPDFファイルが完成します。LINE 065: 書き出し結果のページ数取得。
書き出したばかりのPDFファイルを改めて読み込み、そのページ数を数えて返しています。書き出しが正しく行われたことの確認も兼ねています。RUN 4/8: 白紙PDFを書き出してページ数を数える。
入出力を受け持つopen_pdfとwrite_pdfがそろいました。白紙3ページを書き出して、フォルダの自動作成と保存後のページ数取得を確かめます。CHECK 4/8: 途中実行に成功。
書き出したページ数: 3 ファイル存在: True 欠損時の案内: PDFが見つかりません: check_out/none.pdf RETURN 04: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 069: mergeコマンドの登録。
この直後の関数をmergeという名前のサブコマンドとしてアプリに登録するデコレータです。コマンドライン上でmergeとして呼び出せるようになります。LINE 070: merge関数の定義開始。
複数のPDFを結合するmerge関数の定義を開始しています。LINE 071: 結合対象ファイルの引数定義。結合するPDFファイルのパスを1件以上受け取る必須の引数を定義しています。
files変数にファイルパスのリストが格納されます。LINE 072: 出力先オプションの定義開始。結合結果の出力先パスを指定するオプション引数の定義を開始しています。
LINE 073: 出力先の既定値と説明。出力先が指定されなかった場合の既定値としてmerged.pdfを設定し、--outputや-oというオプション名とヘルプ文言を定めています。LINE 074: 出力先オプション定義の終了。
typer.Optionの呼び出しを閉じる括弧です。ここまでで出力先引数の定義が完了します。LINE 075: merge関数の宣言終了。
merge関数の引数リストと戻り値の型宣言を締めくくっています。戻り値がないことを示すNoneが指定されています。LINE 076: merge関数の説明文。
複数のPDFを指定した順に結合する関数であることを説明しています。LINE 077: 結合開始メッセージの表示。何個のPDFを指定順に結合するかを画面に表示しています。
処理開始を利用者に知らせるメッセージです。LINE 079: 書き込み用PdfWriterの作成。結合したページを蓄積していくためのPdfWriterオブジェクトを新しく作成しています。
この後のループでページが追加されていきます。LINE 080: ファイル一覧のループ開始。指定された結合対象のファイルパスを1つずつ順番に処理するループです。
LINE 081: PDFファイルを開く。ループ中の各パスに対してopen_pdf関数を呼び出し、PDFを読み込んでいます。読み込みに失敗した場合はここでエラーが発生します。
LINE 082: ページ一覧のループ開始。読み込んだPDFに含まれる全ページを1つずつ処理するループです。LINE 083: ページの追加。
読み込んだ各ページをPdfWriterに追加しています。この積み重ねによって最終的な結合結果が構成されていきます。LINE 084: 読み込み結果の表示。
読み込んだファイルのパスとページ数を画面に表示しています。処理の進捗を利用者に伝える役割です。LINE 086: 結合結果の書き出し。
write_pdf関数を呼び出し、蓄積したPdfWriterの内容を出力先へ書き出しています。戻り値として書き出したページ数を受け取っています。LINE 087: 結合完了メッセージの表示。
書き出した出力先のパスとページ数を画面に表示しています。処理が正常に完了したことを利用者に伝えます。RUN 5/8: mergeで2つのPDFを結合してみる。
最初のサブコマンドであるmergeが完成しました。同じ2ページのPDFを2回渡し、読み込みと出力のメッセージが順に並ぶことを確認します。CHECK 5/8: 途中実行に成功。
[merge] 2個のPDFを指定順に結合します 読み込み: check_out/two.pdf (2ページ) 読み込み: check_out/two.pdf (2ページ) 出力: check_out/merged.pdf (4ページ) RETURN 05: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 091: splitコマンドの登録。この直後の関数をsplitという名前のサブコマンドとしてアプリに登録するデコレータです。コマンドライン上でsplitとして呼び出せるようになります。
LINE 092: split関数の定義開始。PDFをページ範囲で分割するsplitコマンドの関数定義を開始しています。この後に続く引数を受け取り、分割処理全体を実行します。
LINE 093: 分割対象PDFの引数。分割したい元のPDFファイルを必須の引数srcとして受け取ります。コマンド実行時にファイルパスを指定する部分です。
LINE 094: ページ範囲オプションの開始。分割するページ範囲を指定するrangesオプションの定義を開始しています。カンマ区切りで複数の範囲を指定できます。
LINE 095: rangesオプションの詳細設定。オプション名と短縮形を指定し、指定例を含むヘルプ文言を設定しています。利用者が入力方法を理解しやすくするための説明です。
LINE 096: rangesオプション定義の終了。ranges引数の定義を閉じています。ここまでの内容でページ範囲指定の受け取り方が確定します。
LINE 097: 出力ディレクトリオプションの開始。分割後のファイルを保存するディレクトリを指定するout_dirオプションの定義を開始しています。LINE 098: 出力ディレクトリの既定値設定。
出力先ディレクトリの既定値をsplit_outとし、名前付きオプションで変更できるように設定しています。指定がなければこのフォルダに保存されます。LINE 099: out_dirオプション定義の終了。
out_dir引数の定義を閉じています。出力先ディレクトリの指定方法がここで確定します。LINE 100: split関数の引数定義終了。
split関数の引数リストを閉じ、戻り値がないことを示しています。ここから関数の処理本体が始まります。LINE 101: split関数のdocstring。
この関数がページ範囲を指定してPDFを複数ファイルへ分割する処理であることを説明するドキュメント文字列です。LINE 102: 入力PDFを開く。open_pdf関数を使って、指定されたsrcのPDFファイルを読み込みreaderとして保持します。
存在しない場合はここでエラーになります。LINE 103: 総ページ数の取得。読み込んだPDFの総ページ数を数えてtotalに保存します。
この後のページ範囲チェックに使う基準値になります。LINE 104: 分割開始メッセージの表示。どのファイルを何ページ、どの範囲で分割するかを画面に表示し、処理開始を利用者に知らせています。
LINE 106: 範囲指定を1件ずつ処理。split_tokens関数でrangesをカンマ区切りのトークンに分解し、1つずつtokenとして繰り返し処理します。LINE 107: ページ番号リストへの展開。
expand_token関数で範囲指定の文字列を実際のページ番号のリストnumbersへ変換しています。LINE 108: 新しいPdfWriterの作成。この範囲分のページをまとめるための新しいPdfWriterを用意しています。
範囲ごとに別ファイルとして出力するための準備です。LINE 109: ページ番号を1件ずつ処理。展開されたnumbersのページ番号を1つずつ取り出して、writerへの追加処理を繰り返します。
LINE 110: 該当ページの追加。ページ番号は1始まりなので1を引いてインデックスへ変換し、対象ページをwriterに追加しています。LINE 112: 出力ファイル名の組み立て。
元のファイル名とページ範囲の文字列を組み合わせて、出力先のファイルパスdestを作成しています。LINE 113: 分割結果の書き出し。write_pdf関数を使って、writerの内容をdestへ書き出し、書き出されたページ数をwrittenに保存しています。
LINE 114: 分割結果の表示。出力先のパスと元のページ番号、書き出されたページ数を画面に表示し、この範囲の処理結果を利用者に伝えています。RUN 6/8: splitで範囲ごとに切り出す。
分割コマンドが動く状態になりました。4ページのPDFを用意し、範囲と単一ページを混ぜた指定でファイルが分かれる様子を見ます。CHECK 6/8: 途中実行に成功。
[split] check_out/four.pdf (4ページ)を範囲1-2,4で分割します 出力: check_out/parts/four_p1-2.pdf (元のページ [1, 2] / 2ページ) 出力: check_out/parts/four_p4.pdf (元のページ [4] / 1ページ) RETURN 06: エディターへ戻りました。
入力済みのコードを維持したまま、次の実装へ進みます。LINE 118: reorderコマンドの登録。この関数をreorderという名前のCLIコマンドとして登録するデコレーターです。
ページの並び替え機能を呼び出せるようにします。LINE 119: reorder関数の定義開始。PDFのページ順を並び替えるreorderコマンドの関数定義を開始しています。
LINE 120: 並び替え対象PDFの引数。並び替えたい元のPDFファイルを必須の引数srcとして受け取ります。LINE 121: 新しいページ順オプションの開始。
並び替え後のページ順を指定するorderオプションの定義を開始しています。LINE 122: orderオプションの詳細設定。オプション名を指定し、逆順指定も可能であることを含むヘルプ文言を設定しています。
LINE 123: orderオプション定義の終了。order引数の定義を閉じています。新しいページ順の指定方法がここで確定します。
LINE 124: 出力先オプションの開始。並び替え結果を保存するファイルを指定するoutputオプションの定義を開始しています。LINE 125: 出力先の既定値設定。
出力先ファイルの既定値をreordered.pdfとし、名前付きオプションで変更できるように設定しています。LINE 126: outputオプション定義の終了。output引数の定義を閉じています。
出力先ファイルの指定方法がここで確定します。LINE 127: reorder関数の引数定義終了。reorder関数の引数リストを閉じ、戻り値がないことを示しています。
ここから処理本体が始まります。LINE 128: reorder関数のdocstring。この関数が指定した順序でページを並び替えた新しいPDFを作る処理であることを説明するドキュメント文字列です。
LINE 129: 入力PDFを開く。open_pdf関数を使って、指定されたsrcのPDFファイルを読み込みreaderとして保持します。LINE 130: 総ページ数の取得。
読み込んだPDFの総ページ数を数えてtotalに保存します。ページ番号の範囲チェックに使われます。LINE 132: 新しい順序を格納するリストの準備。
並び替え後のページ番号を順に格納するための空のリストnumbersを用意しています。LINE 133: 順序指定を1件ずつ処理。split_tokens関数でorderをカンマ区切りのトークンに分解し、1つずつtokenとして繰り返し処理します。
LINE 134: ページ番号の追加展開。expand_token関数でtokenをページ番号のリストへ変換し、numbersに追加していきます。LINE 136: 新しいPdfWriterの作成。
並び替え後のページを詰め込むための新しいPdfWriterを用意しています。LINE 137: 並び替え後の番号を1件ずつ処理。numbersに格納された新しい順序のページ番号を1つずつ取り出して、writerへの追加処理を繰り返します。
LINE 138: 該当ページの追加。ページ番号は1始まりなので1を引いてインデックスへ変換し、指定された順序でページをwriterに追加しています。LINE 140: 並び替え結果の書き出し。
write_pdf関数を使って、writerの内容をoutputへ書き出し、書き出されたページ数をwrittenに保存しています。LINE 141: 並び替え開始メッセージの表示。対象ファイルと総ページ数を示し、ページの並び替えが完了したことを画面に表示しています。
LINE 142: 元の順序の表示。1からtotalまでの連番を表示し、並び替え前の元々のページ順を利用者に確認できるようにしています。LINE 143: 新しい順序の表示。
numbersの内容を表示し、指定によって並び替えられた新しいページ順を確認できるようにしています。LINE 144: 出力結果の表示。出力先のパスと書き出されたページ数を画面に表示し、並び替え処理の結果を利用者に伝えています。
RUN 7/8: reorderでページ順を入れ替える。並び替えコマンドまで入力できました。3ページのPDFへ逆順の指定を与え、元の順序と新しい順序が並んで表示されることを確かめます。
CHECK 7/8: 途中実行に成功。[reorder] check_out/three.pdf (3ページ)のページを並び替えました 元の順序 : [1, 2, 3] 新しい順序: [3, 2, 1] 出力: check_out/reversed.pdf (3ページ) RETURN 07: エディターへ戻りました。
入力済みのコードを維持したまま、次の実装へ進みます。LINE 148: sampleコマンドの登録。この関数をsampleという名前のCLIコマンドとして登録するデコレーターです。
動作確認用のPDF作成機能を呼び出せるようにします。LINE 149: sample関数の定義開始。動作確認用のサンプルPDFを作るsampleコマンドの関数定義を開始しています。
LINE 150: 出力ディレクトリオプションの開始。生成したサンプルPDFを保存するディレクトリを指定するout_dirオプションの定義を開始しています。LINE 151: 出力ディレクトリの既定値設定。
出力先ディレクトリの既定値をsamplesとし、名前付きオプションで変更できるように設定しています。LINE 152: out_dirオプション定義の終了。out_dir引数の定義を閉じています。
サンプルPDFの保存先がここで確定します。LINE 153: sample関数の引数定義終了。sample関数の引数リストを閉じ、戻り値がないことを示しています。
ここから処理本体が始まります。LINE 154: sample関数のdocstring。この関数がページ番号を書いた動作確認用のPDFを作る処理であることを説明するドキュメント文字列です。
LINE 155: サンプル作成開始メッセージの表示。動作確認用のPDFを作成することを画面に表示し、処理開始を利用者に知らせています。LINE 157: サンプル構成を1件ずつ処理。
SAMPLE_SPECの辞書からファイル名とページ数の組を1つずつ取り出し、繰り返し処理しています。LINE 158: 新しいPdfWriterの作成。このサンプルファイル用のページをまとめるための新しいPdfWriterを用意しています。
LINE 159: 作成するページ番号を1件ずつ処理。1からcountまでのページ番号を1つずつ取り出して、ページ作成処理を繰り返します。LINE 160: 空白ページの追加。
幅420、高さ595の空白ページをwriterへ追加し、この後にページ番号を書き込む土台を用意しています。LINE 161: 注釈オブジェクトの作成開始。ページに表示するテキスト注釈FreeTextの作成を開始しています。
ここでページの見た目に関する設定を行います。LINE 162: 表示テキストの設定。ファイル名を大文字にしたものとページ番号を組み合わせた文字列を、注釈に表示するテキストとして設定しています。
LINE 163: テキストの表示位置の設定。注釈テキストを表示する矩形の座標を指定し、ページ内のどこにテキストが表示されるかを決めています。LINE 164: フォントサイズの設定。
注釈テキストの文字の大きさを24ポイントに設定しています。LINE 165: 文字色の設定。注釈テキストの文字色を黒に設定しています。
LINE 166: 枠線色の設定。注釈テキストの枠線の色を白に設定し、枠線が目立たないようにしています。LINE 167: 背景色の設定。
注釈テキストの背景色を白に設定し、ページの背景と馴染むようにしています。LINE 168: FreeText注釈定義の終わり。ページ番号やフォント設定などを指定していたFreeText注釈の定義がここで閉じられます。
この直後の行でこの注釈を実際にページへ追加します。LINE 169: 注釈をページへ追加。作成したFreeText注釈を、直前に追加した空白ページへ貼り付けています。
page_numberには追加したいページの番号を0始まりで指定するため、number-1としています。LINE 171: 出力先ファイルパスの作成。サンプル名(coverやbody)を使って、出力ディレクトリの下に保存するPDFファイルのパスを組み立てています。
ここで決めたパスへ、このあとPDFを書き出します。LINE 172: PDFファイルの書き出し。write_pdf関数を呼び出して、作成したwriterの内容を先ほど決めたdestへ実際に保存しています。
戻り値として書き出されたページ数を受け取り、writtenに格納します。LINE 173: 作成結果の表示。保存したファイルのパスとページ数を画面に表示し、サンプルPDFが正しく作成されたことを確認できるようにしています。
RUN 8/8: sampleコマンドで確認用PDFを作る。4つのサブコマンドがそろい、CLIとして呼び出せる状態になりました。sampleを実行し、表紙用と本文用のPDFが指定フォルダへ作られることを確認します。
CHECK 8/8: 途中実行に成功。[sample]動作確認用のPDFを作成します 作成: check_out/samples/cover.pdf (2ページ) 作成: check_out/samples/body.pdf (4ページ) 終了コード: 0 RETURN 08: エディターへ戻りました。
入力済みのコードを維持したまま、次の実装へ進みます。LINE 176: main関数の定義開始。プログラム全体の入り口となるmain関数を定義しています。
この関数が呼ばれることで、Typerで作られたCLIアプリが起動します。LINE 177: CLIアプリの実行。冒頭で定義したappを呼び出して、Typerによるコマンドライン処理を開始しています。
ここで初めてmerge・split・reorder・sampleなど各コマンドの処理が実行されます。LINE 180: スクリプト直接実行の判定。このファイルが直接実行されたときだけ、以下の処理を行うようにするための条件分岐です。
他のファイルからimportされた場合には実行されません。LINE 181: main関数の呼び出し。先ほど定義したmain関数を呼び出して、実際にCLIアプリを起動しています。
この行が実行されることで、コマンドライン引数の解析とコマンド実行が始まります。実行1/4: 確認用のサンプルPDFを作成する。sampleコマンドを実行し、動作確認用のPDFが2件作られることを確かめます。
表紙用と本文用でページ数が異なる点にも注目してください。確認1/4: 確認用のサンプルPDFを作成する。sampleコマンドを実行し、動作確認用のPDFが2件作られることを確かめます。
表紙用と本文用でページ数が異なる点にも注目してください。RETURN 09: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
実行2/4: 2つのPDFを指定順に結合する。表紙と本文のPDFをmergeコマンドへ渡し、1本のPDFへまとめます。読み込んだ順にページが積まれ、合計ページ数が表示されます。
確認2/4: 2つのPDFを指定順に結合する。表紙と本文のPDFをmergeコマンドへ渡し、1本のPDFへまとめます。読み込んだ順にページが積まれ、合計ページ数が表示されます。
RETURN 10: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。実行3/4: ページ範囲を指定して分割する。
4ページのPDFを前半と後半へ切り出します。範囲ごとにファイルが分かれ、元のページ番号と書き出したページ数が並んで表示されます。確認3/4: ページ範囲を指定して分割する。
4ページのPDFを前半と後半へ切り出します。範囲ごとにファイルが分かれ、元のページ番号と書き出したページ数が並んで表示されます。RETURN 11: エディターへ戻りました。
入力済みのコードを維持したまま、次の実装へ進みます。実行4/4: ページ順を逆順へ並び替える。reorderコマンドへ4-1と指定し、ページを逆順にしたPDFを作ります。
元の順序と新しい順序が並んで表示されるため、変化を目で確認できます。確認4/4: ページ順を逆順へ並び替える。reorderコマンドへ4-1と指定し、ページを逆順にしたPDFを作ります。
元の順序と新しい順序が並んで表示されるため、変化を目で確認できます。RETURN 12: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
学習内容のまとめ。
PdfReader(str(path))で開いてlen(reader.pages)から総ページ数を取得typer.Typer(add_completion=False)で補完用オプションを出さない カンマで分割して前後の空白を除去add_completion=Falseで補完用オプションを非表示 小さく実行確認しながら完成状態まで段階的に組み立てる エンディング。
Python研修はCodeCampでご確認ください。
PDFを扱うpypdfとCLIを作るTyperとは
今回使用する主要なライブラリについて、役割と使い分けを順番に確認します。
PDFを読み書きするライブラリ「pypdf」
pypdfは、既存のPDFを読み込み、ページ単位で取り出して別のファイルへ書き出せるPython製のPDF操作ライブラリです。読み込み担当のPdfReaderと書き出し担当のPdfWriterが分かれているため、どのページをどの順でどこへ出すかを、Python側のリストとループで自由に決められます。
今回のCLIでは結合も分割も並び替えも、PdfReaderから取り出したページをPdfWriterへ積み直す同じ形に落ち着きました。白紙ページの生成や注釈の追加にも対応するので、動作確認用のサンプルPDFを外部素材なしで用意できる点も助かります。
このCLIがpypdfから実際に呼び出している機能を、コード上の記述と対応させて並べます
- PdfReader(str(path))で開いてlen(reader.pages)から総ページ数を取得
- reader.pages[番号 - 1]で0始まりの添字へ変換して取り出す
- writer.add_page(page)でページを1件ずつ積む
- writer.add_blank_page(width=420, height=595)で白紙を追加
- FreeTextのrectとfont_sizeで注釈の位置と文字サイズを指定
- dest.open("wb")で開いたファイルへwriter.writeを渡す
コマンドを組み立てるライブラリ「Typer」
Typerは、Pythonの関数へ少しの目印を付けるだけでコマンドラインの入口を用意できるCLIフレームワークです。引数の型注釈をそのまま解釈するので、受け取った文字列をPathへ変換する処理や、必須と省略可の区別を自分で書く必要がありません。ヘルプ文はdocstringとhelp引数から組み立てられ、--helpを実行すればサブコマンドの一覧が並びます。
今回は1つのアプリへmerge・split・reorder・sampleの4つを登録し、用途ごとにコマンドを切り替える構成にしました。
TyperがこのCLIで肩代わりしている作業を、ソース内の記述と対応させて挙げます
- typer.Typer(add_completion=False)で補完用オプションを出さない
- @app.command("merge")のように公開名を明示して登録
- typer.Argument(...)で省略できない入力を宣言
- typer.Option(..., "--ranges", "-r")で短縮形も同時に定義
- List[Path]型で可変個のファイルを受け取る
- typer.BadParameterを送出して使い方の誤りを通知
Python・pypdfで開発する場合の環境構築
この記事のセットアップ手順と掲載コードは、Windows 11 Pro、PowerShell 5.1、Python 3.13.3で動作確認しています。仮想環境を有効化せず、その中のPythonを直接指定するため、以下のコマンドはPowerShellとコマンドプロンプト(cmd)の両方で使えます。
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install pypdf typer
macOS・Linuxでは仮想環境内のPythonパスが異なります。今回の動作確認環境とは異なるため、以下は環境差分を補う参考手順です。
python3 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install pypdf typer
- パスワード付きPDFはpypdfがそのままでは開けないため、事前に保護を解除したファイルを入力にします。
- sampleコマンドが作るPDFは空白ページに注釈でページ番号を入れたもので、並び替え結果をビューアで目視確認できます。
- PowerShellでカンマを含む値を渡すときは、--ranges "1-2,3-6" のように引用符で囲むと安全です。
PDF結合・分割・並び替えCLIの要件定義
目的は、pypdfとTyperを使い、PDFの結合・分割・並び替えと確認用サンプルの生成を1つのCLIへまとめ、処理したページ数と出力先をコマンド出力で確認できるようにすることです。
対象者として、Pythonの基本文法を一通り学び、pypdfによるPDF操作とTyperでのサブコマンド設計を、手を動かしながら学びたい人を想定しています。
完成物は、pypdfとTyperで作る、merge・split・reorder・sampleの4サブコマンドを備え、出力ファイルのページ数まで表示するPDF操作CLIです。
実装へ入る前に、機能・品質・受け入れ条件を分けて確認します。
機能要件
- 複数のPDFを指定した順に1ファイルへ結合するmergeコマンド
- ページ範囲を指定して複数ファイルへ切り出すsplitコマンド
- 指定した順序でページを並べ直すreorderコマンド
- 動作確認用のPDFを生成するsampleコマンド
- カンマ区切りのページ指定を1始まりの番号へ展開
- 「5-2」のような降順指定で逆順のページ列を取得
- --outputや--out-dirによる出力先の変更
- 結合時に読み込んだ各PDFのページ数を表示
- 分割時に出力先と元のページ番号を1件ずつ表示
- 並び替え時に元の順序と新しい順序を並べて表示
- 書き出し後のファイルを開き直してページ数を表示
非機能要件
- add_completion=Falseで補完用オプションを非表示
- コマンドとオプションにヘルプ文を用意
- 入力PDFが存在しない場合はBadParameterで中断
- ページ指定が空の場合はBadParameterで中断
- 整数として解釈できないページ指定を拒否
- 1から総ページ数の外にある番号を拒否
- 出力先ディレクトリをmkdirで自動作成
- パス操作をpathlibのPathへ統一
- from __future__ import annotationsによる型注釈の遅延評価
- 単一ファイル構成でmain関数を入口にする
- 元のPDFは読み込みのみで変更しない
実装方針
今回はpypdfとTyperの基本動作を追いやすくするため、PDF結合・分割・並び替えCLI本体を1つのPythonファイルへまとめます。
入力、判定、結果表示の役割を分け、実行結果を確認しながら機能を積み上げます。
PDF結合・分割・並び替えCLIを安全に組み立てるための実装方針は次のとおりです。
- add_completion=Falseで補完用オプションを非表示
- コマンドとオプションにヘルプ文を用意
- 入力PDFが存在しない場合はBadParameterで中断
- ページ指定が空の場合はBadParameterで中断
- 整数として解釈できないページ指定を拒否
- 1から総ページ数の外にある番号を拒否
- 出力先ディレクトリをmkdirで自動作成
- パス操作をpathlibのPathへ統一
- from __future__ import annotationsによる型注釈の遅延評価
- 単一ファイル構成でmain関数を入口にする
- 元のPDFは読み込みのみで変更しない
完成と判断する条件
- sampleでcover.pdfとbody.pdfが出力先へ作られる
- cover.pdfが2ページ、body.pdfが4ページになる
- cover.pdfとbody.pdfの結合で6ページのPDFが得られる
- --ranges 1-2,3-4の分割で2ファイルが出力される
- 分割後の各ファイルが指定範囲どおりのページ数になる
- --order 4-1で新しい順序が[4, 3, 2, 1]と表示される
- 存在しないPDFを指定すると理由付きで停止する
- 4つのサブコマンドが終了コード0で完了する
pypdfとTyperでPDF操作CLIを作る際の重要ポイント
3つのコマンドに共通する中心は、1-3,5のような文字列を、実際に扱えるページ番号の並びへ翻訳する処理です。split_tokensがカンマで区切り、expand_tokenが1つのトークンを番号の並びへ広げます。範囲の書き方は昇順でも降順でもよく、5-2と書けば5,4,3,2の逆順が得られるため、並び替えの指定も同じ仕組みで表現できました。
番号が確定した後はPdfWriterへ順に積んでwrite_pdfで保存するだけなので、コマンドごとの差は出力先と表示メッセージにほぼ限られます。
1-3,5という指定が番号の並びへ変わるまでの流れと、その途中で行う検査を順に示します
- カンマで分割して前後の空白を除去
- 空文字だけになったトークンを捨てる
- 残りが0件ならBadParameterで中断
- ハイフンの有無で単一指定と範囲指定を判定
- startがend以下なら+1、そうでなければ-1で刻む
- 展開前に両端が1からtotalに収まるか確認
split_tokensでカンマ区切りを分解する処理
利用者は1-3, 5のように空白を混ぜて書くことがあります。split_tokensでは各要素へstripをかけ、空になった要素を落としてから残りを返す形にしました。
末尾のカンマや空白だけの指定は、そのまま先へ進めても後段で意味の分かりにくいエラーになります。トークンが1件も残らないときは、書き方の例を添えたメッセージで早めに止めます。
実際の入力がどのトークンへ分解されるかを例示します
- 1-3,5は1-3と5の2件
- 空白入りの1-2 , 3は前後を除いた2件
- 1-2,,3は空要素を捨てた2件
- カンマと空白だけの指定は0件となり中断
expand_tokenで範囲と逆順を展開する仕組み
トークンにハイフンが含まれる場合はpartitionで前後へ分け、それぞれを整数へ変換します。含まれない場合は同じ値を開始と終了へ入れ、1ページだけの範囲として扱う書き方に揃えました。
刻み幅はstartとendの大小から決まります。startのほうが大きければ-1になり、rangeの終端をend+stepにすることで、昇順でも降順でも最後のページを含められました。
トークンごとの展開結果を具体的な値で挙げます
- 4は[4]
- 2-5は[2, 3, 4, 5]
- 5-2は[5, 4, 3, 2]
- 3-3は[3]
共通処理を3つのコマンドで使い回す設計
mergeは複数ファイルのページを順に積み、splitはトークンごとに別ファイルへ書き出し、reorderは展開した番号を1つの並びへまとめて書き出します。入口の引数は違っても、内部で行うのはページの取り出しと積み直しです。
共通部分をopen_pdf、split_tokens、expand_token、write_pdfへ切り出したため、コマンド側は読み込み・組み立て・表示の3ステップだけになりました。動作確認用のsampleも、保存には同じwrite_pdfを使っています。
各コマンドが共通関数をどう組み合わせているかを整理します
- mergeはopen_pdfとwrite_pdfだけを利用
- splitはトークンごとにwrite_pdfを呼ぶ
- reorderは展開した番号を1つのリストへ連結
- sampleはPdfWriterとwrite_pdfで新規作成
PythonでTyperのサブコマンド設計とページ指定の書式
Typerは関数の引数に型ヒントを書くだけで、コマンドライン引数やオプションへ変換してくれます。files: List[Path]と書けば、複数のPDFパスをそのまま受け取れます。
この書き方を選んだ理由は、公式ドキュメントが型ヒントを土台にした設計を前面に掲げているからです。パース処理を自前で書かずに済み、ヘルプ文も同じ定義から生成されます。
ページ指定は1-3,5のような文字列で受け取り、split_tokensとexpand_tokenで1始まりのページ番号へ展開しました。降順の5-2は逆順として扱うため、並び替えでも同じ書式が使えます。
このセクションの用語
- 型ヒント
- 引数や戻り値の型を
path: Pathのように書き添える記法です。Typerはこれを読み取って入力を変換します。 - トークン
- 文字列を意味のある最小単位に切り分けたものです。ここでは
1-3や5といったカンマ区切りの1片を指します。 - ページ指定文字列
-
1-3,5のように人が読みやすい形でページ範囲を書いた文字列です。CLIの引数として受け取ります。
| 指定例 | 展開されるページ | 使いどころ |
|---|---|---|
| 1-3 | 1,2,3 | 先頭3ページだけを切り出す |
| 5 | 5 | 単独ページの指定 |
| 6-5 | 6,5 | 逆順に積み直す並び替え |
| 1-2,3-6 | 1,2 / 3,4,5,6 | 2つのファイルへ分割 |
参考:
©Typer公式ドキュメントTyper is a library for building CLI applications that users will love using and developers will love creating. Based on Python type hints.
PythonでPDF結合・分割・並び替えCLIの完成コード
全体の構成は、上から「CLIの定義」「ページ指定の解析」「PDFの入出力」「各サブコマンド」の4ブロックです。共通の下ごしらえを先に置くと、各コマンドの中身は数行で済みます。
PDF操作はPdfReaderで読み、PdfWriterに必要なページを積み、最後にファイルへ書き出す流れで統一しました。結合も分割も並び替えも、積むページの選び方が違うだけです。
不正な入力はtyper.BadParameterで中断し、理由が伝わるメッセージを表示します。ここからは、実装で押さえておきたいポイントを、共通処理の切り出し方と書き出し後のページ数確認という観点でまとめます。
このセクションの用語
- PdfReader
- 既存のPDFを開いてページを取り出すための
pypdfのクラスです。.pagesでページの一覧を扱えます。 - PdfWriter
- 新しいPDFを組み立てて保存するための
pypdfのクラスです。add_pageで1ページずつ積み上げます。 - デコレータ
-
@app.command()のように関数の上に付ける印です。関数に追加の役割を与え、ここではCLIコマンドとして登録します。 - Path
- 標準ライブラリ
pathlibのパス表現です。is_file()やmkdir()など、ファイル操作をメソッドで書けます。
"""pypdfとTyperで作る、PDFの結合・分割・並び替えCLI。"""
from __future__ import annotations
from pathlib import Path
from typing import List
import typer
from pypdf import PdfReader, PdfWriter
from pypdf.annotations import FreeText
# CLIアプリの定義
app = typer.Typer(
add_completion=False,
help="pypdfでPDFの結合・分割・並び替えを行うCLI",
)
# 動作確認用サンプルPDFの構成(ファイル名: ページ数)
SAMPLE_SPEC = {"cover": 2, "body": 4}
# ページ指定文字列の解析
def split_tokens(spec: str) -> list[str]:
"""カンマ区切りのページ指定を、空要素を除いたトークンへ分解する。"""
tokens = [token.strip() for token in spec.split(",") if token.strip()]
if not tokens:
raise typer.BadParameter("ページ指定が空です(例: 1-3,5)")
return tokens
def expand_token(token: str, total: int) -> list[int]:
"""'4'や'2-5'(降順の'5-2'も可)を1始まりのページ番号リストへ展開する。"""
try:
if "-" in token:
head, _, tail = token.partition("-")
start, end = int(head), int(tail)
else:
start = end = int(token)
except ValueError:
raise typer.BadParameter(f"ページ指定を解釈できません: {token}")
for number in (start, end):
if not 1 <= number <= total:
raise typer.BadParameter(
f"ページ番号が範囲外です: {number}(有効範囲 1-{total})"
)
step = 1 if start <= end else -1
return list(range(start, end + step, step))
# PDFの入出力
def open_pdf(path: Path) -> PdfReader:
"""入力PDFを開く。存在しない場合は理由を示して中断する。"""
if not path.is_file():
raise typer.BadParameter(f"PDFが見つかりません: {path}")
return PdfReader(str(path))
def write_pdf(writer: PdfWriter, dest: Path) -> int:
"""出力先ディレクトリを用意して書き出し、書き出した結果のページ数を返す。"""
dest.parent.mkdir(parents=True, exist_ok=True)
with dest.open("wb") as fp:
writer.write(fp)
return len(PdfReader(str(dest)).pages)
# 複数PDFの結合
@app.command("merge")
def merge(
files: List[Path] = typer.Argument(..., help="結合するPDF(1件以上)"),
output: Path = typer.Option(
Path("merged.pdf"), "--output", "-o", help="結合結果の出力先"
),
) -> None:
"""複数PDFを指定した順に結合する。"""
print(f"[merge] {len(files)}個のPDFを指定順に結合します")
writer = PdfWriter()
for path in files:
reader = open_pdf(path)
for page in reader.pages:
writer.add_page(page)
print(f" 読み込み: {path} ({len(reader.pages)}ページ)")
written = write_pdf(writer, output)
print(f" 出力: {output} ({written}ページ)")
# ページ範囲によるPDF分割
@app.command("split")
def split(
src: Path = typer.Argument(..., help="分割するPDF"),
ranges: str = typer.Option(
..., "--ranges", "-r", help="ページ範囲をカンマ区切りで指定(例: 1-3,4-6,7)"
),
out_dir: Path = typer.Option(
Path("split_out"), "--out-dir", help="分割結果の出力ディレクトリ"
),
) -> None:
"""ページ範囲を指定してPDFを複数ファイルへ分割する。"""
reader = open_pdf(src)
total = len(reader.pages)
print(f"[split] {src} ({total}ページ)を範囲 {ranges} で分割します")
for token in split_tokens(ranges):
numbers = expand_token(token, total)
writer = PdfWriter()
for number in numbers:
writer.add_page(reader.pages[number - 1])
dest = out_dir / f"{src.stem}_p{token}.pdf"
written = write_pdf(writer, dest)
print(f" 出力: {dest} (元のページ {numbers} / {written}ページ)")
# PDFの並び替え
@app.command("reorder")
def reorder(
src: Path = typer.Argument(..., help="並び替えるPDF"),
order: str = typer.Option(
..., "--order", help="新しいページ順(例: 3,1-2 / 5-1と書くと逆順)"
),
output: Path = typer.Option(
Path("reordered.pdf"), "--output", "-o", help="並び替え結果の出力先"
),
) -> None:
"""指定した順序でページを並び替えた新しいPDFを作る。"""
reader = open_pdf(src)
total = len(reader.pages)
numbers: list[int] = []
for token in split_tokens(order):
numbers.extend(expand_token(token, total))
writer = PdfWriter()
for number in numbers:
writer.add_page(reader.pages[number - 1])
written = write_pdf(writer, output)
print(f"[reorder] {src} ({total}ページ)のページを並び替えました")
print(f" 元の順序 : {list(range(1, total + 1))}")
print(f" 新しい順序: {numbers}")
print(f" 出力: {output} ({written}ページ)")
# 動作確認用サンプルPDFの生成
@app.command("sample")
def sample(
out_dir: Path = typer.Option(
Path("samples"), "--out-dir", help="サンプルPDFの出力ディレクトリ"
),
) -> None:
"""ページ番号を書いた動作確認用のPDFを作る。"""
print("[sample] 動作確認用のPDFを作成します")
for name, count in SAMPLE_SPEC.items():
writer = PdfWriter()
for number in range(1, count + 1):
writer.add_blank_page(width=420, height=595)
note = FreeText(
text=f"{name.upper()} - page {number}",
rect=(40, 430, 380, 520),
font_size="24pt",
font_color="000000",
border_color="ffffff",
background_color="ffffff",
)
writer.add_annotation(page_number=number - 1, annotation=note)
dest = out_dir / f"{name}.pdf"
written = write_pdf(writer, dest)
print(f" 作成: {dest} ({written}ページ)")
def main() -> None:
app()
if __name__ == "__main__":
main()
コード全文は上の折り畳みに入れてあるので、全部を上から読む必要はありません。ここでは特に重要な部分だけを抜き出して、何をしているのか順番に見ていきます。
typer.Typerでアプリの入口を作る
app = typer.Typer(
add_completion=False,
help="pypdfでPDFの結合・分割・並び替えを行うCLI",
)CLI全体の器を1つ作り、以降の関数を@app.command()でぶら下げます。helpに書いた文章は--helpを付けたときの説明としてそのまま表示されます。add_completion=Falseは補完設定用のオプションを増やさない指定です。
split_tokensでページ指定を分解する
def split_tokens(spec: str) -> list[str]:
"""カンマ区切りのページ指定を、空要素を除いたトークンへ分解する。"""
tokens = [token.strip() for token in spec.split(",") if token.strip()]
if not tokens:
raise typer.BadParameter("ページ指定が空です(例: 1-3,5)")
return tokens1-3, 5,のような入力をカンマで割り、前後の空白と空要素を落としています。全部消えて空になった場合は例文つきで中断するので、利用者は書き直し方がすぐ分かります。
expand_tokenで範囲を展開・検証する
for number in (start, end):
if not 1 <= number <= total:
raise typer.BadParameter(
f"ページ番号が範囲外です: {number}(有効範囲 1-{total})"
)
step = 1 if start <= end else -1
return list(range(start, end + step, step))まず開始と終了のページ番号が総ページ数の中に収まっているかを確かめます。そのうえでstartが大きい場合はstepを-1にして、6-5のような降順指定を逆順のページ並びとして返します。
open_pdfでPdfReaderを安全に開く
def open_pdf(path: Path) -> PdfReader:
"""入力PDFを開く。存在しない場合は理由を示して中断する。"""
if not path.is_file():
raise typer.BadParameter(f"PDFが見つかりません: {path}")
return PdfReader(str(path))ファイルの有無を先に確かめてからPdfReaderを作ります。存在しないときは長いトレースバックではなく、どのパスが見つからなかったかを示す1行で止まります。
write_pdfで保存とページ数確認
def write_pdf(writer: PdfWriter, dest: Path) -> int:
"""出力先ディレクトリを用意して書き出し、書き出した結果のページ数を返す。"""
dest.parent.mkdir(parents=True, exist_ok=True)
with dest.open("wb") as fp:
writer.write(fp)
return len(PdfReader(str(dest)).pages)mkdir(parents=True, exist_ok=True)で出力先フォルダが無ければ作ります。保存した直後に書き出したPDFを読み直し、ページ数を返すところがこの関数の肝です。
mergeコマンドの引数とオプション定義
@app.command("merge")
def merge(
files: List[Path] = typer.Argument(..., help="結合するPDF(1件以上)"),
output: Path = typer.Option(
Path("merged.pdf"), "--output", "-o", help="結合結果の出力先"
),
) -> None:typer.Argumentは位置引数、typer.Optionは--outputのような名前つき引数です。...は必須という意味なので、結合するPDFは1件以上渡す必要があります。出力先は省略時にmerged.pdfとなります。
PdfWriterのadd_pageでページを積む
writer = PdfWriter()
for path in files:
reader = open_pdf(path)
for page in reader.pages:
writer.add_page(page)
print(f" 読み込み: {path} ({len(reader.pages)}ページ)")空のPdfWriterに、入力ファイルを順番に開いてページを追加していきます。並び順は引数に書いた順そのままなので、コマンドの見た目と結果が一致します。読み込んだページ数を都度表示して途中経過も追えるようにしました。
共通処理:解析と書き出しを分離
add_page:入力順にページを積む
書き出し後:再読込でページ数確認
PythonのpypdfとTyper利用時に起きやすいエラーと対処法
このCLIは入力の不備をtyper.BadParameterで受け止め、原因が読み取れるメッセージを表示して中断します。原因さえ分かれば、対処はほとんど1行で済みます。
実行環境まわりのつまずきも一般に起こりやすい部分です。仮想環境の有効化を忘れると、pypdfが見つからないという典型的なエラーになります。
このセクションの用語
- BadParameter
-
Typerが用意する引数エラー用の例外です。投げるとトレースバックではなく短いエラーメッセージが表示されます。 - ModuleNotFoundError
- importしたライブラリが実行中の環境に無いときに出るエラーです。インストール先と実行環境のずれが主な原因になります。
| エラー例 | 原因 | 対処 |
|---|---|---|
| PDFが見つかりません: pdfs/cover.pdf | 指定パスにファイルが無い(実行ディレクトリ違いやtypo) |
lsで場所を確かめ、先にpython subject.py sample --out-dir pdfsでサンプルを作る |
| ページ番号が範囲外です: 8(有効範囲1-6) | 元PDFの総ページ数を超えるページを指定した |
--rangesや--orderの数字を元PDFのページ数以内にそろえる |
| ページ指定を解釈できません: 1〜3 | 全角記号など、半角ハイフン以外の区切りを使った |
1-3,5のように半角ハイフンと半角カンマで書き直す |
| ModuleNotFoundError: No module named 'pypdf' | 仮想環境が有効でない、または別環境で実行している | 目的の仮想環境を有効化してからpip install pypdf typerを実行する |
| Error: Missing option '--ranges' / '-r' | 必須オプションを渡さずにsplitを実行した |
-r 1-2,3-6のように範囲指定を必ず付けて実行する |
PDF結合・分割・並び替えCLIで注意したい点
いちばん引っかかりやすいのは、ページ番号の数え方です。このCLIは画面に見えるページ番号と同じ1始まりで指定でき、そのずれはexpand_tokenが吸収しています。
降順トークンの挙動も覚えておくと便利です。5-2は逆順として展開されるので、reorderで後半を先頭へ持ってくる指定が短く書けます。
出力先はwrite_pdfがフォルダごと用意しますが、同名のファイルがあればそのまま上書きされます。ページ指定の書式と出力ファイルの扱いについて、押さえておきたいポイントを整理しておきましょう。
ページ指定:1始まりで書く
降順トークン:5-2は逆順展開
範囲外指定:エラーで即中断
出力先:同名ファイルは上書き
PDF結合・分割・並び替えCLIの動作確認
動作確認は、サンプル生成から結合・分割・並び替えまでを一通り流しました。実行したコマンドは5回で、いずれも終了コード0で完了しています。
最初にpython subject.py sample --out-dir pdfsを実行し、cover.pdf(2ページ)とbody.pdf(4ページ)を用意しました。手元にPDFが無くても、ここから先の手順をそのまま試せます。
続けて結合、範囲分割、並び替えの順に実行し、最後は分割済みファイルを逆順に結合してswapped.pdfを作りました。各コマンドが読み込みと出力のページ数を表示するため、結果はターミナルだけで追えます。
このセクションの用語
- 終了コード
- コマンドが終わるときに返す数値です。0は正常終了を意味し、シェルスクリプトでの成否判定にも使われます。
- 標準出力
-
printで書いた文字がターミナルへ表示される流れのことです。処理の途中経過を確認する窓口になります。
実行した5つのコマンドと、出力で確認できた内容です。
-
sample --out-dir pdfs:pdfs配下にcover.pdfとbody.pdfを生成 -
merge pdfs/cover.pdf pdfs/body.pdf --output pdfs/merged.pdf: 2ページ+4ページで6ページのmerged.pdf -
split pdfs/merged.pdf --ranges 1-2,3-6 --out-dir pdfs/parts:merged_p1-2.pdfとmerged_p3-6.pdfへ分割 -
reorder pdfs/merged.pdf --order 6-5,1,2-4 --output pdfs/reordered.pdf: 6,5,1,2,3,4の順に積み直し -
merge pdfs/parts/merged_p3-6.pdf pdfs/parts/merged_p1-2.pdf --output pdfs/swapped.pdf: 後半と前半を入れ替えて結合





PythonのpypdfのCLIが活きる実務の活用シーン
定型のPDF作業は、手順が決まっているほどCLIと相性が良い領域です。毎月同じ結合をするなら、コマンドをシェルスクリプトに並べておくだけで再現できます。
分割と結合を組み合わせると、章の入れ替えのような作業も1本の流れで表現できます。今回も分割した2ファイルを逆順に結合し、swapped.pdfを作りました。
このセクションの用語
- バッチ処理
- 同じ手順をまとめて自動実行する方式です。CLIならコマンドを並べたファイルを走らせるだけで実現できます。
- シェルスクリプト
- 複数のコマンドを1つのファイルに書いて順に実行させる仕組みです。定型作業の手順書代わりにもなります。
| 使える場面 | 具体的な使い方 |
|---|---|
| 月次レポートの取りまとめ | 表紙と本文をmergeで入力順に結合し、表示されるページ数で綴じ漏れを確認する |
| 章ごとの配布資料づくり |
split --ranges 1-2,3-6で章単位のファイルへ切り出し、--out-dirで配布用フォルダに集める |
| スキャン資料のページ順修正 |
reorder --order 6-5,1,2-4で逆順や差し込み位置の入れ替えを一度に済ませる |
| 提案書の構成入れ替え |
splitで章を切り出してからmergeの引数順を変え、章立ての試作版を何パターンも作る |
| 研修やテストのダミー資料 |
sample --out-dir pdfsでページ番号入りPDFを生成し、他ツールの動作確認用データにする |
PDF結合・分割・並び替えCLI開発のまとめ
Typerでコマンドの受け口を作り、pypdfのPdfReaderとPdfWriterでページを積み替えるだけで、実用的なPDFツールになりました。ページ指定パーサと書き出し関数を共通化した結果、各サブコマンドの中身はごく短く収まっています。
動作確認では5回のコマンドがすべて終了コード0で完了し、生成・結合・分割・並び替え・再結合まで一通り通せました。sampleコマンドがあるので、手元に資料が無い状態からでも同じ流れを追えます。
まずはsampleでPDFを作り、--helpを眺めながらmergeから試すのがおすすめです。書式に慣れたら、自分の業務フローに合わせて育てていけます。
今回のCLIから広げやすい改造アイデアです。
-
--dry-runを追加し、実際に書き出す前に展開後のページ番号だけ表示する - 出力ファイル名に日付を差し込み、上書きではなく履歴として残す
- よく使う範囲指定を設定ファイルに書き出し、コマンドを短く呼べるようにする
参考にした一次情報
- ^ pypdf documentation. https://pypdf.readthedocs.io/en/stable/, (参照26-09-02).
- ^ pypdf: Merging PDF files. https://pypdf.readthedocs.io/en/stable/user/merging-pdfs.html, (参照26-09-02).
- ^ pypdf: Adding PDF Annotations. https://pypdf.readthedocs.io/en/stable/user/adding-pdf-annotations.html, (参照26-09-02).
- ^ Typer documentation. https://typer.tiangolo.com/, (参照26-09-02).
※内容は執筆時点のものです。ライブラリやサイトの仕様は変わる可能性があるため、公式ドキュメントもあわせてご確認ください。






