【Python】pdfplumberとpandasでPDFの表をCSVへ一括変換するCLIを作ってみた

【Python】pdfplumberとpandasでPDFの表をCSVへ一括変換するCLIを作ってみた

公開:
CodeCampが提供するDX人材育成が可能なプログラミングやITが学べる公開講座

PDFに載っている表を手作業でExcelへ写す作業は、地味に時間を奪います。そこで、PDFから表を見つけてCSVへ書き出すコマンドラインツールをpandasで作りました。検証用サンプルPDFの生成、ページごとの表検出、全表のCSV一括出力、抽出結果の一覧表示を4つのサブコマンドにまとめた自己完結型の構成です。

サンプルPDFはpdfplumberの既定のlines戦略が素直に効く形式にしてあります。実際にCLIツールを5回のコマンドで実行し、すべて終了コード0で動作しました。ここからはコードの要点と実行結果、初心者がつまずきやすい箇所を順に見ていきます。

pdfplumberの基本概念、要件定義、実装、動作確認までを順番に学べる構成です。動画は目次から確認したい場面へ移動でき、本文だけでも手順と考え方が完結します。

【Python】pdfplumberとpandasでPDFの表をCSVへ一括変換するCLIを作ってみた|フルカリキュラム
動画の内容をテキストで確認する

オープニング。pdfplumberとpandasを使ってPDF表のCSV一括変換CLIを作るカリキュラムを始めます。概要紹介。

pdfplumberとpandasの役割と使い方を学ぶPDF表のCSV一括変換CLIの要件を整理する 完成コードと実行結果を確認する 最後に実コマンドとファイル状態で完成挙動を確かめる 具体的にやること。

検証用サンプルPDFを指定ページ数で生成する 生成したPDFのページ数と表の数とサイズを表示する ページ別・表別に表の行数と列数とbboxを表示する 検出した表の列名と先頭行を表示する --pageで検出対象を1ページに絞る 実装環境・必須アプリ。

OS:Windows 11 Pro Python:3.13.3シェル:PowerShell 5.1必須アプリ:コードエディター、ターミナル、エクスプローラー パッケージ:pip、pdfplumber、pandas PDF解析のpdfplumberとpandasとは。

pdfplumber:pdfplumberは、PDFのページからテキストや罫線の座標を読み取り、表として組み立て直せるPDF解析ライブラリpandas:pandasは、行と列を持つデータをDataFrameという構造で扱い、CSVの読み書きまで任せられるデータ分析ライブラリ pdfplumberでPDFの表をCSV化するCLIの要点。

make-sampleは--outと--pagesでPDFを生成detectは--pageで対象ページを絞り込みextractは--out-dirへCSVを書き出す PDF表のCSV一括変換CLIの要件定義。

make-sampleで3ページ・表5個のサンプルPDFが作られるdetectの最終行に検出した表が5個と表示されるpage 1 / table 1が4行4列で表示される --page 2の指定で対象がpage 2のみになるextractでCSV5件・合計25行が書き出される 出力ファイル名がsample_tables_p01_t01.csv形式になる INTRO: Monaco EditorでPDF表のCSV一括変換CLIを実装。

コードを1行ずつ入力し、補完と自動インデントを使いながら実行結果を確認します。LINE 002: モジュール概要(1行目)。このファイル全体がどんなツールかを説明するdocstringの書き出しです。

自前のPDFパーサーとpandasを組み合わせて、PDF内の表をCSVに変換する自己完結型のCLIであることを示しています。LINE 003: モジュール概要(2行目)。外部ライブラリpdfplumberが無い環境でも動くように設計されていることを説明しています。

サンプルPDFの生成フォーマットに合わせた専用のパーサーを使う方針が書かれています。LINE 004: モジュール概要(3行目)。専用パーサーが罫線とテキストを読み取って表を復元するという、このプログラムの中心的な処理内容を説明しています。

docstringの締めくくりの行です。LINE 006: 将来のアノテーション構文を有効化。型ヒントの評価方法をPythonの将来バージョン向けに切り替えるおまじないの行です。

関数引数や戻り値に書いた型ヒントを実行時に評価せず文字列として扱うようにします。LINE 008: argparseモジュールの読み込み。コマンドライン引数を解析するための標準ライブラリargparseを読み込んでいます。

この後のCLIコマンド定義で使われます。LINE 009: 正規表現モジュールの読み込み。文字列からパターンを検索・抽出するための標準ライブラリreを読み込んでいます。

PDFのバイト列から表やテキストを取り出す処理で使われます。LINE 010: パス操作クラスの読み込み。ファイルパスを扱いやすくするPathクラスをpathlibモジュールから読み込んでいます。

PDFファイルの読み書きや出力先ディレクトリの操作に使います。LINE 012: pandasライブラリの読み込み。表形式データを扱うためのpandasライブラリをpdという別名で読み込んでいます。

抽出した表をDataFrameに変換したりCSVへ書き出したりする際に使います。LINE 016: PDFページ幅の定数。サンプルPDFの用紙幅をポイント単位で定めた定数です。

A4サイズに相当する595.0という値を設定しています。LINE 017: PDFページ高さの定数。サンプルPDFの用紙高さをポイント単位で定めた定数です。

A4サイズに相当する842.0という値を設定しています。LINE 018: 余白の定数。ページの端からどれだけ余白を取るかを示す定数です。

タイトルや表の描画開始位置を決める際に使われます。LINE 019: 表の行の高さ定数。表の1行分の高さをポイント単位で定めた定数です。

罫線やセル文字の位置を計算するときに使われます。LINE 020: セル文字のフォントサイズ。表の中身の文字を描くときのフォントサイズを定めた定数です。

後で表の範囲を判定する目印としても使われます。LINE 021: キャプション文字のフォントサイズ。表の見出し(キャプション)を描くときのフォントサイズを定めた定数です。

パーサー側で表の開始位置を見分ける目印にもなります。LINE 022: タイトル文字のフォントサイズ。ページの大見出しを描くときのフォントサイズを定めた定数です。

他の文字と区別するための基準値になります。LINE 026: サンプル表データの一覧開始。サンプルPDFに埋め込む複数ページ分のテンプレートをまとめたリストの定義開始です。

この中に各ページのタイトルと表データが順番に格納されます。LINE 027: 1つ目のテンプレート開始。1ページ目に使う売上レポート用テンプレートの辞書の始まりです。

この後にタイトルと表の情報が続きます。LINE 028: ページタイトルの設定。1つ目のテンプレートのページタイトルを「Sample Mart Sales Report」という文字列で設定しています。

LINE 029: 表リストの開始。このテンプレートに含まれる表をまとめて格納するためのリストの始まりです。この中に複数の表定義が並びます。

LINE 030: 表Aの定義開始。地域別四半期売上を示す1つ目の表の定義が始まる行です。この後にキャプションや列幅、見出し、行データが続きます。

LINE 031: 表Aのキャプション。表Aの見出し文字列として「Table A: Quarterly Sales by Region」を設定しています。この文字はパーサーが表の開始を見つける目印にもなります。

LINE 032: 表Aの列幅設定。表Aの各列の幅をポイント単位のリストで指定しています。この値を使って罫線やセルの位置を計算します。

LINE 033: 表Aの見出し行。表Aのヘッダー行として「Region」「Q1」「Q2」「Q3」という4つの列名を定義しています。LINE 034: 表Aの本体行リスト開始。

表Aに含まれる各行のデータをまとめて格納するリストの始まりです。この後に地域ごとの売上データが並びます。LINE 035: 東京の売上データ行。

東京の四半期売上データとして地域名と3つの数値を1行分並べています。LINE 036: 大阪の売上データ行。大阪の四半期売上データとして地域名と3つの数値を1行分並べています。

LINE 037: 名古屋の売上データ行。名古屋の四半期売上データとして地域名と3つの数値を1行分並べています。LINE 038: 福岡の売上データ行。

福岡の四半期売上データとして地域名と3つの数値を1行分並べています。LINE 039: 表Aの本体行リスト終了。表Aの行データリストを閉じている行です。

ここまでで4地域分の売上行がまとまりました。LINE 040: 表Aの定義終了。表Aの辞書定義を閉じている行です。

キャプション・列幅・見出し・行データがひとまとまりになりました。LINE 041: 表Bの定義開始。人気商品ランキングを示す2つ目の表の定義が始まる行です。

この後にキャプションや列幅、見出し、行データが続きます。LINE 042: 表Bのキャプション。表Bの見出し文字列として「Table B: Top Products」を設定しています。

LINE 043: 表Bの列幅設定。表Bの各列の幅をポイント単位のリストで指定しています。商品名を表示する列だけ幅を広く取っています。

LINE 044: 表Bの見出し行。表Bのヘッダー行として「Rank」「Product」「Price」「Units」という4つの列名を定義しています。LINE 045: 表Bの本体行リスト開始。

表Bに含まれる各行のデータをまとめて格納するリストの始まりです。この後に商品ごとの順位データが並びます。LINE 046: 1位の商品データ行。

コールドブリューコーヒーの順位・商品名・価格・販売数を1行分並べています。LINE 047: 2位の商品データ行。オリーブオイルの順位・商品名・価格・販売数を1行分並べています。

LINE 048: 3位の商品データ行。全粒粉パスタの順位・商品名・価格・販売数を1行分並べています。LINE 049: 4位の商品データ行。

アーモンドグラノーラの順位・商品名・価格・販売数を1行分並べています。LINE 050: 5位の商品データ行。スパークリングウォーターの順位・商品名・価格・販売数を1行分並べています。

LINE 051: 表Bの本体行リスト終了。表Bの行データリストを閉じている行です。ここまでで5商品分のランキング行がまとまりました。

LINE 052: 表Bの定義終了。表Bの辞書定義を閉じている行です。キャプション・列幅・見出し・行データがひとまとまりになりました。

LINE 053: 表リストの終了。1つ目のテンプレートに含まれる表のリストを閉じている行です。表Aと表Bの2つがここに格納されました。

LINE 054: 1つ目のテンプレート終了。売上レポート用テンプレートの辞書定義を閉じている行です。タイトルと2つの表がひとまとまりになりました。

LINE 055: 2つ目のテンプレート開始。在庫レポート用テンプレートの辞書の始まりです。この後にタイトルと表の情報が続きます。

LINE 056: 在庫レポートのタイトル設定。2つ目のテンプレートのページタイトルを「Sample Mart Inventory Report」という文字列で設定しています。LINE 057: 在庫レポートの表リスト開始。

このテンプレートに含まれる表をまとめて格納するためのリストの始まりです。LINE 058: 表Cの定義開始。SKU別の在庫状況を示す表の定義が始まる行です。

この後にキャプションや列幅、見出し、行データが続きます。LINE 059: 表Cのキャプション。表Cの見出し文字列として「Table C: Stock by SKU」を設定しています。

LINE 060: 表Cの列幅設定。表Cの各列の幅をポイント単位のリストで指定しています。5列分の幅がここで決まります。

LINE 061: 表Cの見出し行。表Cのヘッダー行として「SKU」「Item」「Stock」「Safety」「Status」という5つの列名を定義しています。LINE 062: 表Cの本体行リスト開始。

表Cに含まれる各行のデータをまとめて格納するリストの始まりです。この後にSKUごとの在庫データが並びます。LINE 063: SKU-1001の在庫データ行。

コールドブリューコーヒーのSKUコード・商品名・在庫数・安全在庫数・状態を1行分並べています。LINE 064: SKU-1002の在庫データ行。オリーブオイルのSKUコード・商品名・在庫数・安全在庫数・状態を1行分並べています。

在庫が安全水準を下回っているためLOWとなっています。LINE 065: SKU-1003の在庫データ行。全粒粉パスタのSKUコード・商品名・在庫数・安全在庫数・状態を1行分並べています。

LINE 066: SKU-1004の在庫データ行。アーモンドグラノーラのSKUコード・商品名・在庫数・安全在庫数・状態を1行分並べています。在庫が少なくLOWとなっています。

LINE 067: SKU-1005の在庫データ行。スパークリングウォーターのSKUコード・商品名・在庫数・安全在庫数・状態を1行分並べています。LINE 068: SKU-1006の在庫データ行。

ライ麦パンのSKUコード・商品名・在庫数・安全在庫数・状態を1行分並べています。LINE 069: SKU-1007の在庫データ行。ギリシャヨーグルトのSKUコード・商品名・在庫数・安全在庫数・状態を1行分並べています。

在庫がわずかでLOWとなっています。LINE 070: SKU-1008の在庫データ行。ダークチョコレートのSKUコード・商品名・在庫数・安全在庫数・状態を1行分並べています。

LINE 071: 表Cの本体行リスト終了。表Cの行データリストを閉じている行です。ここまでで8種類のSKU分の在庫行がまとまりました。

LINE 072: 表Cの定義終了。表Cの辞書定義を閉じている行です。在庫レポートに含まれる唯一の表なのでカンマを付けずに閉じています。

LINE 073: 在庫レポートの表リスト終了。2つ目のテンプレートに含まれる表のリストを閉じている行です。表Cのみがここに格納されました。

LINE 074: 2つ目のテンプレート終了。在庫レポート用テンプレートの辞書定義を閉じている行です。タイトルと表Cがひとまとまりになりました。

LINE 075: 3つ目のページ定義の開始。3ページ目に相当する注文リストのテンプレート定義を、辞書として開始しています。この辞書はSAMPLE_TEMPLATESリストの3番目の要素になります。

LINE 076: ページタイトルの設定。このページの見出しとして表示する文字列を「Sample Mart Order List」に設定しています。サンプルPDF生成時にタイトル行として描画されます。

LINE 077: 表定義リストの開始。このページに含める複数の表定義をまとめるためのリストを開始しています。この中に注文表と送料表の2つが入ります。

LINE 078: 1つ目の表(注文一覧)の開始。注文一覧を表す表定義の辞書を開始しています。以降にキャプション・列幅・見出し・行データを順に設定していきます。

LINE 079: 表の見出し文字列の設定。表の上に表示するキャプションとして「Table D: Orders」を設定しています。これはPDF上で表の直前に描かれるラベルです。

LINE 080: 各列の幅の設定。注文・日付・顧客・金額の4列に対応する幅をポイント単位のリストで指定しています。この幅は罫線や文字の配置座標の計算に使われます。

LINE 081: 表の見出し行の設定。表の一番上に表示する列名として、注文番号・日付・顧客名・金額の4項目を設定しています。この見出しはヘッダー行として描画対象になります。

LINE 082: データ行リストの開始。表の本体データとなる複数の注文レコードをまとめるリストを開始しています。以降の各行が実際の表の内容になります。

LINE 083: 注文データ1件目。注文番号ORD-2401について、日付・顧客名・金額を含む1行分のデータをリストとして定義しています。LINE 084: 注文データ2件目。

注文番号ORD-2402について、日付・顧客名・金額を含む1行分のデータをリストとして定義しています。LINE 085: 注文データ3件目。注文番号ORD-2403について、日付・顧客名・金額を含む1行分のデータをリストとして定義しています。

LINE 086: 注文データ4件目。注文番号ORD-2404について、日付・顧客名・金額を含む1行分のデータをリストとして定義しています。LINE 087: 注文データ5件目。

注文番号ORD-2405について、日付・顧客名・金額を含む1行分のデータをリストとして定義しています。LINE 088: データ行リストの終了。注文一覧の行データの定義をここで閉じています。

合計5件分の注文データがこの表に含まれることになります。LINE 089: 1つ目の表定義の終了。注文一覧を表す表定義の辞書をここで閉じています。

この後に2つ目の表であるTable Eの定義が続きます。LINE 090: 2つ目の表(送料表)の開始。配送料金を表す表定義の辞書を開始しています。

以降にキャプション・列幅・見出し・行データを設定していきます。LINE 091: 送料表のキャプション設定。表の上に表示するキャプションとして「Table E: Shipping Fee」を設定しています。

この文字列は表の直前に描かれます。LINE 092: 送料表の列幅の設定。地域・料金・日数の3列に対応する幅をポイント単位のリストで指定しています。

