1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Python×Excel業務改善】openpyxlでシートごとに異なるデータ範囲と結合セルを読み取る

1
Last updated at Posted at 2026-11-06

はじめに

業務で複数シートに分かれたExcelのテストケースを横断的に検索したい場面がありました。シートを1枚ずつ開いて探すのは手間なので、Pythonのopenpyxlで必要な情報だけを抜き出し、テキストファイルに出力するスクリプトを作りました。テキストにしてしまえば、エディタやgrepで全シートをまとめて検索できます。

処理の流れはざっくり次のとおりです。

  1. Excelを読み込み、シートごとに処理する
  2. 各シートのデータ範囲を把握する
  3. テストケースを1行ずつ処理し、ステータスや必要情報の有無で出力対象を判定する
  4. 対象行の値をテキストに出力する

作る中で詰まったポイントを1つずつ紹介していきます。今回はシートごとに異なるデータ範囲と結合セルの2つです。

記事中のExcelとコードは、説明のために作成した再現用の実装例です。シート名やテストケースは架空のもので、実際の業務で使ったファイル構成やコードとは異なります。

サンプルのExcel

どちらのシートも1行目がタイトル、2行目が見出し、3行目からがテストケースです。

ログイン画面(A2:E30に罫線を設定。データは6行目まで)

行 A: No B: 大項目 C: テスト内容 D: 期待結果 E: ステータス
3 1 正常系(B3:B4を結合) 正しいID/パスワードでログイン トップ画面に遷移する 済
4 2 (結合範囲内) ログイン状態を保持してログイン 再訪問時に自動ログインされる 未
5 3 異常系(B5:B6を結合) 誤ったパスワードでログイン エラーメッセージを表示する 未
6 4 (結合範囲内) 未入力でログイン 入力エラーを表示する 未
7〜30 (空欄。罫線のみ)

検索画面(列が1つ多く、行数も違う)

行 A: No B: 大項目 C: テスト内容 D: 期待結果 E: ステータス F: 備考
3 1 正常系 キーワードで検索 一致する結果を一覧表示する 済
4 2 異常系(B4:B5を結合) 記号のみで検索 入力エラーを表示する 未 仕様確認中のため保留(F4:F5を結合)
5 3 (結合範囲内) 256文字以上で検索 入力エラーを表示する 未 (結合範囲内)

Tips 1:シートごとに異なるデータ範囲の取得

困ったこと

最初は「3〜30行目、A〜E列」のように範囲を固定して読み取っていました。しかしこの方法には次の問題があります。

  • シートごとに行数・列数が違うため、範囲外のテストケースや列(検索画面のF列など)が漏れる
  • テストケースを追加するたびにコードを直す必要がある
  • データが少ないシートでは、空行まで出力される

シートの範囲を取得するプロパティ

openpyxlのワークシートには、範囲を表すプロパティが用意されています。

プロパティ 内容
min_row / max_row セルが存在する最初の行 / 最後の行
min_column / max_column セルが存在する最初の列 / 最後の列(数値)
dimensions 上記を A1:E30 のような文字列で表したもの
check_range.py
from openpyxl import load_workbook

wb = load_workbook("testcases.xlsx")

for ws in wb.worksheets:
    print(ws.title, ws.dimensions, ws.min_row, ws.max_row, ws.min_column, ws.max_column)
実行結果
ログイン画面 A1:E30 1 30 1 5
検索画面 A1:F5 1 5 1 6

この値を使えば、シートごとに範囲を変えて読み取れます。iter_rows() に範囲を渡し、values_only=True を指定すると、セルではなく値のタプルが返ります。

export_v1.py
HEADER_ROW = 2  # 見出し行(サンプルの想定)

with open("testcases.txt", "w", encoding="utf-8") as f:
    for ws in wb.worksheets:
        for row in ws.iter_rows(min_row=HEADER_ROW + 1, max_row=ws.max_row,
                                max_col=ws.max_column, values_only=True):
            values = ["" if v is None else str(v) for v in row]
            f.write(f"[{ws.title}] " + " | ".join(values) + "\n")
