おはようございます、こんにちは、こんばんは!
いつでも笑顔、いけちゃんです。
突然ですが皆さん、PDFデータに押印をまとめて押したいなぁ、と感じたことはありませんか?
自分はつい先日、400枚程度の請求書データをメール配信することとなり、大変慌てました。
だってそうでしょ?PDFって、加工が結構面倒なんですよ。
仮に編集が楽な拡張子データだったとしても、400枚を一つずつ編集していくのは面倒極まりない状況です。
この作業は毎年発生しているものだったので、どうにかプログラミングのpの字も知らない「ド初心者」でも自動化に取り組めないかな、と考えておりました。
なお、今回実施しようとしているPDF加工は下記の2点です。
400枚のPDFデータは全て同じデザインだが「2ページ」となっているため、1ページ目のみ残し2ページ目を削除したい
全データに2か所、押印をしたい
さあ、早速覚えたてのGoogle Apps Script(以下、GAS)を駆使してやっていきましょう!
え、GASだけじゃPDF加工は出来ないの!?
なんと、GAS単独ではPDF加工が出来ないというのです!
いけちゃんはプログラミングのpの字も知らない「ド初心者」ですので、これには大いに焦りました・・・
しかし、ChatGPTから返ってきた回答には、もう一点、希望の灯がともされていました!
Googleのサービスには、Pythonを利用できる環境がある!
私は生成AIを活用し始めてからプログラミング言語を知った「ド初心者」なので、主に業務上で使用しているGoogle Workspaceアカウントでの使用に適しているGASばかり使用しておりました。
しかし、実はGoogle Workspaceにはこの他にもプログラミング言語を使用するための様々な環境構築が出来るようになっているらしく、そのうちの一つのサービスとして、ブラウザから直接Pythonを記述、実行できるサービスGoogle Colaboratoryがあるんです!!
↓詳しい説明は下記Googleのリンクを参照
Pythonもプログラミング言語の一つですが、世界的にも有名なようで私も名前は昔から知っていました。
ですが、プログラミング言語というだけで「ド初心者」の私は全く触れていなかった・・・
しかし生成AIが登場した今、ここは一つのチャレンジだと思い、ついに環境構築から始め、Pythonを活用してPD加工にチャレンジしてみることにしました!
複数ページになっているPDFデータを、1ページ目のみにして上書き保存しよう!
と、いうことで早速ChatGPTに聞いていきましょう。
聞いていくときはあくまで一歩ずつ、まずは下記1点について実行できるようにしました。
400枚のPDFデータは全て同じデザインだが「2ページ」となっているため、1ページ目のみ残し2ページ目を削除したい
こ・・・これはとても期待できるぞ!!結果はいかに!?
# =========================
# Google Drive内PDFを「1ページ目だけ」にして上書き保存(Colab完全版)
# =========================
# 1) 必要ライブラリ
!pip -q install pypdf
# 2) インポート & 認証
from google.colab import auth
auth.authenticate_user()
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
from pypdf import PdfReader, PdfWriter
import io, time, math, sys, traceback
# 3) 設定値(必要に応じて変更)
FOLDER_ID = "請求書が保存されているGoogle Drive ID" # 対象フォルダID
PAGE_SIZE = 1000 # 1回の取得上限(最大1000)
SIZE_LIMIT_MB = 32 # 32MB超はスキップ(メモリ・安定性のため)
MAX_FILES_PER_RUN = None # Noneなら全件、数値ならその件数まで処理
SLEEP_BETWEEN_FILES_SEC = 0.1 # Drive API呼び出しの息継ぎ
RETRY_MAX = 3 # API等の一時失敗リトライ回数
# 4) Driveサービス初期化
drive_service = build('drive', 'v3')
def list_all_pdfs(folder_id):
"""対象フォルダ内の全PDFファイルをページネーションで取得"""
all_files = []
page_token = None
while True:
resp = drive_service.files().list(
q=f"'{folder_id}' in parents and mimeType='application/pdf' and trashed=false",
fields="nextPageToken, files(id, name, size)",
pageSize=PAGE_SIZE,
pageToken=page_token
).execute()
all_files.extend(resp.get('files', []))
page_token = resp.get('nextPageToken')
if not page_token:
break
return all_files
def retry_call(fn, *args, **kwargs):
"""指数バックオフ付きの簡易リトライ"""
delay = 1.0
for attempt in range(1, RETRY_MAX + 1):
try:
return fn(*args, **kwargs)
except Exception as e:
if attempt == RETRY_MAX:
raise
time.sleep(delay)
delay *= 2
def download_pdf_to_bytes(file_id):
"""DriveのPDFをBytesIOで取得"""
request = drive_service.files().get_media(fileId=file_id)
fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request)
done = False
while not done:
status, done = retry_call(downloader.next_chunk)
fh.seek(0)
return fh
def make_first_page_pdf(src_bytes_io):
"""BytesIOのPDF -> 1ページ目だけのPDF(BytesIO)"""
reader = PdfReader(src_bytes_io)
if len(reader.pages) == 0:
return None
writer = PdfWriter()
writer.add_page(reader.pages[0])
out_stream = io.BytesIO()
writer.write(out_stream)
out_stream.seek(0)
return out_stream
def update_drive_file(file_id, pdf_bytes_io):
"""同一ファイルIDへPDFを上書き"""
media = MediaIoBaseUpload(pdf_bytes_io, mimetype='application/pdf', resumable=True)
retry_call(drive_service.files().update, fileId=file_id, media_body=media).execute()
# 5) メイン処理
all_files = list_all_pdfs(FOLDER_ID)
total = len(all_files)
print(f"発見PDF: {total} 件")
processed = 0
skipped = 0
failed = 0
to_process = all_files if MAX_FILES_PER_RUN is None else all_files[:MAX_FILES_PER_RUN]
for idx, f in enumerate(to_process, 1):
try:
file_id = f['id']
name = f['name']
size = int(f.get('size', 0)) if f.get('size') is not None else 0
size_mb = size / (1024 * 1024) if size else 0
print(f"[{idx}/{len(to_process)}] 処理中: {name} ({size_mb:.2f} MB)")
# サイズ制限(安定運用のため)
if SIZE_LIMIT_MB and size > SIZE_LIMIT_MB * 1024 * 1024:
print(f" → {SIZE_LIMIT_MB}MB超のためスキップ")
skipped += 1
continue
# ダウンロード
src_bytes = download_pdf_to_bytes(file_id)
# 1ページPDF化
out_bytes = make_first_page_pdf(src_bytes)
if out_bytes is None:
print(" → ページが0のためスキップ")
skipped += 1
continue
# 上書き
update_drive_file(file_id, out_bytes)
processed += 1
print(" → 完了")
except Exception as e:
failed += 1
print(" → 失敗:", e)
traceback.print_exc(file=sys.stdout)
finally:
time.sleep(SLEEP_BETWEEN_FILES_SEC)
print("\n===== 結果 =====")
print(f"処理成功: {processed}")
print(f"スキップ: {skipped}")
print(f"失敗 : {failed}")
print("完了!")
今回、ChatGPTとのやり取りは若干省略していますが、最終的に上記のようなコードが払い出されました。
では、実際に使ってみたいと思います。
↑ここまでがGoogle Colabを使用するまでの初回使用時のみの設定
⑧Google Drive内の左上にある「新規」をクリックしましょう