この幅で罫線位置や文字配置が決まります。LINE 093: 送料表の見出し行の設定。表の一番上に表示する列名として、地域・料金・日数の3項目を設定しています。

この見出しがヘッダー行として使われます。LINE 094: 送料データ行リストの開始。送料表の本体データとなる複数の地域別レコードをまとめるリストを開始しています。

LINE 095: 送料データ1件目。関東地域について、送料と配送日数を含む1行分のデータをリストとして定義しています。LINE 096: 送料データ2件目。

関西地域について、送料と配送日数を含む1行分のデータをリストとして定義しています。LINE 097: 送料データ3件目。九州地域について、送料と配送日数を含む1行分のデータをリストとして定義しています。

LINE 098: 送料データ行リストの終了。送料表の行データの定義をここで閉じています。合計3件分の地域別データがこの表に含まれることになります。

LINE 099: 表定義リストの終了。注文一覧と送料表の2つの表定義をまとめていたリストをここで閉じています。LINE 100: 2つ目の表定義の終了。

送料表を表す表定義の辞書をここで閉じています。この時点で1ページ分に含まれる2つの表の定義が完了しています。LINE 101: 3つ目のページ定義の終了。

注文リストページ全体を表す辞書をここで閉じています。タイトルと2つの表が1つのページ定義としてまとまりました。LINE 102: テンプレートリストの終了。

SAMPLE_TEMPLATES全体のリスト定義をここで閉じています。これで3種類のページテンプレートが用意されたことになります。RUN 1/9: サンプルPDFの表テンプレートを確認する。

サンプルPDFへ描く3ページ分のテンプレートを定義したところです。タイトルと表の件数を表示して、想定どおりのデータが並んでいるか確かめます。CHECK 1/9: 途中実行に成功。

テンプレート数: 3 Sample Mart Sales Report / 表2個 Sample Mart Inventory Report / 表1個 Sample Mart Order List / 表2個 RETURN 01: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 107: エスケープ関数の定義開始。PDFの文字列として安全に扱えるよう記号を変換する関数_escape_pdf_textを定義しています。文字列型を受け取り文字列型を返す関数です。

LINE 108: 関数の説明を記すドキュメント文字列。この関数がPDFの文字列リテラルで特別な意味を持つ記号をエスケープする処理であることを説明しています。LINE 109: 特殊記号の置換処理。

バックスラッシュと丸括弧をPDFの規則に従って順番にエスケープした文字列を返しています。置換の順序を守ることで二重エスケープを防いでいます。LINE 112: 表描画関数の定義開始。

1つの表を描くための罫線とテキストのオペレータ列を作る関数_draw_tableを定義しています。表データと開始座標を引数に受け取ります。LINE 113: 関数の説明を記すドキュメント文字列。

この関数が表の罫線とセル文字を描くオペレータ列と、表の下端のY座標をあわせて返すことを説明しています。LINE 114: 見出し行と本体行の結合。見出し行と本体の行データを1つのリストにまとめて、以降の描画処理で同じ扱いができるようにしています。

LINE 115: 列の左端座標リストの初期化。表の各列の左端X座標を格納するリストを、表全体の開始X座標だけを入れた状態で用意しています。LINE 116: 列幅を使った座標の累積計算開始。

表の各列幅を順番に取り出すループを開始し、列の境界となるX座標を積み上げて求めていきます。LINE 117: 次の列境界座標の追加。直前の列境界座標に現在の列幅を足した値を新たな境界座標としてリストに追加しています。

これにより各列の左右境界が順に定まります。LINE 118: 表の下端Y座標の計算。表の開始Y座標から行数分の行高さを差し引いて、表全体の下端となるY座標を求めています。

LINE 120: オペレータ列の初期化。罫線とテキストの描画命令を順に格納していく空のリストを用意しています。LINE 121: 横罫線を引くためのループ開始。

見出し行を含めた行数プラス1本分の横罫線を描くために、その本数だけ繰り返す処理を開始しています。LINE 122: 横罫線のY座標の計算。表の上端から行の高さ分だけ下げた位置を計算し、その行に対応する横罫線のY座標として求めています。

LINE 123: 横罫線の描画命令の追加。計算したY座標を使って、表の左端から右端まで水平線を引くPDF描画命令を組み立ててオペレータ列に追加しています。LINE 124: 縦罫線を引くためのループ開始。

先ほど求めた各列境界のX座標を順に取り出し、縦罫線を1本ずつ描く処理を開始しています。LINE 125: 縦罫線の描画命令の追加。表の上端から下端まで、指定したX座標で垂直線を引くPDF描画命令を組み立ててオペレータ列に追加しています。

LINE 127: 行ごとのセル描画処理開始。見出し行と本体行をまとめたリストを1行ずつ順番に取り出し、その行番号とデータを使って処理する繰り返しを開始しています。LINE 128: 文字のベースライン座標の計算。

その行の下端付近に文字を配置するためのY座標を、行番号と行の高さから計算しています。5.5を加えることで文字が罫線内に収まるよう調整しています。LINE 129: セルごとの描画処理開始。

1行の中の各セルを順番に取り出し、その列番号とセルの値を使って文字を描画する処理を開始しています。LINE 130: 文字の開始X座標の計算。そのセルが属する列の左端座標に少し余白を加えて、文字を描き始めるX座標を求めています。

LINE 131: セル文字のエスケープ処理。セルの値を文字列に変換したうえで、先ほど定義した関数を使ってPDFの文字列として安全な形にエスケープしています。LINE 132: テキスト描画命令の追加開始。

計算した座標とエスケープ済みの文字を使って、1文字ずつのテキスト描画命令をオペレータ列に追加する処理を開始しています。LINE 133: テキスト描画命令の文字列組み立て。フォントサイズ・座標・表示文字を含んだPDFのテキスト描画命令の文字列を、書式付きで組み立てています。

LINE 134: テキスト追加処理の区切り。組み立てたテキスト描画命令の文字列をオペレータ列へ追加する処理の閉じ括弧です。LINE 135: 描画結果の返却。

作成したオペレータ列と、表の下端Y座標をタプルとして呼び出し元に返しています。この戻り値が次の表やページ全体の配置計算に使われます。LINE 138: ページ内容組み立て関数の定義開始。

1ページ分のコンテンツストリームを組み立てる関数_page_contentを定義しています。ページの仕様を表す辞書を引数に受け取ります。LINE 139: 関数の説明を記すドキュメント文字列。

この関数がタイトル・見出し・表を含む1ページ分のコンテンツストリームを組み立てることを説明しています。LINE 140: 基本設定オペレータの初期化。線の太さと線の色を指定するPDF描画命令を初期値としてオペレータ列に設定しています。

LINE 141: 描画開始Y座標の設定。ページの上端から60ポイント下げた位置を、タイトルなどを描き始めるY座標として設定しています。LINE 142: タイトル描画命令の追加開始。

ページタイトルを描画するためのテキスト命令をオペレータ列に追加する処理を開始しています。LINE 143: タイトル文字列の組み立て。タイトル用のフォントサイズと座標、エスケープ済みのタイトル文字列を使って、テキスト描画命令の文字列を組み立てています。

LINE 144: タイトル追加処理の区切り。組み立てたタイトル描画命令をオペレータ列に追加する処理の閉じ括弧です。LINE 145: Y座標の下方向への移動。

タイトルの下に表を配置するため、Y座標を34ポイント分下げています。LINE 146: 表ごとの処理ループ開始。このページに含まれる複数の表を順番に取り出し、それぞれのキャプションと表本体を描画する処理を開始しています。

LINE 147: キャプション描画命令の追加開始。表の見出しであるキャプションを描画するためのテキスト命令をオペレータ列に追加する処理を開始しています。LINE 148: キャプション文字列の組み立て。

キャプション用のフォントサイズと座標、エスケープ済みのキャプション文字列を使って、テキスト描画命令の文字列を組み立てています。LINE 149: キャプション描画呼び出しの終端。表のキャプション文字を描画するops.append呼び出しの閉じ括弧です。

ここまでの引数指定が終わり、キャプションのテキスト描画命令が確定します。LINE 150: キャプション下の余白調整。y座標を14.0だけ下げて、キャプション文字と表の間に見た目の余白を作ります。

この後に描かれる表がキャプションと重ならないようにする役割です。LINE 151: 表の描画処理を呼び出す。_draw_table関数を呼び出し、罫線とセル文字のオペレータ列と表の下端のy座標を受け取ります。

受け取ったyは次の要素の配置位置として使われます。LINE 152: 表オペレータをページに追加。_draw_tableが返した表描画用のオペレータをページ全体のops配列に連結します。

これにより表の罫線と文字がページの描画命令に組み込まれます。LINE 153: 次の表との間隔を空ける。y座標をさらに40.0下げて、次のキャプションや表との間に十分な余白を確保します。

複数の表が縦に並んでも見やすく配置されます。LINE 154: コンテンツストリームの完成。組み立てたops配列を改行文字で連結し、1ページ分のPDFコンテンツストリーム文字列として返します。

この文字列が後でPDFオブジェクトに埋め込まれます。LINE 157: PDF全体構築関数の定義。ページ定義のリストを受け取り、PDFファイル全体のバイト列を組み立てる関数を定義しています。

この関数がサンプルPDF生成の中心処理になります。LINE 158: 関数の説明文。この関数がページ定義リストからPDFファイル全体のバイト列を作ることを説明するドキュメント文字列です。

処理内容を理解する手がかりになります。LINE 159: ページオブジェクト番号の計算。各ページに割り当てるオブジェクト番号を計算してリストにまとめます。

ページごとに2つのオブジェクト番号が必要なため、4から2ずつ増える番号を用意しています。LINE 160: オブジェクト格納辞書の初期化。PDFオブジェクトの番号と内容を紐づけて保持するための空の辞書を用意します。

この後の処理でこの辞書に各種オブジェクトが追加されていきます。LINE 161: Catalogオブジェクトの登録。PDFの起点となるCatalogオブジェクトを番号1として辞書に登録します。

Pagesオブジェクトへの参照を含み、PDF全体の構造の入り口になります。LINE 162: ページ参照文字列の作成。ページIDのリストをPDFの間接参照形式の文字列に変換し、スペース区切りで連結します。

この文字列は次のPagesオブジェクトのKids配列に使われます。LINE 163: Pagesオブジェクトの登録。全ページをまとめるPagesオブジェクトを番号2として辞書に登録します。

ページ数とKids配列を含み、Catalogから参照される親要素になります。LINE 164: フォントオブジェクト定義の開始。番号3のフォントオブジェクトを登録する代入文の開始行です。

丸括弧で複数行にまたがる値をまとめています。LINE 165: フォントオブジェクトの内容。Helveticaフォントを指定するPDFオブジェクトのバイト列本体です。

全ページで共通して使うフォント情報がここで定義されます。LINE 166: フォントオブジェクト定義の終了。複数行にまたがっていたフォントオブジェクトの代入文を閉じる括弧です。

これによりobjects[3]への代入が完了します。LINE 167: ページ仕様とIDの対応付けループ。ページ定義のリストと先に計算したページIDを組み合わせてループ処理します。

各ページごとに個別のPDFオブジェクトを作成する準備です。LINE 168: コンテンツオブジェクト番号の計算。ページオブジェクトの番号に1を足して、そのページの内容を格納するコンテンツオブジェクトの番号を求めます。

ページとコンテンツは常に番号が隣り合うよう設計されています。LINE 169: ページ内容のバイト列化。_page_content関数でページのコンテンツストリーム文字列を作り、latin-1でエンコードしてバイト列に変換します。

このバイト列がPDFのstreamに格納されます。LINE 170: ページオブジェクト定義の開始。番号page_idのページオブジェクトを登録する代入文の開始行です。

ページの属性情報を複数行にわたって組み立てていきます。LINE 171: ページサイズと参照の記述。ページの親要素とMediaBox(用紙サイズ)を文字列として記述します。

ここで指定した幅と高さがPDFの表示サイズを決めます。LINE 172: リソースと内容の参照記述。フォントリソースとコンテンツオブジェクトへの参照をページ定義文字列に追加します。

この情報によりビューアがどのフォントとどの内容を使うか分かります。LINE 173: ページオブジェクト定義のエンコードと終了。組み立てた文字列全体をlatin-1でバイト列に変換し、代入文を閉じます。

これでこのページのオブジェクトが辞書に登録されます。LINE 174: コンテンツオブジェクト定義の開始。番号content_idのコンテンツオブジェクトを登録する代入文の開始行です。

ページの実際の描画命令をここに格納します。LINE 175: ストリームデータの組み立て。ストリームの長さ情報とstreamキーワード、実際の描画バイト列、endstreamを連結してコンテンツオブジェクトの中身を作ります。

PDFの仕様に沿った形式で構築しています。LINE 176: コンテンツオブジェクト定義の終了。複数行にまたがっていたコンテンツオブジェクトの代入文を閉じる括弧です。

これでこのページの内容が辞書に登録されます。LINE 178: PDFヘッダの書き込み。PDFファイルの先頭に必要なバージョン宣言とバイナリマーカーをbytearrayとして用意します。

これがPDF出力全体の書き出し開始点になります。LINE 179: オフセット記録用辞書の初期化。各オブジェクトがファイル内のどの位置に書き込まれたかを記録するための空の辞書を用意します。

この情報は後でxrefテーブルの作成に使われます。LINE 180: オブジェクト番号順の書き込みループ。辞書に登録されたオブジェクトを番号の昇順で処理するループです。

PDFの慣例に従い番号順に書き出すための準備です。LINE 181: 書き込み位置の記録。現在のoutの長さを、これから書き込むオブジェクトの開始位置として記録します。

この値がxrefテーブルに必要なオフセット情報になります。LINE 182: オブジェクト本体の書き込み。オブジェクト番号・obj・内容・endobjという形式でPDFオブジェクトをoutに追記します。

この処理を繰り返すことで全オブジェクトがファイルに書き込まれます。LINE 184: xref開始位置の記録。ここまでに書き込んだ内容の長さを記録し、xrefテーブルがファイル内のどこから始まるかを保存します。

この値はtrailerのstartxrefで使われます。LINE 185: xrefサイズの計算。登録されたオブジェクトの最大番号に1を足して、xrefテーブルに必要なエントリ数を求めます。

オブジェクト0の分も含めたサイズになります。LINE 186: xrefヘッダの書き込み。xrefセクションの開始を示す見出し行と、エントリ数の情報をoutに追記します。

この後に各オブジェクトのオフセット一覧が続きます。LINE 187: 先頭ダミーエントリの書き込み。PDF仕様で必須となるオブジェクト0の固定エントリをxrefに追記します。

空きオブジェクトを表す形式的な行です。LINE 188: オフセットエントリ生成のループ。オブジェクト番号1からsize-1まで順にループし、各オブジェクトのxrefエントリを作成する準備をします。

LINE 189: 各オブジェクトのオフセット書き込み。記録しておいたオフセット値を10桁のゼロ埋め形式に整えてxrefに追記します。この情報によりPDFビューアが各オブジェクトの位置を特定できます。

LINE 190: trailer部の書き込み開始。PDFの末尾に必要なtrailer情報を追記する処理の開始行です。複数の情報を含む文字列を組み立てています。

LINE 191: trailer内容の記述。オブジェクト総数とRootオブジェクトの参照、xref開始位置、ファイル終端マーカーをまとめて記述します。これによりPDFビューアが読み込みの起点を把握できます。

LINE 192: trailerのエンコードと追記。組み立てたtrailer文字列をlatin-1でバイト列に変換し、outに追記します。これでPDFファイル全体の書き込みが完了します。

LINE 193: PDFバイト列の返却。bytearrayのoutをbytes型に変換して呼び出し元に返します。この戻り値がそのままPDFファイルとして保存できるデータになります。

RUN 2/9: PDFのバイト列が組み立てられるか確認する。ページ定義からPDFファイル全体のバイト列を作る処理が書き終わりました。先頭の署名とバイト数を表示して、PDFの形になっているか確かめます。