testcases.txt
[ログイン画面] 1 | 正常系 | 正しいID/パスワードでログイン | トップ画面に遷移する | 済
[ログイン画面] 2 |  | ログイン状態を保持してログイン | 再訪問時に自動ログインされる | 未
[ログイン画面] 3 | 異常系 | 誤ったパスワードでログイン | エラーメッセージを表示する | 未
[ログイン画面] 4 |  | 未入力でログイン | 入力エラーを表示する | 未
[ログイン画面]  |  |  |  | 
[ログイン画面]  |  |  |  | 
(30行目まで空行が続く)
[検索画面] 1 | 正常系 | キーワードで検索 | 一致する結果を一覧表示する | 済 | 
[検索画面] 2 | 異常系 | 記号のみで検索 | 入力エラーを表示する | 未 | 仕様確認中のため保留
[検索画面] 3 |  | 256文字以上で検索 | 入力エラーを表示する | 未 | 

列数の違いには対応できましたが、ログイン画面では空行が大量に出力されてしまいました。

なぜ空行が出るのか

max_row などは「値が入っている範囲」ではなく、ワークシート上にセルが存在する範囲を返します。罫線や背景色などの書式だけが設定された空のセルも「存在するセル」として数えられるため、サンプルでは罫線を引いた30行目までが範囲になりました。値を消しても書式が残っているセルも同様です。

つまり、次の2つは別物として考える必要があります。

  • データ範囲:シート上にセルが存在する範囲(max_row などで取得)
  • テストケースの範囲:実際にテストケースが入っている範囲(自分で判定する)

対処法:キー列と見出し行から実データの範囲を判定する

テストケースには必ずNoがある、見出し行には必ず項目名がある、という前提を置き、max_row / max_column から逆向きに走査して最後の値を探します。

range_utils.py
def has_value(v):
    return v is not None and str(v).strip() != ""

def get_last_row(ws, key_col=1):
    """キー列(No列)に値がある最後の行"""
    for r in range(ws.max_row, HEADER_ROW, -1):
        if has_value(ws.cell(row=r, column=key_col).value):
            return r
    return HEADER_ROW  # テストケースなし

def get_last_col(ws):
    """見出し行に値がある最後の列"""
    for c in range(ws.max_column, 0, -1):
        if has_value(ws.cell(row=HEADER_ROW, column=c).value):
            return c
    return 0

max_row は「ここより下にはセルがない」という上限として使い、テストケースの終わりはデータの中身で判定する、という役割分担です。

通常モードの ws.cell() は、存在しないセルを指定すると新しく作成します。範囲外を参照すると max_row などが変わってしまうため、走査は max_row / max_column の内側に収めています。

また、load_workbook(..., read_only=True) で開いた場合、範囲はファイルに記録された情報をもとに返されるため、実際とずれることがあります。次のTipsで使う結合セル情報も読み取り専用モードでは参照できないため、今回は通常モードで読み込んでいます。

このあと range(HEADER_ROW + 1, get_last_row(ws) + 1) で行を回せば、空行は出力されなくなります(出力コードはTips 2でまとめて掲載します)。

Tips 2:結合セルからの値の取得

困ったこと

Tips 1の出力をよく見ると、ログイン画面のNo.2・4や検索画面のNo.3で「大項目」が空になっています。Excel上では「正常系」「異常系」と表示されているのに、です。これでは「正常系」でgrepしても、No.2がヒットしません。

原因

Excelのファイル上では、結合セルの値は結合範囲の左上のセルにだけ保存されています。それ以外のセルは「結合されている」という情報があるだけで、値を持っていません。

openpyxlでは、左上以外のセルは MergedCell という特殊なオブジェクトとして読み込まれ、value は常に None です。

from openpyxl.cell.cell import MergedCell

ws = wb["ログイン画面"]
print(ws["B3"].value)                    # 正常系
print(ws["B4"].value)                    # None
print(isinstance(ws["B4"], MergedCell))  # True

MergedCell で「結合範囲の左上以外であること」は分かりますが、どこが左上なのかは分かりません。そこで結合範囲の一覧を使います。

ws.merged_cells.ranges で結合範囲を調べる

ws.merged_cells.ranges には、シート内の結合範囲が CellRange オブジェクトとして入っています。各範囲は行・列の最小値と最大値を持っています。