⑨「その他」項目からGoogle Colaboratoryをクリックしましょう

⑩Google Colaboratory基本画面、私も慣れていないので使うところだけ説明してます

⑪コード入力欄に、ChatGPTから払い出されたコードをコピー&ペーストしましょう
コードに名前を付けたい場合はこのタイミングで変更しておいてOKです

⑬初回は下記のような画面が表示されるので、右下の「許可」をクリックしましょう

⑭PDFデータを保存しているGoogleアカウントをクリックしましょう
基本はPythonコードを起動しているアカウント1つしか表示されないと思います

実際にPDFデータも見てみましょう、1ページ目のみが保存されていることを確認できるかと思います!
いやぁ、本当にできちゃうとは凄いな、Python・・・!
PDFデータへ2カ所の電子押印をしてみよう!
さて、ページ数の添削が出来たので、もう一つの課題にチャレンジしてみます。
全データに2か所、押印をしたい
通常、押印は紙で印刷した後に、利き手を痛める400枚の押印(!)、更にその後PDFとして保存しなおさなければなりません。
その時間、プライスレス・・・!
ですが、今回の件で、PDF加工をGoogle Colaboratory環境でPythonを起動させることで実施できることがわかりました。
と、いうことはもしかして、PDFデータにJPGデータを合成することだって出来るのでは・・・?
早速試してみましたよ、頼むぞChatGPT!!もう、相棒でございます。
こちらのコードは、実行の前に電子印のjpgデータの準備が必要です。
Goole Drive内に「電子印」フォルダを作成し、2種類の電子印を保存してください。
2種類の電子印のリンクを確認し、IDをコードにコピペできるようにしておきましょう。
完成版コードは下記のとおりです。
# =========================
# Google Drive内PDFの1ページ目に電子印(JPG)を自動合成
# - 出力: Drive直下に "PDF_電子印_出力" フォルダを作り、<元名>_signed.pdf を保存
# 依存: PyMuPDF (fitz), googleapiclient
# =========================
!pip -q install PyMuPDF==1.24.9 google-api-python-client google-auth google-auth-oauthlib
import io, os, sys, math, time
from typing import Optional, Tuple
import fitz # PyMuPDF
from google.colab import auth
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google.auth.exceptions import RefreshError
from google.auth import default as google_auth_default
# ====== 設定 ======
PDF_FOLDER_ID = "請求書が保存されているGoogle Drive ID"
SEAL_FOLDER_ID = "電子印(jpg)が保存されているGoogle Drive ID"
OUTPUT_FOLDER_NAME = "PDF_電子印_出力"
SEAL_APPROVER_NAME = "承認者印"
SEAL_ISSUER_NAME = "発行者印"
SEAL_APPROVER_FILE = "承認者印.jpg"
SEAL_ISSUER_FILE = "発行者印.jpg"
# □枠にキレイに収めるためのサイズ調整と余白(ポイント)
SEAL_SIZE_SCALE = 1.20 # テキスト高さに対する倍率(□が文字高に近い前提。大きければ下げる)
SEAL_RIGHT_MARGIN = 6.0 # テキスト左端から左側□までの余白(右寄せ微調整)
SEAL_TOP_OFFSET = -1.0 # テキスト上端からの微調整(上にズラす)
# ====== 認証 & Drive API 準備 ======
auth.authenticate_user()
creds, _ = google_auth_default(scopes=['https://www.googleapis.com/auth/drive'])
if not creds.valid:
try:
creds.refresh(Request())
except Exception as e:
raise RuntimeError(f"認証の更新に失敗しました: {e}")
drive = build('drive', 'v3', credentials=creds)
def ensure_output_folder(folder_name: str) -> str:
q = f"name = '{folder_name}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
res = drive.files().list(q=q, fields="files(id, name)").execute()
files = res.get('files', [])
if files:
return files[0]['id']
meta = {'name': folder_name, 'mimeType': 'application/vnd.google-apps.folder'}
created = drive.files().create(body=meta, fields="id").execute()
return created['id']
OUTPUT_FOLDER_ID = ensure_output_folder(OUTPUT_FOLDER_NAME)
def list_files_in_folder(folder_id: str, mime_prefix: Optional[str]=None):
"""フォルダ直下ファイルを列挙(必要ならMIMEで絞り込み)"""
page_token = None
results = []
while True:
q = f"'{folder_id}' in parents and trashed = false"
if mime_prefix:
q += f" and mimeType contains '{mime_prefix}'"
resp = drive.files().list(q=q,
spaces='drive',
fields="nextPageToken, files(id, name, mimeType)",
pageToken=page_token).execute()
items = resp.get('files', [])
results.extend(items)
page_token = resp.get('nextPageToken', None)
if not page_token:
break
return results
def download_file(file_id: str) -> bytes:
buf = io.BytesIO()
request = drive.files().get_media(fileId=file_id)
downloader = MediaIoBaseDownload(buf, request)
done = False
while not done:
status, done = downloader.next_chunk()
buf.seek(0)
return buf.read()
def upload_pdf(bytes_data: bytes, name: str, parent_id: str):
media = MediaIoBaseUpload(io.BytesIO(bytes_data), mimetype='application/pdf', resumable=False)
file_metadata = {'name': name, 'parents': [parent_id]}
return drive.files().create(body=file_metadata, media_body=media, fields="id").execute()
# ====== 印影画像の取得 ======
seal_files = list_files_in_folder(SEAL_FOLDER_ID)
seal_map = {f['name']: f['id'] for f in seal_files}
if SEAL_APPROVER_FILE not in seal_map or SEAL_ISSUER_FILE not in seal_map:
raise FileNotFoundError(
f"印影フォルダ({SEAL_FOLDER_ID}) に '{SEAL_APPROVER_FILE}' と '{SEAL_ISSUER_FILE}' があるか確認してください。"
)
seal_approver_bytes = download_file(seal_map[SEAL_APPROVER_FILE])
seal_issuer_bytes = download_file(seal_map[SEAL_ISSUER_FILE])
# ====== テキスト検索&配置 ======
def find_text_box(page: fitz.Page, text: str) -> Optional[fitz.Rect]:
"""ページ内で 'text' を検索し、最初の一致の矩形を返す。見つからなければ None。"""
rects = page.search_for(text)
if rects:
# 通常、右上領域にある想定なので、yが小さい順/ xが大きい順などで並べ替えたい場合は下を利用
# rects.sort(key=lambda r: (r.y0, -r.x1))
return rects[0]
return None
def paste_image_left_of_text(page: fitz.Page, img_bytes: bytes, text_rect: fitz.Rect):
"""テキスト矩形の左側に、テキスト高さに合わせた正方形で印影を貼る。"""
text_h = text_rect.height
size = text_h * SEAL_SIZE_SCALE
right = text_rect.x0 - SEAL_RIGHT_MARGIN
left = right - size
top = text_rect.y0 + SEAL_TOP_OFFSET
bottom= top + size
box = fitz.Rect(left, top, right, bottom)
# 画面外などに出ないよう軽い保護(必要なら調整)
page_rect = page.rect
if box.y0 < page_rect.y0:
box = fitz.Rect(box.x0, page_rect.y0, box.x1, page_rect.y0 + size)
if box.x0 < page_rect.x0:
# はみ出る場合は右に寄せる
box = fitz.Rect(page_rect.x0, box.y0, page_rect.x0 + size, box.y0 + size)
page.insert_image(box, stream=img_bytes, keep_proportion=True)
def process_pdf(pdf_bytes: bytes,
approver_img: bytes,
issuer_img: bytes) -> bytes:
"""1ページ目に印影2つを貼り付けて新しいPDFのbytesを返す"""
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
if doc.page_count < 1:
return pdf_bytes # 念のため
page = doc[0]
# 「承認者印」「発行者印」を探す(フォント埋め込み/アウトライン化で失敗時のフォールバックあり)
approver_rect = find_text_box(page, "承認者印") or find_text_box(page, "承認者")
issuer_rect = find_text_box(page, "発行者印") or find_text_box(page, "発行者")
# 右上にある想定だが、見つからない場合の軽いフォールバック(ページ右上隅の近くに仮配置)
if approver_rect is None and issuer_rect is None:
# どちらも見つからなければ何もせず返す(誤配置防止)
out = io.BytesIO()
doc.save(out)
doc.close()
return out.getvalue()
if approver_rect:
paste_image_left_of_text(page, approver_img, approver_rect)
if issuer_rect:
paste_image_left_of_text(page, issuer_img, issuer_rect)
out = io.BytesIO()
doc.save(out, deflate=True)
doc.close()
return out.getvalue()
# ====== 実行:PDFフォルダ内の全PDF処理 ======
pdf_files = list_files_in_folder(PDF_FOLDER_ID, mime_prefix="pdf")
if not pdf_files:
print("PDFフォルダ内にPDFが見つかりませんでした。フォルダIDや権限をご確認ください。")
for f in pdf_files:
try:
name = f['name']
fid = f['id']
if not name.lower().endswith(".pdf"):
# Googleドキュメント等の変換前ファイルへ対応するなら export が必要
continue
print(f"処理中: {name} ...")
original = download_file(fid)
signed = process_pdf(original, seal_approver_bytes, seal_issuer_bytes)
base, ext = os.path.splitext(name)
out_name = f"{base}_signed.pdf"
upload_pdf(signed, out_name, OUTPUT_FOLDER_ID)
print(f" -> 完了: {out_name}")
except Exception as e:
print(f"エラー: {f.get('name')} / {e}")
print(f"\n完了しました。出力先フォルダ: {OUTPUT_FOLDER_NAME}(Drive直下)")
さあ、さっきのPDFのページ削除のコードと同じ要領で実行してみましょう。!
PDFデータの加工が済んだら、下記記事より一斉にメール配信してしまいましょう!
一気に業務改善につながりますよ!
最後に
なんと、あれだけ当社、そして私を苦しめていたPDFの一括加工が、Pythonであればこんなに簡単に出来るということがわかったのです!
まさに革命的事案であり、しかもどの請求書にも同じことが出来るという、大変な業務改善への足掛かりとなりました。
今後は、GASとPythonをそれぞれの特徴を生かした活躍をさせて、新たな業務改善の旅へ出かけようと思います。
記事をご覧いただき、ありがとうございました!