CHECK 2/9: 途中実行に成功。PDFバイト数: 3900 先頭8バイト: b'%PDF-1.4' 末尾6バイト: b'%%EOF\n' RETURN 02: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 196: サンプルページ生成関数の定義。指定したページ数分のページ定義を作る関数を定義しています。テンプレートを使い回してサンプルPDFの内容を用意する役割です。

LINE 197: 関数の説明文。テンプレートを循環させて指定ページ数分のページ定義を作ることを説明するドキュメント文字列です。処理の目的が一目で分かります。

LINE 198: ページ定義リストの初期化。作成したページ定義を格納するための空リストを用意します。この後のループでページ情報が追加されていきます。

LINE 199: ページ数分のループ。指定されたページ数countの回数だけループを回し、1ページずつページ定義を作成していきます。LINE 200: テンプレートの循環選択。

インデックスをテンプレート数で割った余りを使い、用意された3種類のテンプレートを順番に繰り返し使用します。ページ数がテンプレート数より多くても対応できます。LINE 201: ページ定義の追加。

テンプレートのタイトルにページ番号を付け加え、表データと合わせて1ページ分の定義としてリストに追加します。タイトルにページ番号を含めることでページごとの区別がつきます。LINE 202: ページ定義リストの返却。

作成したページ定義のリストを呼び出し元に返します。この戻り値がそのまま_build_pdfの入力として使われます。LINE 207: PDFオブジェクト抽出関数の定義。

PDFのバイト列からオブジェクトを取り出す関数を定義しています。自前パーサーの中心となる処理の一つです。LINE 208: 関数の説明文。

PDFバイト列から番号付きオブジェクトを取り出すことを説明するドキュメント文字列です。正規表現を使った処理内容の理解を助けます。LINE 209: オブジェクト格納辞書の初期化。

取り出したオブジェクトを番号をキーにして格納するための空の辞書を用意します。この後のループで内容が追加されていきます。LINE 210: オブジェクトの正規表現検索。

PDFバイト列の中から「数字0 obj ... endobj」という形式のブロックを正規表現で全て探し出すループです。ここでPDF内の各オブジェクトが順に見つかります。LINE 211: 見つかったオブジェクトの登録。

正規表現でマッチしたオブジェクト番号と中身のバイト列を辞書に登録します。この辞書が以降の解析処理の基礎データになります。LINE 212: オブジェクト辞書の返却。

作成したオブジェクト辞書を呼び出し元に返します。この辞書を使ってページやコンテンツの探索が行われます。LINE 215: ストリーム取り出し関数の定義。

オブジェクトのバイト列からstream部分の中身を取り出す関数を定義しています。ページの描画命令を得るために使われます。LINE 216: 関数の説明文。

オブジェクトのバイト列からstreamとendstreamの間の中身を取り出すことを説明するドキュメント文字列です。LINE 217: ストリーム部分の検索。正規表現を使ってstreamキーワードとendstreamキーワードの間にあるデータを探します。

改行コードの違いにも対応できるようになっています。LINE 218: ストリーム内容の返却。マッチが見つかればその中身のバイト列を、見つからなければ空のバイト列を返します。

呼び出し側は安全に結果を受け取れます。LINE 221: ページ番号取得関数の定義。CatalogからPagesを辿ってページオブジェクトの番号一覧を得る関数を定義しています。

PDF内のページ構造を追跡する処理です。LINE 222: 関数の説明文。Catalog、Pages、Kidsの順に辿ってページオブジェクト番号を順番通りに集めることを説明するドキュメント文字列です。

LINE 223: Catalogオブジェクトの文字列化。番号1のCatalogオブジェクトをlatin-1でデコードし、文字列として扱えるようにします。この後の正規表現検索のための下準備です。

LINE 224: Pages参照番号の抽出。Catalog文字列の中から/Pagesが指す参照番号を正規表現で取り出します。この番号を使って次にPagesオブジェクトを取得します。

LINE 225: Pagesオブジェクトの文字列化。取得した参照番号を使ってPagesオブジェクトを辞書から取り出し、文字列にデコードします。この文字列にページ一覧の情報が含まれています。

LINE 226: Kids配列内容の抽出。Pagesオブジェクトの文字列から/Kids配列の中身の文字列を正規表現で取り出します。この文字列に各ページオブジェクトへの参照が列挙されています。

LINE 227: Kids文字列からページ番号を抽出。Kids欄の文字列から「n 0 R」というパターンを正規表現で探し、数字部分だけを取り出してリストにしています。これによりページオブジェクトの番号が出現順に並んだ配列を返しています。

RUN 3/9: PDF内のページ番号を辿れるか確認する。PDFのオブジェクトを取り出し、CatalogからKidsを辿る処理までが完成しました。取り込んだオブジェクト数とページ番号の並びを表示します。

CHECK 3/9: 途中実行に成功。オブジェクト数: 9 ページ番号: [4, 6, 8] RETURN 03: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 230: ページ本文取得関数の定義。ページオブジェクトの/Contentsを辿って実際のコンテンツストリーム文字列を取得する関数を定義しています。この関数を使うことで、指定したページの描画内容をあとで解析できるようになります。

LINE 231: 関数の説明を記すドキュメント文字列。この関数がページオブジェクトの/Contentsを辿ってコンテンツストリームの文字列を得るものであることを説明しています。読み手が処理内容を把握しやすくするための説明文です。

LINE 232: ページオブジェクトをデコード。指定したページ番号に対応するオブジェクトのバイト列をlatin-1でデコードし、文字列として扱えるようにしています。この後の正規表現検索の対象になります。

LINE 233: Contents参照番号を取得。ページ文字列の中から/Contentsに続くオブジェクト番号を正規表現で探し出しています。この番号が実際の描画内容を持つオブジェクトを指しています。

LINE 234: コンテンツオブジェクトの取得。先ほど取得した参照番号を使い、対応するコンテンツオブジェクトのバイト列をobjectsから取り出しています。この中にストリームデータが含まれています。

LINE 235: ストリーム文字列を返す。_extract_stream関数でstreamからendstreamまでの中身を取り出し、latin-1でデコードして文字列として返しています。これが1ページ分の描画命令の文字列になります。

LINE 238: エスケープ解除関数の定義。PDF文字列としてエスケープされた文字を元に戻すための関数を定義しています。これにより表示用のテキストを正しい形に復元できます。

LINE 239: 逆変換であることの説明。この関数が_escape_pdf_textの逆変換であり、エスケープされた括弧やバックスラッシュを元に戻すものであることを説明しています。LINE 240: エスケープ文字を順番に置換。

バックスラッシュ付きの括弧を通常の括弧に戻し、最後に二重バックスラッシュを単一のバックスラッシュに戻しています。順序を守ることで正しく元の文字列に復元できます。LINE 243: 罫線オペレータの正規表現定義。

PDFの罫線描画命令(mとlとS)にマッチする正規表現パターンを、始点と終点の座標を名前付きグループとして定義しています。この定義を後で罫線抽出に利用します。LINE 244: テキストオペレータの正規表現定義。

PDFのテキスト描画命令(BT〜ET)にマッチする正規表現パターンを、フォントサイズ・座標・本文を名前付きグループとして定義しています。この定義を後でテキスト抽出に利用します。LINE 245: 罫線とテキストの正規表現を統合。

罫線用とテキスト用の2つの正規表現をORでまとめてコンパイルし、1回の検索で両方のパターンを見つけられるようにしています。これにより後続の走査処理が簡潔になります。LINE 248: オペレータ抽出関数の定義。

コンテンツストリームの文字列から罫線とテキストの描画命令を順番に取り出す関数を定義しています。この結果があとで表の構造復元に使われます。LINE 249: 関数の説明を記すドキュメント文字列。

この関数がコンテンツストリームから罫線オペレータとテキストオペレータを出現順に取り出すものであることを説明しています。LINE 250: 結果格納リストの初期化。抽出したオペレータを順番に格納するための空リストを用意しています。

この後のループでここに要素が追加されていきます。LINE 251: 正規表現で全マッチを走査。先ほど定義した統合正規表現を使い、コンテンツ文字列内のすべてのマッチを出現順に走査しています。

マッチごとに罫線かテキストかを判定します。LINE 252: 罫線マッチかどうかの判定。マッチしたグループにx1が含まれているかどうかで、そのマッチが罫線の命令であるかを判定しています。

含まれていれば罫線として処理を進めます。LINE 253: 罫線情報をタプルで追加。罫線と判定された場合、種類を示す文字列"line"と始点・終点の座標を数値に変換したタプルをopsリストに追加しています。

この情報が後で表の格子構造の判定に使われます。LINE 254: テキストマッチの分岐。罫線でなかった場合はテキストの命令であるとみなし、こちらの処理へ分岐しています。

LINE 255: テキスト情報をタプルで追加。テキストと判定された場合、種類を示す文字列"text"とフォントサイズ・座標・エスケープ解除済みの本文をまとめたタプルをopsリストに追加しています。この情報からタイトルや見出し、セルの文字を区別できます。

LINE 256: 抽出結果を返す。出現順に集めた罫線とテキストのオペレータのリストをそのまま呼び出し元に返しています。この結果が表の検出処理の入力になります。

RUN 4/9: 罫線とテキストの描画命令を数える。コンテンツストリームから描画命令を取り出す処理が動く状態です。1ページ目の罫線とテキストの件数を数えて、読み取れているか確かめます。

CHECK 4/9: 途中実行に成功。罫線オペレータ: 23 テキストオペレータ: 47 RETURN 04: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 259: 表復元関数の定義。罫線とセル文字の情報から1つの表の構造を組み立てる関数を定義しています。表のbboxと行データを含む辞書を返す作りになっています。

LINE 260: 関数の説明を記すドキュメント文字列。この関数が罫線群とセル文字群から罫線の交点で決まる行数・列数の表を復元するものであることを説明しています。LINE 261: 水平線の抽出。

渡された罫線の中からy座標がほぼ同じ(始点と終点のy値が一致する)ものだけを抽出し、水平線のリストとしています。この水平線が表の行の区切りになります。LINE 262: 垂直線の抽出。

渡された罫線の中からx座標がほぼ同じ(始点と終点のx値が一致する)ものだけを抽出し、垂直線のリストとしています。この垂直線が表の列の区切りになります。LINE 263: 水平線のy座標を重複なく降順に整理。

水平線のy座標を丸めて重複を取り除き、上から下に並ぶよう降順にソートしています。この並びが行の境界位置を表します。LINE 264: 垂直線のx座標を重複なく昇順に整理。

垂直線のx座標を丸めて重複を取り除き、左から右に並ぶよう昇順にソートしています。この並びが列の境界位置を表します。LINE 265: 行数の算出。

水平線の本数から1を引いた値を行数として求めています。境界線の数より行数は1つ少なくなるための計算です。LINE 266: 列数の算出。

垂直線の本数から1を引いた値を列数として求めています。境界線の数より列数は1つ少なくなるための計算です。LINE 267: 表の外枠座標の定義開始。

表全体の外枠を表す座標タプル(bbox)の組み立てを開始しています。左・下・右・上の座標をまとめて後で使う形にしています。LINE 268: 左端x座標の設定。

垂直線のx座標のうち最小値を表の左端として設定しています。垂直線が存在しない場合は0.0を使います。LINE 269: 下端y座標の設定。

水平線のy座標のうち最小値(リストの末尾)を表の下端として設定しています。水平線が存在しない場合は0.0を使います。LINE 270: 右端x座標の設定。

垂直線のx座標のうち最大値を表の右端として設定しています。垂直線が存在しない場合は0.0を使います。LINE 271: 上端y座標の設定。

水平線のy座標のうち最大値(リストの先頭)を表の上端として設定しています。水平線が存在しない場合は0.0を使います。LINE 272: bboxタプルの組み立て終了。

左・下・右・上の4つの座標をまとめたタプルとしてbboxの定義を締めくくっています。この値が表の位置とサイズを表します。LINE 273: 行データ格納リストの初期化。

復元した表の各行のセル値を格納するための空リストを用意しています。この後のループでここに1行分ずつ追加されていきます。LINE 274: セル読み取り位置の初期化。

cellsリストから何番目のセル文字を取り出すかを示すインデックスを0で初期化しています。行と列を進めるたびにこの値を増やしていきます。LINE 275: 行数分のループ開始。

算出した行数の分だけ繰り返し、1行ずつセルの値を集める処理を行っています。LINE 276: 1行分のセル格納リストの初期化。現在処理中の1行分のセル値を集めるための空リストを用意しています。

このリストは列数分埋まったらraw_rowsに追加されます。LINE 277: 列数分のループ開始。算出した列数の分だけ繰り返し、1つの行の各列のセル値を順番に取り出す処理を行っています。

LINE 278: セル文字の取り出しと追加。cellsリストの現在位置にあるテキストオペレータから本文部分を取り出し、範囲外であれば空文字にして行のリストへ追加しています。これにより空セルがあっても処理が止まりません。

LINE 279: 読み取り位置を1つ進める。次のセルを参照できるようにインデックスを1増やしています。行・列のループを通じてcellsを順番に消費していきます。

LINE 280: 完成した行をraw_rowsへ追加。1行分のセル値がすべて揃ったリストを、表全体の行データであるraw_rowsに追加しています。この処理が行数分繰り返されます。

LINE 281: 表データを辞書として返す。bboxの各値を丸めた数値にしたタプルと、組み立てたraw_rowsをまとめて辞書として返しています。これが1つの表の復元結果になります。

LINE 284: 表単位への区切り関数の定義。ページ内のオペレータ列を、キャプションの出現を境に複数の表へ区切る関数を定義しています。この関数の戻り値がページ内で検出された表のリストになります。

LINE 285: 関数の説明を記すドキュメント文字列。この関数がフォントサイズを手がかりに表単位へ区切るものであることを説明しています。タイトル・キャプション・セルのフォントサイズの違いを利用しています。

LINE 286: 検出した表を格納するリストの初期化。最終的に検出されたすべての表を格納するための空リストを用意しています。この関数の戻り値になります。

LINE 287: 現在処理中の罫線リストの初期化。今読み進めている表に属する罫線を一時的にためておくためのリストを用意しています。キャプションが出るたびにリセットされます。

LINE 288: 現在処理中のセル文字リストの初期化。今読み進めている表に属するセル文字を一時的にためておくためのリストを用意しています。キャプションが出るたびにリセットされます。

LINE 289: 表処理中フラグの初期化。現在表の中を処理しているかどうかを示すフラグをFalseで初期化しています。このフラグにより表の外側の罫線やテキストを誤って取り込まないようにしています。

LINE 290: オペレータを順番に走査。_parse_content_opsで得たオペレータのリストを先頭から順番に処理していくループを開始しています。LINE 291: テキストオペレータかどうかの判定。

現在のオペレータがテキスト種別かどうかを確認し、その場合はフォントサイズによる分岐処理へ進んでいます。LINE 292: フォントサイズの取得。テキストオペレータからフォントサイズの値を取り出し、後続の判定に使えるようにしています。

LINE 293: キャプションサイズかどうかの判定。取り出したフォントサイズがキャプション用の10.0とほぼ一致するかを確認しています。一致していれば新しい表の見出しとみなします。

LINE 294: 既存の表を確定させる条件判定。すでに表を処理中で、かつ罫線を集めている場合は、その表を確定させるべきタイミングであると判定しています。LINE 295: 直前の表を確定してリストに追加。

ここまで集めた罫線とセル文字を使って_build_tableで表を組み立て、検出済みリストに追加しています。次のキャプションが現れたタイミングで前の表を確定させる処理です。LINE 296: 新しい表用のバッファをリセット。

罫線リストとセル文字リストを空にし、表処理中フラグをTrueに設定して、新しい表の情報収集を開始しています。LINE 297: セルサイズのテキストを蓄積。表処理中でフォントサイズがセル用の9.0とほぼ一致する場合、そのテキストをセル文字リストに追加しています。

これが表内の実際のデータになります。LINE 298: セル文字の追加処理。条件に合致したテキストオペレータをcurrent_cellsへ追加し、後の表組み立てで利用できるようにしています。