for rng in ws.merged_cells.ranges:
    print(rng.coord, rng.min_row, rng.max_row, rng.min_col, rng.max_col)
実行結果(ログイン画面。順序は保証されません)
B3:B4 3 4 2 2
B5:B6 5 6 2 2

対象セルの行・列がこの範囲に収まっていれば結合範囲内であり、左上セルは (min_row, min_col) です。なお "B4" in rng のように座標文字列での判定もできますが、今回は行番号・列番号で処理しているので数値で比較します。

merged_utils.py
def get_merged_value(ws, row, col):
    """結合範囲内なら左上セルの値、そうでなければセル自身の値を返す"""
    for rng in ws.merged_cells.ranges:
        if rng.min_row <= row <= rng.max_row and rng.min_col <= col <= rng.max_col:
            return ws.cell(row=rng.min_row, column=rng.min_col).value
    return ws.cell(row=row, column=col).value

セルごとに全結合範囲をループするので、結合が非常に多いシートでは、シートごとに {(行, 列): 値} の辞書を一度作っておく方が効率的です。

値を書き込まず、読み取り時に参照する

MergedCell の value は読み取り専用で、代入すると AttributeError になります。unmerge_cells() で結合を解除してから値を埋める方法もありますが、元のファイルの構造を変えることになり、保存先を間違えると元データを壊すリスクもあります。今回の目的は読み取りだけなので、Excelには手を加えず、読み取り時に左上の値を参照する方式にしました。

すべての行に同じ値を出すべきとは限らない

結合セルの値を全行に補完するかどうかは、その列の意味によって変わります。

  • 大項目のような分類:各行に補完すると、1行だけで内容が分かり検索しやすい
  • 備考のような補足:複数ケースへのまとめ書きを各行に複製すると、ケースごとに個別の備考があるように見え、後で件数を数えるときに重複する
  • No列など1件のテストケースを複数行で書いている場合:補完すると同じテストケースが複数件あるように見えてしまう

そこで、補完する列を明示的に指定することにしました。

export_v2.py
FILL_COLS = {2}  # 結合セルを各行に補完する列(B列=大項目)

with open("testcases.txt", "w", encoding="utf-8") as f:
    for ws in wb.worksheets:
        last_row, last_col = get_last_row(ws), get_last_col(ws)
        for r in range(HEADER_ROW + 1, last_row + 1):
            values = []
            for c in range(1, last_col + 1):
                if c in FILL_COLS:
                    v = get_merged_value(ws, r, c)
                else:
                    v = ws.cell(row=r, column=c).value
                values.append("" if v is None else str(v))
            f.write(f"[{ws.title}] " + " | ".join(values) + "\n")
testcases.txt
[ログイン画面] 1 | 正常系 | 正しいID/パスワードでログイン | トップ画面に遷移する | 済
[ログイン画面] 2 | 正常系 | ログイン状態を保持してログイン | 再訪問時に自動ログインされる | 未
[ログイン画面] 3 | 異常系 | 誤ったパスワードでログイン | エラーメッセージを表示する | 未
[ログイン画面] 4 | 異常系 | 未入力でログイン | 入力エラーを表示する | 未
[検索画面] 1 | 正常系 | キーワードで検索 | 一致する結果を一覧表示する | 済 | 
[検索画面] 2 | 異常系 | 記号のみで検索 | 入力エラーを表示する | 未 | 仕様確認中のため保留
[検索画面] 3 | 異常系 | 256文字以上で検索 | 入力エラーを表示する | 未 | 

空行がなくなり、大項目はすべての行に出力されました。一方、備考は補完対象外なので、元のExcelと同じく最初の行にだけ出ています。

まとめ

  • max_row / max_column は「セルが存在する範囲」であり、書式だけのセルも含まれる。テストケースの範囲は、キー列や見出し行の値をもとに別途判定する
  • 結合セルの値は左上のセルにしかなく、それ以外は MergedCell(値は None)として読み込まれる
  • ws.merged_cells.ranges で結合範囲を調べ、読み取り時に左上セルの値を参照すれば、Excelを変更せずに対応できる
  • 結合セルを全行に補完するかは列の意味次第。補完する列は明示的に決めておくと安全

参考資料

1
0
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?