LINE 299: 罫線オペレータかどうかの判定。現在のオペレータが罫線種別で、かつ表処理中である場合に、その罫線を蓄積する処理へ進んでいます。LINE 300: 罫線の蓄積処理。

表処理中に見つかった罫線オペレータをcurrent_linesリストへ追加しています。この罫線情報が後で表の行列構造を判定するために使われます。LINE 301: 最後の表を確定させる条件判定。

ループが終わった時点で表処理中かつ罫線が集まっている場合、まだ確定していない最後の表があると判定しています。LINE 302: 最後の表をリストに確定。ループ終了後に残っていた未確定の罫線とセル文字を使って_build_tableを呼び出し、最後の表をtablesリストに追加しています。

これにより本文末尾の表も取りこぼされます。LINE 303: 検出済み表リストを返す。ここまでの走査で集めたテーブル一覧を呼び出し元へ返します。

ページ内に見つかった全ての表がこの戻り値にまとめられます。RUN 5/9: ページごとの表の区切りを確認する。フォントサイズを手がかりに表を区切る処理まで進みました。

ページごとの表の件数と、復元された行数を表示して区切りの結果を見ます。CHECK 5/9: 途中実行に成功。page 1: 表2個 [5, 6] page 2: 表1個 [9] RETURN 05: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 308: 表データをDataFrame化する関数の定義。生の表データ(2次元リスト)を受け取り、pandasのDataFrameへ変換する関数を定義しています。

以降の処理でこの表を使いやすい形に整えます。LINE 309: 関数の役割を説明するdocstring。この関数が先頭行を列名にしてDataFrame化し、空セルや改行、重複した列名を整える役割であることを説明しています。

LINE 310: セルの整形処理を開始。表の各行・各セルに対してクリーニング処理を行うリスト内包表記を開始しています。ここから2重ループで全セルを処理します。

LINE 311: 各セルを文字列化して整形。セルがNoneなら空文字に置き換え、それ以外は文字列化した上で改行をスペースに変え、前後の余分な空白を取り除いています。表示や比較がしやすい綺麗な文字列になります。

LINE 312: 表の各行を順に処理。raw_tableに含まれる各行を順番に取り出し、上のセル整形処理を適用しています。これにより表全体がきれいな文字列の2次元リストになります。

LINE 313: リスト内包表記の終了。セル整形のためのリスト内包表記を閉じ、結果をcleaned変数に格納します。LINE 314: 空の表かどうかを判定。

整形後のデータが空だった場合の分岐条件です。表がまったく検出できなかったケースに備えています。LINE 315: 空のDataFrameを返す。

表データが空の場合は、中身のない空のDataFrameを返して処理を終えます。呼び出し元でのエラーを防ぎます。LINE 317: 見出し行と本体行を分離。

整形済みデータの先頭行を見出し(header)、残りを表の本体データ(body)として分けています。以降はこの2つを使って列名とデータを組み立てます。LINE 318: 列名リストと重複管理用辞書を用意。

最終的な列名を格納するcolumnsリストと、同名列の重複回数を数えるseen辞書を空の状態で準備しています。LINE 319: 見出しの各項目を順に処理。header内の各列名候補を、インデックス番号付きで1つずつ取り出して処理しています。

LINE 320: 空欄の列名に仮の名前を付与。見出しが空文字の場合は「col1」「col2」のように連番の仮の列名を付け、それ以外はそのままの名前を使います。LINE 321: 既出の列名かどうかを判定。

同じ列名がすでに登場しているかをseen辞書で確認しています。重複した列名があるとDataFrame作成時に問題が起きるため、ここで対処します。LINE 322: 重複カウントを増やす。

同名列がすでに存在する場合、その列名の出現回数を1つ増やして記録しています。LINE 323: 重複列名に連番を付けて区別。重複している列名の末尾に出現回数の番号を付け足し、「名前_1」のようにユニークな名前へ変えています。

LINE 324: 重複がない場合の分岐。その列名が初めて登場した場合の処理に進みます。LINE 325: 初出の列名を記録。

初めて出てきた列名をseen辞書に登録し、出現回数を0として記録しておきます。LINE 326: 最終的な列名をリストに追加。仮名付けや重複解消を経た列名を、正式な列名リストcolumnsへ追加しています。

LINE 327: 本体データからDataFrameを作成。整えた本体データと列名リストを使ってpandasのDataFrameを作成し、呼び出し元へ返しています。これで表データが扱いやすい形式に仕上がります。

LINE 330: 表収集関数の定義。指定したPDFファイルからページごと・表ごとの検出結果を集める関数を定義しています。ページ番号を指定すれば特定ページのみに絞ることもできます。

LINE 331: 関数の役割を説明するdocstring。この関数がPDFを開いて自前のパーサーで走査し、ページ別・表別の検出結果を集めることを説明しています。LINE 332: PDFファイルをバイト列として読み込み。

指定されたパスのPDFファイルをバイナリデータとして丸ごと読み込んでいます。この後の解析処理の入力になります。LINE 333: PDF内のオブジェクトを取り出す。

読み込んだバイト列から、PDF形式の各オブジェクトを番号付きで抽出しています。ページやコンテンツの情報がここに含まれます。LINE 334: ページオブジェクト番号の一覧を取得。

CatalogからPagesを辿って、ページの並び順通りにオブジェクト番号のリストを取得しています。LINE 335: 総ページ数を算出。取得したページ番号リストの長さから、PDF全体のページ数を求めています。

LINE 337: 結果格納用リストを準備。検出した表の情報をため込むための空のリストresultsを用意しています。LINE 338: 各ページを順に処理。

ページ番号リストを1始まりの連番indexとともに1ページずつ取り出し、処理を進めています。LINE 339: 対象ページ以外をスキップする条件。page_noが指定されていて、かつ現在のページがそれと一致しない場合の判定を行っています。

LINE 340: 対象外ページの処理をスキップ。指定ページ以外は処理をせずに次のページへ進みます。特定ページだけを解析したい場合に無駄な処理を省きます。

LINE 341: ページのコンテンツ文字列を取得。対象ページのオブジェクトからコンテンツストリームをたどり、文字列として取り出しています。この中に罫線やテキストの描画命令が含まれています。

LINE 342: コンテンツから描画命令を抽出。取得したコンテンツ文字列を解析し、罫線やテキストのオペレータを出現順のリストとして取り出しています。LINE 343: 抽出した表を1つずつ処理。

オペレータ列から表を検出し、1始まりの表番号t_indexとともに各表を順に取り出して処理しています。LINE 344: 検出結果を辞書として追加。1つの表について、ページ番号や表番号などの情報をまとめた辞書をresultsリストへ追加する処理を開始しています。

LINE 345: 辞書リテラルの開始。1つの表の検出結果を表す辞書の定義を始めています。LINE 346: ページ番号を記録。

この表が何ページ目にあるかを示すページ番号を辞書に格納しています。LINE 347: 表番号を記録。同一ページ内で何番目の表かを示すインデックスを辞書に格納しています。

LINE 348: 表の位置情報を記録。表が用紙上のどの範囲にあるかを示すbbox(座標範囲)を辞書に格納しています。LINE 349: 表をDataFrame化して格納。

表の生データを_table_to_frameでDataFrameへ変換し、その結果を辞書に格納しています。これが最終的にCSVや表示に使われるデータになります。LINE 350: 辞書リテラルの終了。

1つの表の検出結果をまとめた辞書の定義を閉じています。LINE 351: 辞書追加処理の終了。resultsリストへの辞書追加の呼び出しを閉じています。

LINE 352: ページ数と検出結果を返す。総ページ数と、集めた表の検出結果リストをまとめて呼び出し元へ返しています。RUN 6/9: ページ別・表別の検出結果を集める。

PDFを走査して検出結果をまとめる処理が完成しました。書き出したPDFを読み込み、ページ数と表の件数、各表の行数と列数を表示します。CHECK 6/9: 途中実行に成功。

ページ数: 3 / 検出した表: 5 page 1 table 1 4行4列 page 1 table 2 5行4列 page 2 table 1 8行5列 page 3 table 1 5行4列 page 3 table 2 3行3列 RETURN 06: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 357: サンプルPDF作成コマンドの定義。CLIの「make-sample」コマンドに対応する処理を行う関数を定義しています。

引数argsには出力先やページ数などの指定が入っています。LINE 358: 関数の役割を説明するdocstring。手元にPDFがなくても検証を始められるよう、サンプルPDFを作成するコマンドであることを説明しています。

LINE 359: 出力先パスをPathオブジェクト化。引数で指定された出力ファイルパスの文字列を、扱いやすいPathオブジェクトへ変換しています。LINE 360: ページ数を最低1に補正。

指定されたページ数と1のうち大きい方を採用し、0以下のページ数指定でもエラーにならないようにしています。LINE 361: サンプルページ定義を作成。補正したページ数に応じて、テンプレートを循環させたページ定義のリストを組み立てています。

LINE 362: 出力先ディレクトリを作成。出力ファイルを置く親ディレクトリが存在しなければ作成しています。既にある場合はエラーにならないよう設定されています。

LINE 363: PDFデータを生成してファイルに書き込み。ページ定義からPDFのバイト列を組み立て、それを出力先のファイルとして書き込んでいます。これでサンプルPDFが完成します。

LINE 365: 総表数を集計。生成した全ページ分のテーブル数を合計し、表の総数を求めています。LINE 366: 結果メッセージ行のリストを開始。

作成結果を表示するための文字列リストの組み立てを始めています。LINE 367: 作成完了メッセージを追加。サンプルPDFを作成したことと、その出力先パスを知らせるメッセージを追加しています。

LINE 368: ページ数を表示するメッセージを追加。作成したPDFの総ページ数を示すメッセージを追加しています。LINE 369: 表の数を表示するメッセージを追加。

PDF全体に含まれる表の総数を示すメッセージを追加しています。LINE 370: ファイルサイズを表示するメッセージを追加。作成したPDFファイルの実際のサイズをバイト単位で表示するメッセージを追加しています。

LINE 371: メッセージリストの一区切り。ここまでの基本情報を表示するリストの定義を閉じています。LINE 372: 各ページの内容を順に処理。

作成したページ定義を1始まりの連番iとともに1つずつ取り出し、ページごとの詳細情報を追加していきます。LINE 373: ページ内の表キャプションを一覧表示。そのページに含まれる各表のキャプションをスラッシュ区切りでつなげ、ページ番号とともにメッセージへ追加しています。

LINE 374: メッセージ全体を結合して返す。組み立てた複数行のメッセージを改行でつなげ、1つの文字列として呼び出し元へ返しています。これがコマンドの出力表示になります。

LINE 379: 検出結果表示コマンドの定義。CLIの「detect」コマンドに対応する処理を行う関数を定義しています。指定したPDFの表検出結果を表示します。

LINE 380: 関数の役割を説明するdocstring。PDFのページ別・表別の検出結果を表示し、表を1つずつ確認できるようにするコマンドであることを説明しています。LINE 381: 入力PDFパスをPathオブジェクト化。

引数で指定されたPDFファイルパスの文字列を、扱いやすいPathオブジェクトへ変換しています。LINE 382: ファイル存在チェックの条件。指定されたパスが実際に存在するファイルかどうかを確認する条件分岐です。

LINE 383: ファイルが無い場合のエラーメッセージ。指定されたPDFファイルが見つからない場合に、その旨を伝えるメッセージを返して処理を終了します。LINE 385: 表の検出結果を取得。

_collect_tablesを呼び出して、指定PDFのページ数と検出した表の一覧を取得しています。args.pageで対象ページを絞り込める仕組みになっています。LINE 386: 対象範囲の表示文言を決定。

args.pageがNoneかどうかで、全ページを対象にしたのか特定ページのみを対象にしたのかを表す文字列scopeを組み立てています。LINE 387: 出力冒頭の見出し行を作成。入力PDFのパスやページ数、対象範囲の情報をまとめた見出し行と、区切り線を含むlinesリストを初期化しています。

LINE 388: 検出した表を1件ずつ処理。tablesに格納された検出結果を1件ずつ取り出して、後続の行で詳細情報を表示していくループです。LINE 389: 表のDataFrameを取り出す。

itemから対応するpandasのDataFrameを取り出して、frameという変数に代入し以降の行数・列数表示に使います。LINE 390: 表の基本情報を出力行に追加。ページ番号や表番号などの情報を出力するため、次の行で組み立てる文字列をlinesに追加する準備をしています。

LINE 391: ページ番号と表番号を整形。itemの'page'と'index'を使って、何ページ目の何番目の表かを示す文字列の前半部分を作成しています。LINE 392: 行数・列数・bboxを整形。

frameの行数と列数、そして表の位置を示すbboxの値を組み合わせて、表の詳細を示す文字列の後半部分を作成しています。LINE 393: append呼び出しを閉じる。391行目と392行目で組み立てた文字列をまとめてlines.appendの引数として渡し、括弧を閉じて呼び出しを完成させています。

LINE 394: 列名一覧を出力に追加。frame.columnsを文字列に変換し、区切り記号でつなげて列名一覧としてlinesに追加しています。LINE 395: 先頭行の有無を確認。

frameに1行以上データがある場合のみ、次の行で先頭行の内容を表示するための条件分岐です。LINE 396: 先頭行の内容を出力。frameの最初の行のデータをリストとして取り出し、区切り記号でつなげてlinesに先頭行の内容として追加しています。

LINE 397: 区切り線を追加。すべての表の情報を出力し終えたあと、見た目を整えるための区切り線をlinesに追加しています。LINE 398: 検出件数のまとめを追加。

検出できた表の総数をlen(tables)で数えて、最後にまとめとしてlinesに追加しています。LINE 399: 出力文字列を結合して返す。linesに溜め込んだ各行を改行でつなぎ、1つの文字列としてまとめて呼び出し元に返しています。

RUN 7/9: detectの表示内容を確認する。検出結果を画面へ並べるコマンドが書き終わりました。1ページ目だけを対象にした表示を呼び出し、列名や先頭行の並びを確かめます。

CHECK 7/9: 途中実行に成功。

入力PDF : checkpoint_sample.pdf (2ページ / page 1のみ) ------------------------------------------------------------------------ page 1 / table 1 : 4行x 4列bbox=(50.0, 644.0, 460.0, 734.0) 列名 : Region | Q1 | Q2 | Q3 先頭行 : Tokyo | 1240 | 1385 | 1502 page 1 / table 2 : 5行x 4列bbox=(50.0, 482.0, 460.0, 590.0) 列名 : Rank | Product | Price | Units 先頭行 : 1 | Cold Brew Coffee 1L | 780 | 412 ------------------------------------------------------------------------ 検出した表: 2個 RETURN 07: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 404: CSV変換コマンドの定義開始。PDF内の全表をCSVへ一括変換するコマンド関数の定義で、argsに指定された入力PDFと出力先の情報を使って処理を進めます。

LINE 405: 関数の説明ドキュメント。この関数がPDF内の全表をCSVへ一括変換する公開CLIコマンドであり、1表につき1ファイルのCSVを書き出すことを説明した文言です。LINE 406: 入力PDFパスをPathへ変換。

args.pdfとして渡された文字列をPathオブジェクトに変換し、ファイル操作をしやすい形にしています。LINE 407: 入力ファイルの存在確認。指定されたPDFファイルが実際に存在するかどうかをis_file()で確認し、無い場合は次の行でエラーメッセージを返す準備をしています。

LINE 408: エラーメッセージを返す。PDFファイルが見つからない場合に、その旨を伝えるメッセージを組み立てて処理を終了させています。LINE 410: 出力先ディレクトリをPathへ変換。

args.out_dirをPathオブジェクトに変換し、CSVファイルを書き出す先のフォルダとして扱えるようにしています。LINE 411: 出力ディレクトリを作成。出力先ディレクトリが存在しない場合でも、親フォルダも含めて自動的に作成し、既にある場合はエラーにならないようにしています。

LINE 412: 全ページから表を検出。_collect_tablesを呼び出して、今回はページを指定せずPDF全体からページ数と検出した表の一覧を取得しています。LINE 414: 出力冒頭の見出しを作成。

入力PDFのパスとページ数、出力先ディレクトリの情報を含む見出し行と区切り線を含むlinesリストを初期化しています。LINE 415: 合計行数のカウンタ初期化。すべてのCSVファイルに書き出された行数の合計を数えるための変数total_rowsを0で初期化しています。

LINE 416: 検出した表を1件ずつ処理。tablesに格納された検出結果を1件ずつ取り出し、以降の行でCSVファイルへの書き出しを行うループです。LINE 417: 表のDataFrameを取り出す。

itemから対応するpandasのDataFrameを取り出し、frameという変数に代入してCSV書き出しに使います。LINE 418: CSVファイル名を組み立て。元のPDFファイル名、ページ番号、表番号を組み合わせて、重複しないCSVファイル名nameを作成しています。

LINE 419: DataFrameをCSVへ書き出す。frameの内容を指定された出力先ディレクトリとファイル名でCSVファイルとして保存し、行番号を含めず指定の文字コードで書き込んでいます。LINE 420: 合計行数を加算。

今回書き出したframeの行数をtotal_rowsに加算し、全体の合計行数を積み上げています。LINE 421: 書き出し結果を出力行に追加。作成したCSVファイル名と、由来となったページ・表の情報を組み合わせた文字列を、次の行で完成させlinesに追加する準備をしています。

LINE 422: ファイル名と由来情報を整形。CSVファイル名を左寄せで整形し、どのページ・どの表から作成されたかを示す文字列の前半部分を作成しています。LINE 423: 行数・列数を整形。

書き出したframeの行数と列数を組み合わせて、出力行の後半部分の文字列を完成させています。LINE 424: append呼び出しを閉じる。422行目と423行目で組み立てた文字列をまとめてlines.appendの引数として渡し、括弧を閉じて呼び出しを完成させています。

LINE 425: 区切り線を追加。全ての表のCSV書き出しが終わったあと、見た目を整えるための区切り線をlinesに追加しています。LINE 426: 変換件数と合計行数のまとめを追加。

書き出したCSVファイルの件数と合計行数をまとめた文字列を作成し、最後の報告としてlinesに追加しています。LINE 427: 出力文字列を結合して返す。linesに溜め込んだ各行を改行でつなぎ、1つの文字列としてまとめて呼び出し元に返しています。

LINE 432: CSV一覧コマンドの定義開始。出力されたCSVファイルを一覧表示するコマンド関数の定義で、argsに指定された出力先ディレクトリの情報を使って処理を進めます。LINE 433: 関数の説明ドキュメント。

この関数が出力されたCSVファイルを一覧表示する公開CLIコマンドであり、extractの結果を読み直して健全性を確かめることを説明した文言です。LINE 434: 出力ディレクトリをPathへ変換。args.out_dirとして渡された文字列をPathオブジェクトに変換し、フォルダの存在確認や一覧取得に使えるようにしています。

LINE 435: 出力ディレクトリの存在確認。指定されたディレクトリが実際に存在するかどうかをis_dir()で確認し、無い場合は次の行でエラーメッセージを返す準備をしています。LINE 436: エラーメッセージを返す。

出力ディレクトリが見つからない場合に、その旨を伝えるメッセージを組み立てて処理を終了させています。LINE 438: CSVファイル一覧を取得。出力ディレクトリ内にある拡張子csvのファイルをすべて集めて、ファイル名順に並べたリストpathsを作成しています。

LINE 439: CSVファイルの有無を確認。pathsが空かどうかを確認し、CSVファイルが1つも見つからない場合は次の行でメッセージを返す準備をしています。LINE 440: 該当なしメッセージを返す。

CSVファイルがまだ作成されていない場合に、その旨を伝えるメッセージを組み立てて処理を終了させています。LINE 442: 出力見出しリストの作成開始。CSV一覧であることを示す見出しや区切り線、列見出し行をまとめるためのlinesリストの定義を開始しています。

LINE 443: 一覧タイトルを追加。出力先ディレクトリの情報を含むCSV一覧のタイトル文字列を、linesリストの最初の要素として用意しています。LINE 444: 区切り線を追加。

タイトルの下に表示する区切り線を、linesリストの2番目の要素として用意しています。LINE 445: 列見出し行を追加。ファイル名・バイト数・行数・列数・列名を並べた表形式のヘッダー行を、桁を揃えて用意しています。

LINE 446: 見出しリストの定義を完了。443行目から445行目で用意した要素をまとめてlinesリストとして確定させ、括弧を閉じています。LINE 447: 合計行数のカウンタ初期化。

すべてのCSVファイルの行数を合計するための変数total_rowsを0で初期化しています。LINE 448: 合計バイト数のカウンタ初期化。すべてのCSVファイルのサイズを合計するための変数total_bytesを0で初期化しています。

LINE 449: CSVファイルを1件ずつ処理。pathsに集めた各CSVファイルを1件ずつ取り出し、以降の行で内容を読み込んで表示していくループです。LINE 450: CSVファイルを読み込む。

pandasのread_csvを使って、文字列型として、空欄をそのまま扱う設定で、指定の文字コードでCSVファイルを読み込みframeに格納しています。LINE 451: ファイルサイズを取得。path.stat().st_sizeを使って、対象CSVファイルの容量をバイト単位で取得しsizeに代入しています。

LINE 452: 合計行数を加算。読み込んだframeの行数をtotal_rowsに加算し、全ファイル分の合計行数を積み上げています。LINE 453: 合計バイト数を加算。

取得したsizeをtotal_bytesに加算し、全ファイル分の合計サイズを積み上げています。LINE 454: 列名一覧を文字列化。frameの列名をカンマ区切りの文字列に変換し、columnsという変数にまとめています。

LINE 455: 列名の長さを確認。columnsの文字数が30文字を超えているかどうかを確認し、長すぎる場合は次の行で短縮する処理につなげています。LINE 456: 長い列名を省略表示。

columnsが長すぎる場合に、先頭27文字だけを残して末尾に「...」を付け、表示を短くまとめています。LINE 457: ファイル情報を1行にまとめて追加。ファイル名・サイズ・行数・列数・列名一覧を桁を揃えて1行に整形し、linesに追加しています。

LINE 458: 区切り線を追加。すべてのCSVファイルの情報を出力し終えたあと、見た目を整えるための区切り線をlinesに追加しています。LINE 459: 合計値のまとめを追加。

読み込んだファイル件数、合計行数、合計バイト数をまとめた文字列を作成し、最後の報告としてlinesに追加しています。LINE 460: 出力文字列を結合して返す。linesに溜め込んだ各行を改行でつなぎ、1つの文字列としてまとめて呼び出し元に返しています。

RUN 8/9: CSV出力と一覧表示をつなげて確認する。CSVへの書き出しと一覧表示のコマンドが揃いました。1ページ分のPDFから変換して、そのまま一覧表示まで続けて確認します。

CHECK 8/9: 途中実行に成功。

------------------------------------------ checkpoint_sample_p01_t01.csv <- page 1 / table 1 4行x 4列 checkpoint_sample_p01_t02.csv <- page 1 / table 2 5行x 4列 ------------------------------------------------------------------------ CSV 2件 / 合計9行を書き出しました CSV一覧: checkpoint_csv ------------------------------------------------------------------------------ file bytes rows cols columns checkpoint_sample_p01_t01.csv 99 4 4 Region, Q1, Q2, Q3 checkpoint_sample_p01_t02.csv 168 5 4 Rank, Product, Price, Units ------------------------------------------------------------------------------ 合計: 2ファイル / 9行 / 267バイト RETURN 08: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 465: 引数パーサー構築関数の定義。コマンドライン引数の解析器を組み立てて返す関数を定義しています。

戻り値の型がargparse.ArgumentParserであることも明示しています。LINE 466: ArgumentParserの生成開始。argparse.ArgumentParserのインスタンスを作り始めています。

ここで作られるparserがCLI全体の引数解析の土台になります。LINE 467: CLI全体の説明文を設定。このツールをhelp表示したときに出る説明文を指定しています。

ユーザーがコマンドの目的を一目で理解できるようにしています。LINE 468: ArgumentParser生成の完了。複数行にまたがっていたArgumentParserの呼び出しを閉じています。

この時点でparserが使える状態になります。LINE 469: サブコマンド機能の登録。make-sampleやdetectなど複数のサブコマンドを扱えるように、サブパーサー機能を追加しています。

requiredをTrueにしているため、サブコマンドの指定を必須にしています。LINE 471: make-sampleサブコマンドの追加。サンプルPDFを作成するためのサブコマンドmake-sampleを登録しています。

help文言はコマンド一覧表示時の説明として使われます。LINE 472: 出力先パスの引数定義。生成するサンプルPDFの出力先パスを指定する--outオプションを定義しています。

指定がない場合はsample_tables.pdfというファイル名が使われます。LINE 473: 生成ページ数の引数定義。サンプルPDFを何ページ生成するかを指定する--pagesオプションを定義しています。

整数型として扱い、指定がなければ3ページになります。LINE 475: detectサブコマンドの追加。PDF内の表を検出して結果を表示するdetectサブコマンドを登録しています。

help文言でコマンドの役割を説明しています。LINE 476: 入力PDFパスの必須引数定義。detectコマンドで解析対象とするPDFファイルのパスを受け取る必須の位置引数pdfを定義しています。

LINE 477: 対象ページ指定の引数定義。検出対象を特定のページに絞り込むための--pageオプションを定義しています。指定しない場合はNoneとなり全ページが対象になります。

LINE 479: extractサブコマンドの追加。PDF内の表をCSVへ一括変換するextractサブコマンドを登録しています。help文言で用途をユーザーに伝えています。

LINE 480: 入力PDFパスの必須引数定義。extractコマンドで変換対象とするPDFファイルのパスを受け取る必須の位置引数pdfを定義しています。LINE 481: CSV出力先ディレクトリの引数定義。

変換したCSVファイルを書き出す先のディレクトリを指定する--out-dirオプションを定義しています。指定がない場合はcsv_outというディレクトリが使われます。LINE 482: CSVの文字コード指定の引数定義。

書き出すCSVファイルの文字コードを指定する--encodingオプションを定義しています。既定値はutf-8-sigで、Excelでも文字化けしにくい形式になっています。LINE 484: list-csvサブコマンドの追加。

出力済みのCSVファイルを一覧表示するlist-csvサブコマンドを登録しています。help文言でこのコマンドの目的を説明しています。LINE 485: 一覧対象ディレクトリの引数定義。

一覧表示したいCSVファイルが置かれているディレクトリを指定する--out-dirオプションを定義しています。既定値はcsv_outです。LINE 486: 読み込み時の文字コード指定。

一覧表示のためにCSVファイルを読み込む際の文字コードを指定する--encodingオプションを定義しています。extract時と同じutf-8-sigが既定値になっています。LINE 487: 完成したparserの返却。

ここまで設定してきたparserオブジェクトを呼び出し元に返しています。これによりmain関数などで実際の引数解析に使えるようになります。RUN 9/9: サブコマンドの引数解析を確認する。

argparseによるサブコマンドの定義が完成しました。extractを指定して解析し、コマンド名と既定の出力先や文字コードを表示します。CHECK 9/9: 途中実行に成功。

command: extract out_dir: csv_out / encoding: utf-8-sig make-sample既定: 3 RETURN 09: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 492: エントリポイント関数の定義。

プログラムの実行時に呼び出される中心的な関数mainを定義しています。文字列を戻り値として返す設計になっています。LINE 493: コマンドライン引数の解析実行。

build_parserで作った解析器を使って実際の引数を解析し、結果をargsに格納しています。argvを指定すればテスト時などに任意の引数リストを渡すこともできます。LINE 494: コマンド名と処理関数の対応表定義開始。

サブコマンド名をキーとして対応する処理関数を値に持つ辞書handlersを作り始めています。ここから各コマンドの実処理へ振り分ける仕組みを構築しています。LINE 495: make-sampleコマンドの割り当て。

make-sampleという文字列に対してcmd_make_sample関数を対応付けています。これによりサブコマンド名から実行すべき処理を特定できます。LINE 496: detectコマンドの割り当て。

detectという文字列に対してcmd_detect関数を対応付けています。表の検出結果を表示する処理につながっています。LINE 497: extractコマンドの割り当て。

extractという文字列に対してcmd_extract関数を対応付けています。CSVへの一括変換処理につながっています。LINE 498: list-csvコマンドの割り当て。

list-csvという文字列に対してcmd_list_csv関数を対応付けています。出力済みCSVの一覧表示処理につながっています。LINE 499: 対応表の定義終了。

handlers辞書の定義を閉じています。これで4つのサブコマンド全てに処理関数が紐づいた状態になります。LINE 500: 該当コマンドの実行と結果返却。

args.commandで指定されたサブコマンド名をキーにhandlersから対応する関数を取り出し、argsを渡して実行した結果を返しています。この一行でCLI全体の処理が実際に動きます。LINE 503: スクリプト直接実行時の判定。

このファイルがモジュールとしてimportされたのではなく、直接実行されたときだけ処理を行うための定型的な判定です。これによりインポート時に余計な処理が走らないようにしています。LINE 504: main関数の実行結果を表示。

main関数を呼び出してその戻り値の文字列を画面に出力しています。ここがプログラム実行時に最終的に表示される結果です。実行1/5: サンプルPDFを作成する。

make-sampleコマンドを実行し、罫線付きの表を並べた検証用PDFを3ページ分作成します。ページ数と表の数、ページごとのキャプションが表示されます。確認1/5: サンプルPDFを作成する。

make-sampleコマンドを実行し、罫線付きの表を並べた検証用PDFを3ページ分作成します。ページ数と表の数、ページごとのキャプションが表示されます。RETURN 10: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。実行2/5: PDFのページごとに表を検出する。detectコマンドで、生成したPDFのページごとの表を検出します。

表の行数と列数、列名、先頭行が表ごとに並びます。確認2/5: PDFのページごとに表を検出する。detectコマンドで、生成したPDFのページごとの表を検出します。

表の行数と列数、列名、先頭行が表ごとに並びます。RETURN 11: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

実行3/5: 全表をCSVへ一括変換する。extractコマンドで、検出したすべての表をCSVへ書き出します。ファイル名と行数が1件ずつ並び、最後に件数と合計行数が表示されます。

確認3/5: 全表をCSVへ一括変換する。extractコマンドで、検出したすべての表をCSVへ書き出します。ファイル名と行数が1件ずつ並び、最後に件数と合計行数が表示されます。

RETURN 12: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。実行4/5: 出力したCSVを一覧で確認する。

list-csvコマンドで、書き出したCSVを読み直して一覧表示します。バイト数、行数、列数、列名と合計値が確認できます。確認4/5: 出力したCSVを一覧で確認する。

list-csvコマンドで、書き出したCSVを読み直して一覧表示します。バイト数、行数、列数、列名と合計値が確認できます。RETURN 13: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。実行5/5: 2ページ目の表だけを検出する。detectへ--pageを付けて、2ページ目の表だけを確認します。

対象範囲と検出件数が絞り込まれた結果になります。確認5/5: 2ページ目の表だけを検出する。detectへ--pageを付けて、2ページ目の表だけを確認します。

対象範囲と検出件数が絞り込まれた結果になります。RETURN 14: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

学習内容のまとめ。ページに描かれた縦横の罫線の座標 先頭行を列名にしたDataFrameの生成make-sampleは--outと--pagesでPDFを生成 外部PDFライブラリに依存しない自己完結型の構成 小さく実行確認しながら完成状態まで段階的に組み立てる エンディング。Python研修はCodeCampでご確認ください。

PDF解析のpdfplumberとpandasとは

今回使用する主要なライブラリについて、役割と使い分けを順番に確認します。

PDFから表を読み取るpdfplumberの役割

pdfplumberは、PDFのページからテキストや罫線の座標を読み取り、表として組み立て直せるPDF解析ライブラリです。既定のlines戦略では、ページに描かれた縦横の罫線を手がかりに行と列の境界を決め、その枠へ収まる文字をセルとして割り当てます。今回のサンプルPDFも、この罫線ベースの読み取りが素直に効く形式で描いています。

教材のソースでは、pdfplumberが無い環境でも同じ結果を再現できるよう、サンプルPDFの生成フォーマットに合わせた専用パーサーを自前で用意しました。

pdfplumberが表を復元するときに手がかりとする情報と、今回のサンプルPDFがそれへ合わせている要素です

  • ページに描かれた縦横の罫線の座標
  • 文字の描画位置とフォントサイズ
  • 罫線の交点から決まる行数と列数
  • 表の外接矩形を表すbbox
  • 罫線が引かれた表を前提とするlines戦略

表データをCSVへ整えるpandasの役割

pandasは、行と列を持つデータをDataFrameという構造で扱い、CSVの読み書きまで任せられるデータ分析ライブラリです。検出した直後の表は文字列の二次元リストにすぎないため、列名も行数の把握手段もありません。DataFrameへ載せ替えると、先頭行を列名として扱ったり、shapeで行数と列数を数えたりする操作が一行で済みます。

書き出しはto_csv、確認のための読み直しはread_csvと、入口と出口が揃っているのも扱いやすい点です。

本ツールがpandasへ任せている表の整形処理とCSVの入出力です

  • 先頭行を列名にしたDataFrameの生成
  • 空の列名をcol1形式で補完
  • 重複する列名への連番付与
  • index列を含めないto_csvでの書き出し
  • dtype=strを指定したread_csvでの読み直し
  • shapeによる行数と列数の取得
さらに詳しく学べるPython研修の詳細はこちら

Python・pdfplumberで開発する場合の環境構築

この記事のセットアップ手順と掲載コードは、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 pdfplumber pandas

macOS・Linuxでは仮想環境内のPythonパスが異なります。今回の動作確認環境とは異なるため、以下は環境差分を補う参考手順です。

python3 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install pdfplumber pandas
  • pdfplumberはpdfminer.sixとPillowを依存として一緒に入れるため、個別に追加する必要はありません。
  • サンプルPDFは標準の14フォント(Helvetica)で描くため、セルの文字はASCIIのみにしています。日本語を入れたい場合はCJKフォントの埋め込みが別途必要です。
  • CSVの既定エンコーディングはutf-8-sigです。Excelで開いても文字化けしませんが、他ツールへ渡すときは--encoding utf-8を指定してください。
さらに詳しく学べるPython研修の詳細はこちら

PDF表のCSV一括変換CLIの要件定義

目的は、サンプルPDFの生成から表の検出、CSVへの一括変換、出力CSVの一覧確認までを4つのサブコマンドで動かし、PDF内の表が行数と列数を保ったままCSVになる流れを確認することです。

対象者として、Pythonの基本文法を終えて、argparseとpandasでPDFの表をCSVへ変換するCLIの作り方を学びたい人を想定しています。

完成物は、罫線とフォントサイズを手がかりにPDFの表を復元し、pandasで1表1ファイルのCSVへ書き出せる4サブコマンド構成のPDF表変換CLIです。

実装へ入る前に、機能・品質・受け入れ条件を分けて確認します。

機能要件

  • 検証用サンプルPDFを指定ページ数で生成する
  • 生成したPDFのページ数と表の数とサイズを表示する
  • ページ別・表別に表の行数と列数とbboxを表示する
  • 検出した表の列名と先頭行を表示する
  • --pageで検出対象を1ページに絞る
  • PDF内の全表を1表1ファイルのCSVへ書き出す
  • 書き出したCSVの件数と合計行数を表示する
  • 出力ディレクトリのCSVを一覧表示して合計を集計する

非機能要件

  • 外部PDFライブラリに依存しない自己完結型の構成
  • 標準ライブラリのargparseによるサブコマンド定義
  • pandasのDataFrameを経由したCSVの入出力
  • CSVの文字コードは既定でutf-8-sig
  • 出力ディレクトリはparents付きで自動作成
  • 入力PDFが無い場合は見つからない旨のメッセージを返す
  • サンプルPDFの文字はHelveticaで描けるASCIIのみ
  • 空の列名はcol1形式で補完し重複列名へ連番を付与
  • --pagesは1未満を1として扱う
  • list-csvはdtype=strとkeep_default_na=Falseで読み直す

実装方針

今回はpdfplumberとpandasの基本動作を追いやすくするため、PDF表のCSV一括変換CLI本体を1つのPythonファイルへまとめます。

入力、判定、結果表示の役割を分け、実行結果を確認しながら機能を積み上げます。

PDF表のCSV一括変換CLIを安全に組み立てるための実装方針は次のとおりです。

  • 外部PDFライブラリに依存しない自己完結型の構成
  • 標準ライブラリのargparseによるサブコマンド定義
  • pandasのDataFrameを経由したCSVの入出力
  • CSVの文字コードは既定でutf-8-sig
  • 出力ディレクトリはparents付きで自動作成
  • 入力PDFが無い場合は見つからない旨のメッセージを返す
  • サンプルPDFの文字はHelveticaで描けるASCIIのみ
  • 空の列名はcol1形式で補完し重複列名へ連番を付与
  • --pagesは1未満を1として扱う
  • list-csvはdtype=strとkeep_default_na=Falseで読み直す

完成と判断する条件

  1. make-sampleで3ページ・表5個のサンプルPDFが作られる
  2. detectの最終行に検出した表が5個と表示される
  3. page 1 / table 1が4行4列で表示される
  4. --page 2の指定で対象がpage 2のみになる
  5. extractでCSV5件・合計25行が書き出される
  6. 出力ファイル名がsample_tables_p01_t01.csv形式になる
  7. list-csvの合計が5ファイル・25行と表示される
さらに詳しく学べるPython研修の詳細はこちら

PDF表のCSV一括変換CLIを作る際の重要ポイント

このCLIは、サンプルPDFの生成、表の検出、CSVへの一括変換、出力CSVの一覧確認という4つのサブコマンドで構成されています。中心にあるのは、PDFを走査してページ別・表別の検出結果をまとめる処理で、detectとextractはどちらも同じ収集関数を呼び出します。

detectは結果を画面へ並べる担当、extractは同じ結果をCSVへ書き出す担当と、役割をはっきり分けました。サブコマンドの受け付けは標準ライブラリのargparseにまとめ、コマンド名と処理関数を辞書で対応させています。

4つのサブコマンドがそれぞれ担う入力と出力の対応です

  • make-sampleは--outと--pagesでPDFを生成
  • detectは--pageで対象ページを絞り込み
  • extractは--out-dirへCSVを書き出す
  • list-csvは出力ディレクトリの内容を集計
  • detectとextractは共通の収集処理を利用
  • 入力PDFが無い場合はメッセージを返す

argparseでサブコマンドを定義する手順

add_subparsersdestrequired=Trueを渡すと、サブコマンド名の指定を必須にできます。あとはadd_parserでコマンドごとの引数を並べるだけで、--pagesのような数値のオプションもtype=intで受け取れました。

main関数ではコマンド名と処理関数を辞書で対応させ、解析済みの引数をそのまま渡す形にしています。各コマンドは表示用の文字列を返し、印字は最後の1か所へ集約しました。

各サブコマンドへ用意した引数と既定値です

  • make-sampleの--outはsample_tables.pdf
  • make-sampleの--pagesは既定で3
  • detectの--pageは未指定で全ページ
  • extractの--out-dirはcsv_out
  • --encodingの既定はutf-8-sig

フォントサイズを手がかりに表を区切る

1ページに複数の表があると、罫線と文字をまとめて読んだだけでは、どこからどこまでが1つの表か分かりません。今回はサンプルPDFを描く側で、タイトルを14ポイント、表のキャプションを10ポイント、セルを9ポイントに固定しました。読み取り側はキャプションのサイズを見つけた時点で新しい表の開始と判断し、直前までに集めた罫線とセルから表を1件組み立てます。

セルのサイズの文字だけを値として集めるため、タイトルが表へ混ざる心配もありません。

表の区切り判定で使っている3つのフォントサイズの役割です

  • 14ポイントはページタイトル
  • 10ポイントは表の開始を示すキャプション
  • 9ポイントはセルの値
  • キャプション検出時に直前の表を確定
  • ページ末尾で残った表を組み立て

ページ別・表別に結果を整理する設計

収集処理は、ページ番号、そのページ内での表番号、表の外接矩形、DataFrameの4点を1件の辞書へまとめ、リストへ順番通り積み上げます。detectはこの並びをそのまま画面へ出し、行数と列数、bbox、列名、先頭行を表示します。

extractは同じ並びからファイル名を組み立てるので、sample_tables_p02_t01.csvのように、どのページのどの表かがファイル名だけで分かる仕組みです。最後の行へ件数と合計行数を出して、取りこぼしの有無も確認できるようにしました。

収集結果の1件が持つ情報と、それを使う出力先です

  • ページ番号と表番号の組み合わせ
  • 小数第1位まで丸めたbbox
  • 整形済みのDataFrame
  • ゼロ埋め2桁のファイル名
  • 検出件数と合計行数の集計行
さらに詳しく学べるPython研修の詳細はこちら

PythonでサンプルPDFをpdfplumberが読める形で作る

表抽出でつまずく最大の原因は、入力PDFの中身が分からないことです。そこで検証用の入力を確保するため、標準ライブラリだけでPDFを書き出すライタを先に用意しました。

PDFは線分オペレータで格子を引き、その内側にHelveticaでセル文字を置く構造にしています。罫線が明示的に存在するので、find_tables()が表の枠を素直に見つけられます。

処理を4つのサブコマンドに分けたのは、生成・検出・変換・確認という段階を別々に試したかったからです。標準ライブラリの公式ドキュメントも、機能の多いプログラムでこの形を紹介しています。

ポイントとしては、サンプルPDFに固定した用紙とフォントの前提を先に把握しておくと、抽出結果の読み解きが早くなります。

このセクションの用語

ポイント
PDFで使う長さの単位です。1ポイントは約72分の1インチで、A4は595×842ポイントに相当します。
lines戦略
pdfplumberが罫線の線分を手がかりに表の枠を判定する既定の設定です。
Helvetica
PDFに標準で用意されている欧文フォントで、追加の埋め込みなしで使える一方、扱えるのはASCII文字だけです。

サンプルPDFの描画条件として固定した値です。

  • 用紙はA4相当の595×842ポイントで、余白は50ポイント
  • 行の高さは18ポイント、セル文字は9ポイント
  • 表の上にキャプションを置き、ページ数は--pagesで指定

参考:

©Python公式ドキュメントargparse

Many programs split up their functionality into a number of sub-commands, for example, the svn program can invoke sub-commands like svn checkout, svn update, and svn commit.

POINT

ASCII限定:Helveticaは日本語不可

罫線あり:linesの既定戦略が有効

座標系:左下原点のポイント単位


Python研修一覧はこちら

目的に合うPython研修を一覧形式から探したい方は、ぜひご利用ください。

Python研修を比較する

Java研修一覧はこちら

目的に合うJava研修を一覧形式から探したい方は、ぜひご利用ください。

Java研修を比較する

PHP研修一覧はこちら

目的に合うPHP研修を一覧形式から探したい方は、ぜひご利用ください。

PHP研修を比較する

新入社員研修

目的に合う新入社員研修を一覧形式から探したい方は、ぜひご利用ください。

新入社員研修を比較する

全ての研修からも探したい方はこちら

PythonでPDF表のCSV一括変換CLIの完成コード

コードは1ファイル構成で、上から用紙の定数、サンプル表のデータ、PDFの生成と解析、CLIの入口という並びになっています。読む順番もこの流れに沿えば迷いません。

外部依存をpandasだけに絞ったのは、CSV書き出しと列名の整形を任せたいからです。実行環境にpdfplumberが無い場合でも動くよう、サンプルPDFの形式に合わせた専用パーサーを同梱しました。

各関数の冒頭には、担っている役割をdocstringで明記しています。あとから読み返すときに、どの関数が生成担当でどれが解析担当かをすぐ判別できました。

このセクションの用語

DataFrame
pandasの表オブジェクトです。行と列に名前を持つExcelシートのような構造で、列単位の加工やCSV出力ができます。
docstring
モジュールや関数の先頭に置く説明文の文字列です。役割をコード内に書き残せます。
argparse
コマンドライン引数を解析するPython標準ライブラリのモジュールです。サブコマンドも定義できます。
find_tables()
pdfplumberのページオブジェクトが持つメソッドで、ページ内の表を候補としてまとめて返します。
PDFの罫線からCSVファイルまでのデータの流れ 罫線とテキストを座標付きで読む格子を行と列のセルに分解先頭行を列名にして空欄と重複を補正DataFrameへ変換to_csvで1表1ファイルへ保存
PDFの罫線からCSVファイルまでのデータの流れ
#!/usr/bin/env python3
"""自前の最小PDFパーサーと pandas で PDF 内の表を検出し CSV へ変換する自己完結型 CLI。
実行環境に pdfplumber が無くても動くよう、サンプルPDFの生成フォーマットに合わせた
専用パーサーで罫線とテキストを読み取り、表を復元する。"""

from __future__ import annotations

import argparse
import re
from pathlib import Path

import pandas as pd

# サンプルPDFの用紙設定

PAGE_WIDTH = 595.0
PAGE_HEIGHT = 842.0
MARGIN = 50.0
ROW_HEIGHT = 18.0
CELL_FONT_SIZE = 9.0
CAPTION_FONT_SIZE = 10.0
TITLE_FONT_SIZE = 14.0

# サンプルPDFに描画する表データ(Helvetica で描くため ASCII のみ)

SAMPLE_TEMPLATES = [
    {
        "title": "Sample Mart Sales Report",
        "tables": [
            {
                "caption": "Table A: Quarterly Sales by Region",
                "widths": [140.0, 90.0, 90.0, 90.0],
                "header": ["Region", "Q1", "Q2", "Q3"],
                "rows": [
                    ["Tokyo", "1240", "1385", "1502"],
                    ["Osaka", "980", "1024", "1108"],
                    ["Nagoya", "760", "812", "845"],
                    ["Fukuoka", "530", "574", "610"],
                ],
            },
            {
                "caption": "Table B: Top Products",
                "widths": [60.0, 200.0, 80.0, 70.0],
                "header": ["Rank", "Product", "Price", "Units"],
                "rows": [
                    ["1", "Cold Brew Coffee 1L", "780", "412"],
                    ["2", "Olive Oil 500ml", "1280", "268"],
                    ["3", "Whole Wheat Pasta", "460", "233"],
                    ["4", "Almond Granola", "890", "187"],
                    ["5", "Sparkling Water 6pk", "540", "154"],
                ],
            },
        ],
    },
    {
        "title": "Sample Mart Inventory Report",
        "tables": [
            {
                "caption": "Table C: Stock by SKU",
                "widths": [90.0, 175.0, 75.0, 75.0, 80.0],
                "header": ["SKU", "Item", "Stock", "Safety", "Status"],
                "rows": [
                    ["SKU-1001", "Cold Brew Coffee 1L", "128", "60", "OK"],
                    ["SKU-1002", "Olive Oil 500ml", "42", "50", "LOW"],
                    ["SKU-1003", "Whole Wheat Pasta", "260", "80", "OK"],
                    ["SKU-1004", "Almond Granola", "18", "40", "LOW"],
                    ["SKU-1005", "Sparkling Water 6pk", "305", "100", "OK"],
                    ["SKU-1006", "Rye Bread", "64", "45", "OK"],
                    ["SKU-1007", "Greek Yogurt 400g", "9", "30", "LOW"],
                    ["SKU-1008", "Dark Chocolate 70", "151", "50", "OK"],
                ],
            }
        ],
    },
    {
        "title": "Sample Mart Order List",
        "tables": [
            {
                "caption": "Table D: Orders",
                "widths": [90.0, 90.0, 160.0, 80.0],
                "header": ["Order", "Date", "Customer", "Amount"],
                "rows": [
                    ["ORD-2401", "2026-04-02", "Aoyama Coffee", "18400"],
                    ["ORD-2402", "2026-04-03", "Midori Bakery", "9200"],
                    ["ORD-2403", "2026-04-05", "Hills Deli", "27600"],
                    ["ORD-2404", "2026-04-08", "Station Kiosk", "5400"],
                    ["ORD-2405", "2026-04-11", "Green Grocer", "13800"],
                ],
            },
            {
                "caption": "Table E: Shipping Fee",
                "widths": [120.0, 90.0, 90.0],
                "header": ["Zone", "Fee", "Days"],
                "rows": [
                    ["Kanto", "600", "1"],
                    ["Kansai", "780", "2"],
                    ["Kyushu", "980", "3"],
                ],
            },
        ],
    },
]


# サンプルPDFを組み立てる最小限のPDFライタ

def _escape_pdf_text(text: str) -> str:
    """PDFの文字列リテラルで特別な意味を持つ記号をエスケープする。"""
    return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")


def _draw_table(table: dict, x: float, y_top: float):
    """1つの表の罫線とセル文字を描くオペレータ列と、表の下端Y座標を返す。"""
    rows = [table["header"]] + table["rows"]
    xs = [x]
    for width in table["widths"]:
        xs.append(xs[-1] + width)
    y_bottom = y_top - ROW_HEIGHT * len(rows)

    ops = []
    for i in range(len(rows) + 1):
        y = y_top - ROW_HEIGHT * i
        ops.append(f"{xs[0]:.2f} {y:.2f} m {xs[-1]:.2f} {y:.2f} l S")
    for vx in xs:
        ops.append(f"{vx:.2f} {y_top:.2f} m {vx:.2f} {y_bottom:.2f} l S")

    for r, row in enumerate(rows):
        baseline = y_top - ROW_HEIGHT * (r + 1) + 5.5
        for c, cell in enumerate(row):
            tx = xs[c] + 4.0
            body = _escape_pdf_text(str(cell))
            ops.append(
                f"BT /F1 {CELL_FONT_SIZE:.1f} Tf 1 0 0 1 {tx:.2f} {baseline:.2f} Tm ({body}) Tj ET"
            )
    return ops, y_bottom


def _page_content(spec: dict) -> str:
    """1ページ分のコンテンツストリーム(タイトル・見出し・表)を組み立てる。"""
    ops = ["0.7 w", "0 0 0 RG"]
    y = PAGE_HEIGHT - 60.0
    ops.append(
        f"BT /F1 {TITLE_FONT_SIZE:.1f} Tf 1 0 0 1 {MARGIN:.2f} {y:.2f} Tm ({_escape_pdf_text(spec['title'])}) Tj ET"
    )
    y -= 34.0
    for table in spec["tables"]:
        ops.append(
            f"BT /F1 {CAPTION_FONT_SIZE:.1f} Tf 1 0 0 1 {MARGIN:.2f} {y:.2f} Tm ({_escape_pdf_text(table['caption'])}) Tj ET"
        )
        y -= 14.0
        table_ops, y = _draw_table(table, MARGIN, y)
        ops.extend(table_ops)
        y -= 40.0
    return "\n".join(ops)


def _build_pdf(specs: list) -> bytes:
    """ページ定義のリストから PDF ファイル全体のバイト列を作る。"""
    page_ids = [4 + 2 * i for i in range(len(specs))]
    objects = {}
    objects[1] = b"<< /Type /Catalog /Pages 2 0 R >>"
    kids = " ".join(f"{pid} 0 R" for pid in page_ids)
    objects[2] = f"<< /Type /Pages /Count {len(specs)} /Kids [{kids}] >>".encode("latin-1")
    objects[3] = (
        b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
    )
    for spec, page_id in zip(specs, page_ids):
        content_id = page_id + 1
        stream = _page_content(spec).encode("latin-1")
        objects[page_id] = (
            f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {PAGE_WIDTH:.0f} {PAGE_HEIGHT:.0f}] "
            f"/Resources << /Font << /F1 3 0 R >> >> /Contents {content_id} 0 R >>"
        ).encode("latin-1")
        objects[content_id] = (
            f"<< /Length {len(stream)} >>\nstream\n".encode("latin-1") + stream + b"\nendstream"
        )

    out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
    offsets = {}
    for number in sorted(objects):
        offsets[number] = len(out)
        out += f"{number} 0 obj\n".encode("latin-1") + objects[number] + b"\nendobj\n"

    xref_pos = len(out)
    size = max(objects) + 1
    out += f"xref\n0 {size}\n".encode("latin-1")
    out += b"0000000000 65535 f \n"
    for number in range(1, size):
        out += f"{offsets[number]:010d} 00000 n \n".encode("latin-1")
    out += (
        f"trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n"
    ).encode("latin-1")
    return bytes(out)


def _sample_pages(count: int) -> list:
    """テンプレートを循環させて指定ページ数分のページ定義を作る。"""
    pages = []
    for i in range(count):
        template = SAMPLE_TEMPLATES[i % len(SAMPLE_TEMPLATES)]
        pages.append({"title": f"{template['title']} - page {i + 1}", "tables": template["tables"]})
    return pages


# 自前PDFパーサー(pdfplumber非依存): オブジェクト・ページ・コンテンツストリームの取り出し

def _load_pdf_objects(data: bytes) -> dict:
    """PDFバイト列から `n 0 obj ... endobj` 形式のオブジェクトを番号付きで取り出す。"""
    objects = {}
    for match in re.finditer(rb"(\d+)\s+0\s+obj\s*(.*?)\s*endobj", data, re.DOTALL):
        objects[int(match.group(1))] = match.group(2)
    return objects


def _extract_stream(obj_bytes: bytes) -> bytes:
    """オブジェクトのバイト列から `stream ... endstream` の中身を取り出す。"""
    match = re.search(rb"stream\r?\n(.*?)\r?\nendstream", obj_bytes, re.DOTALL)
    return match.group(1) if match else b""


def _page_object_numbers(objects: dict) -> list:
    """Catalog -> Pages -> Kids を辿ってページオブジェクト番号を順番通りに集める。"""
    catalog = objects[1].decode("latin-1")
    pages_ref = re.search(r"/Pages\s+(\d+)\s+0\s+R", catalog).group(1)
    pages_obj = objects[int(pages_ref)].decode("latin-1")
    kids = re.search(r"/Kids\s*\[(.*?)\]", pages_obj, re.DOTALL).group(1)
    return [int(n) for n in re.findall(r"(\d+)\s+0\s+R", kids)]


def _page_content_text(objects: dict, page_num: int) -> str:
    """ページオブジェクトの /Contents を辿ってコンテンツストリームの文字列を得る。"""
    page_bytes = objects[page_num].decode("latin-1")
    content_ref = re.search(r"/Contents\s+(\d+)\s+0\s+R", page_bytes).group(1)
    content_bytes = objects[int(content_ref)]
    return _extract_stream(content_bytes).decode("latin-1")


def _unescape_pdf_text(text: str) -> str:
    """`_escape_pdf_text` の逆変換。エスケープされた括弧・バックスラッシュを戻す。"""
    return text.replace("\\(", "(").replace("\\)", ")").replace("\\\\", "\\")


_LINE_RE = r"(?P<x1>-?\d+\.\d+) (?P<y1>-?\d+\.\d+) m (?P<x2>-?\d+\.\d+) (?P<y2>-?\d+\.\d+) l S"
_TEXT_RE = r"BT /F1 (?P<size>\d+\.\d+) Tf 1 0 0 1 (?P<tx>-?\d+\.\d+) (?P<ty>-?\d+\.\d+) Tm \((?P<body>.*?)\) Tj ET"
_COMBINED_RE = re.compile(f"(?:{_LINE_RE})|(?:{_TEXT_RE})")


def _parse_content_ops(content: str) -> list:
    """コンテンツストリームから罫線オペレータとテキストオペレータを出現順に取り出す。"""
    ops = []
    for m in _COMBINED_RE.finditer(content):
        if m.group("x1") is not None:
            ops.append(("line", float(m.group("x1")), float(m.group("y1")), float(m.group("x2")), float(m.group("y2"))))
        else:
            ops.append(("text", float(m.group("size")), float(m.group("tx")), float(m.group("ty")), _unescape_pdf_text(m.group("body"))))
    return ops


def _build_table(lines: list, cells: list) -> dict:
    """罫線群とセル文字群から、罫線交点で決まる行数・列数の表(bbox・行データ)を復元する。"""
    horiz = [op for op in lines if abs(op[2] - op[4]) < 1e-6]
    vert = [op for op in lines if abs(op[1] - op[3]) < 1e-6]
    horiz_ys = sorted({round(op[2], 2) for op in horiz}, reverse=True)
    vert_xs = sorted({round(op[1], 2) for op in vert})
    rows_count = max(len(horiz_ys) - 1, 0)
    cols_count = max(len(vert_xs) - 1, 0)
    bbox = (
        vert_xs[0] if vert_xs else 0.0,
        horiz_ys[-1] if horiz_ys else 0.0,
        vert_xs[-1] if vert_xs else 0.0,
        horiz_ys[0] if horiz_ys else 0.0,
    )
    raw_rows = []
    idx = 0
    for _ in range(rows_count):
        row_vals = []
        for _ in range(cols_count):
            row_vals.append(cells[idx][4] if idx < len(cells) else "")
            idx += 1
        raw_rows.append(row_vals)
    return {"bbox": tuple(round(float(v), 1) for v in bbox), "rows": raw_rows}


def _extract_tables_from_ops(ops: list) -> list:
    """フォントサイズ(タイトル14.0/キャプション10.0/セル9.0)を手がかりに表単位へ区切る。"""
    tables = []
    current_lines: list = []
    current_cells: list = []
    in_table = False
    for op in ops:
        if op[0] == "text":
            size = op[1]
            if abs(size - CAPTION_FONT_SIZE) < 1e-6:
                if in_table and current_lines:
                    tables.append(_build_table(current_lines, current_cells))
                current_lines, current_cells, in_table = [], [], True
            elif in_table and abs(size - CELL_FONT_SIZE) < 1e-6:
                current_cells.append(op)
        elif op[0] == "line" and in_table:
            current_lines.append(op)
    if in_table and current_lines:
        tables.append(_build_table(current_lines, current_cells))
    return tables


# 検出した表を pandas の DataFrame へ整形

def _table_to_frame(raw_table: list) -> pd.DataFrame:
    """先頭行を列名として DataFrame 化し、空セル・改行・重複列名をならす。"""
    cleaned = [
        [("" if cell is None else str(cell)).replace("\n", " ").strip() for cell in row]
        for row in raw_table
    ]
    if not cleaned:
        return pd.DataFrame()

    header, body = cleaned[0], cleaned[1:]
    columns, seen = [], {}
    for i, name in enumerate(header):
        label = name if name else f"col{i + 1}"
        if label in seen:
            seen[label] += 1
            label = f"{label}_{seen[label]}"
        else:
            seen[label] = 0
        columns.append(label)
    return pd.DataFrame(body, columns=columns)


def _collect_tables(pdf_path: Path, page_no=None):
    """PDF を開いてページ別・表別の検出結果を集める(自前パーサーで走査)。"""
    data = pdf_path.read_bytes()
    objects = _load_pdf_objects(data)
    page_numbers = _page_object_numbers(objects)
    page_count = len(page_numbers)

    results = []
    for index, page_num in enumerate(page_numbers, start=1):
        if page_no is not None and index != page_no:
            continue
        content = _page_content_text(objects, page_num)
        ops = _parse_content_ops(content)
        for t_index, table in enumerate(_extract_tables_from_ops(ops), start=1):
            results.append(
                {
                    "page": index,
                    "index": t_index,
                    "bbox": table["bbox"],
                    "frame": _table_to_frame(table["rows"]),
                }
            )
    return page_count, results


# 公開CLIコマンド1: サンプルPDFを作成する公開CLIコマンド

def cmd_make_sample(args) -> str:
    """サンプルPDFを作成する公開CLIコマンド。手元にPDFがなくても検証を始められるようにする。"""
    out_path = Path(args.out)
    page_count = max(1, args.pages)
    specs = _sample_pages(page_count)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_bytes(_build_pdf(specs))

    table_total = sum(len(spec["tables"]) for spec in specs)
    lines = [
        f"サンプルPDFを作成しました: {out_path}",
        f"  ページ数 : {page_count}",
        f"  表の数   : {table_total}",
        f"  サイズ   : {out_path.stat().st_size:,} バイト",
    ]
    for i, spec in enumerate(specs, start=1):
        lines.append(f"  page {i}: " + " / ".join(t["caption"] for t in spec["tables"]))
    return "\n".join(lines)


# 公開CLIコマンド2: PDFのページ別・表別の検出結果を表示する公開CLIコマンド

def cmd_detect(args) -> str:
    """PDFのページ別・表別の検出結果を表示する公開CLIコマンド。表を1つずつ確認できるようにする。"""
    pdf_path = Path(args.pdf)
    if not pdf_path.is_file():
        return f"PDFが見つかりません: {pdf_path}"

    page_count, tables = _collect_tables(pdf_path, args.page)
    scope = "全ページ" if args.page is None else f"page {args.page} のみ"
    lines = [f"入力PDF : {pdf_path} ({page_count} ページ / {scope})", "-" * 72]
    for item in tables:
        frame = item["frame"]
        lines.append(
            f"page {item['page']} / table {item['index']} : "
            f"{len(frame)} 行 x {frame.shape[1]} 列  bbox={item['bbox']}"
        )
        lines.append("    列名   : " + " | ".join(map(str, frame.columns)))
        if len(frame) > 0:
            lines.append("    先頭行 : " + " | ".join(frame.iloc[0].tolist()))
    lines.append("-" * 72)
    lines.append(f"検出した表: {len(tables)} 個")
    return "\n".join(lines)


# 公開CLIコマンド3: PDF内の全表をCSVへ一括変換する公開CLIコマンド

def cmd_extract(args) -> str:
    """PDF内の全表をCSVへ一括変換する公開CLIコマンド。1表につき1ファイルのCSVを書き出す。"""
    pdf_path = Path(args.pdf)
    if not pdf_path.is_file():
        return f"PDFが見つかりません: {pdf_path}"

    out_dir = Path(args.out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    page_count, tables = _collect_tables(pdf_path)

    lines = [f"入力PDF : {pdf_path} ({page_count} ページ)", f"出力先  : {out_dir}", "-" * 72]
    total_rows = 0
    for item in tables:
        frame = item["frame"]
        name = f"{pdf_path.stem}_p{item['page']:02d}_t{item['index']:02d}.csv"
        frame.to_csv(out_dir / name, index=False, encoding=args.encoding)
        total_rows += len(frame)
        lines.append(
            f"{name:<34}<- page {item['page']} / table {item['index']}  "
            f"{len(frame)} 行 x {frame.shape[1]} 列"
        )
    lines.append("-" * 72)
    lines.append(f"CSV {len(tables)} 件 / 合計 {total_rows} 行を書き出しました")
    return "\n".join(lines)


# 公開CLIコマンド4: 出力されたCSVファイルを一覧表示する公開CLIコマンド

def cmd_list_csv(args) -> str:
    """出力されたCSVファイルを一覧表示する公開CLIコマンド。extractの結果を読み直して健全性を確かめる。"""
    out_dir = Path(args.out_dir)
    if not out_dir.is_dir():
        return f"出力ディレクトリがありません: {out_dir}"

    paths = sorted(out_dir.glob("*.csv"))
    if not paths:
        return f"CSVファイルはまだありません: {out_dir}"

    lines = [
        f"CSV一覧: {out_dir}",
        "-" * 78,
        f"{'file':<34}{'bytes':>8}{'rows':>6}{'cols':>6}  columns",
    ]
    total_rows = 0
    total_bytes = 0
    for path in paths:
        frame = pd.read_csv(path, dtype=str, keep_default_na=False, encoding=args.encoding)
        size = path.stat().st_size
        total_rows += len(frame)
        total_bytes += size
        columns = ", ".join(map(str, frame.columns))
        if len(columns) > 30:
            columns = columns[:27] + "..."
        lines.append(f"{path.name:<34}{size:>8}{len(frame):>6}{frame.shape[1]:>6}  {columns}")
    lines.append("-" * 78)
    lines.append(f"合計: {len(paths)} ファイル / {total_rows} 行 / {total_bytes:,} バイト")
    return "\n".join(lines)


# コマンドライン引数の定義

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="自前パーサーと pandas で PDF 内の表を検出し CSV へ変換する CLI"
    )
    sub = parser.add_subparsers(dest="command", required=True)

    p_sample = sub.add_parser("make-sample", help="サンプルPDFを作成する公開CLIコマンド")
    p_sample.add_argument("--out", default="sample_tables.pdf", help="出力するPDFのパス")
    p_sample.add_argument("--pages", type=int, default=3, help="生成するページ数")

    p_detect = sub.add_parser("detect", help="PDFのページ別・表別の検出結果を表示する公開CLIコマンド")
    p_detect.add_argument("pdf", help="入力PDFのパス")
    p_detect.add_argument("--page", type=int, default=None, help="対象ページ番号(1始まり)")

    p_extract = sub.add_parser("extract", help="PDF内の全表をCSVへ一括変換する公開CLIコマンド")
    p_extract.add_argument("pdf", help="入力PDFのパス")
    p_extract.add_argument("--out-dir", default="csv_out", help="CSVの出力ディレクトリ")
    p_extract.add_argument("--encoding", default="utf-8-sig", help="CSVの文字コード")

    p_list = sub.add_parser("list-csv", help="出力されたCSVファイルを一覧表示する公開CLIコマンド")
    p_list.add_argument("--out-dir", default="csv_out", help="一覧表示するディレクトリ")
    p_list.add_argument("--encoding", default="utf-8-sig", help="CSVの文字コード")
    return parser


# エントリポイント

def main(argv=None) -> str:
    args = build_parser().parse_args(argv)
    handlers = {
        "make-sample": cmd_make_sample,
        "detect": cmd_detect,
        "extract": cmd_extract,
        "list-csv": cmd_list_csv,
    }
    return handlers[args.command](args)


if __name__ == "__main__":
    print(main())

コード全文は上の折り畳みに入れてあるので、全部を上から読む必要はありません。ここでは特に重要な部分だけを抜き出して、何をしているのか順番に見ていきます。

自己完結型CLIを宣言するモジュールdocstring

"""自前の最小PDFパーサーと pandas で PDF 内の表を検出し CSV へ変換する自己完結型 CLI。
実行環境に pdfplumber が無くても動くよう、サンプルPDFの生成フォーマットに合わせた
専用パーサーで罫線とテキストを読み取り、表を復元する。"""

ファイル先頭の文字列が、このツールの目的書きになっています。表を検出してCSVへ変換する自己完結型のCLIであることと、専用パーサーを持たせた理由が最初の3行で伝わります。

argparseとpandasに絞った依存関係

from __future__ import annotations

import argparse
import re
from pathlib import Path

import pandas as pd

外部ライブラリはpandasだけで、引数解析は標準のargparse、パス操作はpathlibPathに任せています。from __future__ import annotationsを書いておくと型注釈が文字列として扱われ、新しい書き方を安全に使えます。

A4サイズと行高を決める用紙の定数

PAGE_WIDTH = 595.0
PAGE_HEIGHT = 842.0
MARGIN = 50.0
ROW_HEIGHT = 18.0
CELL_FONT_SIZE = 9.0
CAPTION_FONT_SIZE = 10.0
TITLE_FONT_SIZE = 14.0

用紙サイズ、余白、行の高さ、文字サイズを定数にまとめています。数値を1か所へ集めておくと、表の見た目を変えたいときに定義の書き換えだけで済みます。

SAMPLE_TEMPLATESで表の骨組みを定義

SAMPLE_TEMPLATES = [
    {
        "title": "Sample Mart Sales Report",
        "tables": [
            {
                "caption": "Table A: Quarterly Sales by Region",
                "widths": [140.0, 90.0, 90.0, 90.0],
                "header": ["Region", "Q1", "Q2", "Q3"],

サンプルPDFの中身を辞書のリストで宣言しています。titleがページ見出し、captionが表の説明、widthsが列幅で、この列幅がそのまま縦罫線の位置になります。

headerとrowsを分けて持つ表データ

                "rows": [
                    ["Tokyo", "1240", "1385", "1502"],
                    ["Osaka", "980", "1024", "1108"],
                    ["Nagoya", "760", "812", "845"],
                    ["Fukuoka", "530", "574", "610"],
                ],

headerrowsを分けているところが要点です。PDFへ描くときは同じ格子に並べますが、抽出後はheaderが列名、rowsが値としてDataFrameへ入ります。数値もいったん文字列で持たせ、描画時の扱いを揃えました。

5列の在庫表で列数の違いを検証

                "caption": "Table C: Stock by SKU",
                "widths": [90.0, 175.0, 75.0, 75.0, 80.0],
                "header": ["SKU", "Item", "Stock", "Safety", "Status"],
                "rows": [
                    ["SKU-1001", "Cold Brew Coffee 1L", "128", "60", "OK"],
                    ["SKU-1002", "Olive Oil 500ml", "42", "50", "LOW"],

在庫表だけ列を5つに増やしています。列数の違う表を同じPDFへ混ぜておけば、表ごとに列数を判定できているかをdetectの出力で確認できます。


Python基礎・実践(Django)

企業・法人向けのPython研修では、基礎から応用まで体系的に学べます。

Python研修の詳細

DX社員研修

企業・法人向けのDX研修では、実務に繋がるリスキリングでITレベルを向上させます。

DX研修の詳細

Javaエンジニア育成研修

企業・法人向けのJavaエンジニア育成研修では、Javaの基礎から応用まで確実に習得できます。

Java研修の詳細

新卒・新入社員向け研修

企業・法人に新入社員・新卒社員に向けたプログラミング研修を提供しています。

新入社員研修の詳細

コードキャンプのIT研修を全て見る

PythonでPDF表のCSV一括変換CLIのエラー対処

PDFの表抽出は、環境の違いとファイルパスの取り違えでつまずきやすい領域です。ここでは一般に遭遇しやすいエラーと、その切り分け方を整理しました。

エラーメッセージは最後の1行だけでなく、トレースバックの上から順に読むのがコツです。どのファイルのどの行で止まったかが分かれば、修正箇所はほぼ絞れます。

このセクションの用語

ModuleNotFoundError
importしたライブラリが見つからないときに出るエラーです。インストール先の環境違いでも起こります。
トレースバック
エラー発生時に表示される呼び出し履歴です。どの行で止まったかを上から順にたどれます。
utf-8-sig
BOM付きUTF-8を指定する文字コード名です。ExcelでCSVを開いたときの文字化けを避けやすくなります。
エラー例 原因 対処
ModuleNotFoundError: No module named 'pandas' pandasが未インストール、または別のPython環境で実行している 仮想環境を有効化してからpip install pandasを実行し、python -c "import pandas"で確認する
FileNotFoundError: sample_tables.pdf 相対パスの基準が実行ディレクトリとずれている 先にmake-sampleを実行し、同じディレクトリでファイル名を指定するか絶対パスに切り替える
表が0件として扱われる スキャン画像のPDFや罫線が無いレイアウトで、lines戦略が枠を見つけられない まず自作サンプルPDFで動作を確認し、罫線が無いPDFでは文字位置ベースの抽出戦略を検討する
列名関連のKeyError 先頭行に空欄や重複があり、そのまま列名にできない 空の列名は連番で補い、重複には接尾辞を付けてからDataFrameを作る
CSVをExcelで開くと文字化けする 保存時の文字コードがExcelの想定と合っていない to_csvencoding="utf-8-sig"を指定して書き出す

PDF表のCSV一括変換CLIで注意したい点

PDFの表抽出は、失敗しても例外にならず「表が見つからない」という静かな結果になりがちです。まず成功する形をサンプルPDFで体験しておくと、失敗との差分が見えてきます。

列名の扱いも注意が必要です。先頭行をそのまま列名にすると、空欄や重複がある表でDataFrameが扱いにくくなるので、整形の段階で埋めておきます。

ポイントとしては、ヘッダー行の扱い、CSVの文字コード、ファイルパスの3点を先に押さえておくと安心できます。

POINT

ヘッダー行:先頭行を列名に固定

空の列名:連番で補って重複回避

文字コード:CSVはutf-8-sigで保存

相対パス:実行ディレクトリを確認

さらに詳しく学べるPython研修の詳細はこちら

PDF表のCSV一括変換CLIの動作確認

実際にCLIツールを5回のコマンドで実行し、いずれも終了コード0で正常に終わりました。手順はサンプルPDFの生成から始め、検出、変換、一覧表示まで一直線に進めています。

detectはページ全体を対象にした場合と、--page 2でページを絞った場合の2通りを試しました。ページ指定を付けると対象が狭まるので、気になるページだけを素早く確認できます。

extract--out-dir csv_outを指定して実行し、続けてlist-csvで同じディレクトリを読み直しました。生成と確認を別コマンドに分けたおかげで、出力後の状態を独立して点検できるのが便利です。

このセクションの用語

終了コード
コマンドが終了時に返す数値です。0が正常終了で、0以外は何らかの異常を示します。
標準出力
コマンドの実行結果が表示される既定の出力先です。画面表示のほかファイルへ転送もできます。

実際に打ち込んだコマンドを実行順に並べます。

  1. python subject.py make-sample --out sample_tables.pdf --pages 3
  2. python subject.py detect sample_tables.pdf
  3. python subject.py detect sample_tables.pdf --page 2
  4. python subject.py extract sample_tables.pdf --out-dir csv_out
  5. python subject.py list-csv --out-dir csv_out
python subject.py make-sample --out sample_tables.pdf --pages 3の実行結果(終了コード0)
python subject.py make-sample --out sample_tables.pdf --pages 3の実行結果(終了コード0)
python subject.py detect sample_tables.pdfの実行結果(終了コード0)
python subject.py detect sample_tables.pdfの実行結果(終了コード0)
python subject.py detect sample_tables.pdf --page 2の実行結果(終了コード0)
python subject.py detect sample_tables.pdf --page 2の実行結果(終了コード0)
python subject.py extract sample_tables.pdf --out-dir csv_outの実行結果(終了コード0)
python subject.py extract sample_tables.pdf --out-dir csv_outの実行結果(終了コード0)
python subject.py list-csv --out-dir csv_outの実行結果(終了コード0)
python subject.py list-csv --out-dir csv_outの実行結果(終了コード0)
さらに詳しく学べるPython研修の詳細はこちら

PDF表のCSV一括変換CLIの活用例

同じレイアウトのPDFが定期的に届く業務では、抽出をコマンド化しておくと効果が大きくなります。毎回の手作業がコマンド1行に置き換わるからです。

サブコマンド構成なので、変換だけを自動実行し、確認は人がlist-csvで見るという分担もしやすいです。

このセクションの用語

定型帳票
毎月ほぼ同じレイアウトで発行される請求書や報告書などの書類で、自動処理と相性がよい対象です。
パイプライン
抽出、整形、出力のように処理を順につないだ流れです。工程ごとに分けると原因の特定が簡単になります。
使える場面 具体的な使い方
月次の定型帳票の集計 毎月届く売上PDFにextractをかけてCSV化し、pandasで前月比や地域別の合計を計算する
公開統計資料の取り込み ページ数の多い資料をdetectで先に確認し、必要な表だけを--page指定でCSVへ落としてBIツールへ渡す
在庫・棚卸データの点検 SKU別の在庫PDFをCSVに変換し、安全在庫を下回る行だけを抽出して発注候補リストを作る
過去資料の一括棚卸し 蓄積したPDFを順に変換し、list-csvの一覧で変換漏れや空ファイルが無いかを点検する
抽出ロジックの事前検証 make-sampleで列数や列幅を変えたPDFを作り、レイアウト変更に耐えるかを本番前に試す
さらに詳しく学べるPython研修の詳細はこちら

PythonでPDF表抽出を自動化して分かったこと

PDFの表抽出は、入力を自分で用意できるかどうかで難易度が大きく変わります。罫線と文字だけのサンプルPDFを先に作った結果、抽出側の挙動を落ち着いて確認できました。

生成・検出・変換・一覧という4段階をサブコマンドに分けた構成も扱いやすい形です。どこまで進んだかがコマンド単位で分かるため、途中で止まっても再開しやすくなります。

今回はCLIツールを5回のコマンドで実行し、すべて終了コード0で完走しました。まずは自作サンプルで一連の流れをなぞってから、手元の本物のPDFへ広げていくのがおすすめです。

次の一歩として試したい拡張のアイデアです。

  • 複数PDFをまとめて変換するループ処理の追加
  • 抽出した表を1つのCSVへ縦結合するconcatオプション
  • 列名や数値の書式を整える整形ルールの外部ファイル化
さらに詳しく学べるPython研修の詳細はこちら

参考にした一次情報

  1. ^ pdfplumber - Extracting tables (GitHub README). https://github.com/jsvine/pdfplumber#extracting-tables, (参照26-09-02).
  2. ^ pandas.DataFrame.to_csv — pandas documentation. https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_csv.html, (参照26-09-02).
  3. ^ argparse --- コマンドラインオプション、引数、サブコマンドのパーサー. https://docs.python.org/ja/3/library/argparse.html, (参照26-09-02).
  4. ^ PDF 32000-1:2008 (Document management — Portable document format). https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf, (参照26-09-02).

※内容は執筆時点のものです。ライブラリやサイトの仕様は変わる可能性があるため、公式ドキュメントもあわせてご確認ください。

ブログに戻る

コメントを残す

コメントは公開前に承認される必要があることにご注意ください。

企業・法人向けのIT・プログラミング・生成AI研修を探す、比較する - IT・プログラミングを知って学べるコネクトメディア CodeCampが提供するDX人材育成が可能なプログラミングやITが学べる公開講座 - IT・プログラミングを知って学べるコネクトメディア コードキャンプが提供する無料で学べるプログラミングスクール講座 - IT・プログラミングを知って学べるコネクトメディア コードキャンプDX人材育成研修 - IT・プログラミングを知って学べるコネクトメディア 3.5日の研修で、年間1,600時間の削減効果が見込まれる。東京きらぼしフィナンシャルグループのDX人材育成事例 - IT・プログラミングを知って学べるコネクトメディア 配属3ヶ月で30%の生産性向上を実現するいよぎんコンピュータサービスの新人研修に迫る - IT・プログラミングを知って学べるコネクトメディア 金融業界の業務効率化を加速するニッセイアセットマネジメントの生成AI×GAS活用研修事例 - IT・プログラミングを知って学べるコネクトメディア 【製造業のDX人材育成事例】デジタル人材の即戦力化を実現する、日本ガイシ株式会社の異動者向オンボーディング研修 - ITやプログラミングを知って学べるコネクトメディア フューチャーアーキテクト株式会社が実現した新入社員向けIT研修プログラムでタスクフォース制度が主体的な学びと成長を生み出す - IT・プログラミングを知って学べるコネクトメディア コードキャンプDX人材育成研修 - IT・プログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/【IT新入社員研修】オンラインとオフラインの最適バランスを実現したFutureOneの導入事例 - IT・プログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/【新入社員研修】柔軟なハイブリッド型Java研修で実現した新卒20名の成長と成果|サークレイス株式会社 - ITやプログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/現場により近いところにデジタルを根付かせるDX基礎講座研修|株式会社ブリヂストン - ITやプログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/業務の効率化・DX推進に向けたIT人材育成への第一歩|株式会社カナエ - ITやプログラミングを知って学べるコネクトメディア 企業・法人向けのIT・プログラミング研修 - ITやプログラミングを知って学べるコネクトメディア

What's New

新着記事

対象者別で探す

子供(小学生・中学生・高校生)向け
プログラミング教室検索する

子供(小学生・中学生・高校生)がロボットやプログラミング言語を学ぶことができるオフラインからオンラインスクールを検索、比較することが可能です。

子供(小学生・中学生・高校生)
プログラミング教室検索する

ITやプログラムなどの
最新情報を検索する

日々、新しいITやプログラミング言語の情報が流れていきますが、特定の情報を時系列でニュースやコラムを確認することができます。

ITやプログラムなどの
最新情報を検索する