0
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 配布用テンプレートを保護する

0
Posted at

本社財務部は毎四半期、同じ《経費予算申請書》を全部署に配布している。勘定科目・単位・単価は全社共通の予算単価で、各部署が記入してよいのは 2 列だけ——申請数量と備考である。金額欄は「数量 × 単価」の自動計算にする。素の表をそのまま配ると、返ってくるものは揃わない。合計を合わせようとして単価を書き換える部署があり、数式を手入力の数値で上書きする部署があり、行を 1 つ挿入して合計の参照範囲をずらす部署があり、科目の並びを入れ替えて統一の集計と突き合わせられなくなる部署がある。

Excel のシート保護でこの問題は解決できる。難しいのは「使えるかどうか」ではなく、繰り返し配布できるテンプレートとして仕立てるときの数か所で、既定の挙動が直感と逆になることだ。セルは最初からロック状態であり、数式の非表示は別のオブジェクトに付いており、列挙型を 1 つ余分に渡すと開けていた権限がすべて戻ってしまう。手作業でチェックを入れる分には踏まないが、スクリプトにすると引数 1 つでテンプレートが形骸化する。

本記事では Free Spire.XLS for Python でテンプレート生成をスクリプトに固定し、「下す判断」の順にそれぞれの実測結果を述べる。記入範囲をどこで切るか、ロックの印はどこから来るか、数式を隠すか、保護をどの層に掛けるか、パスワードをどう回収するか——の 5 つである。サンプルの素表(科目 12 件)と最終的な配布用テンプレートは生成済みで、そのまま開いて照合できる。

pip install spire.xls.free

保護を掛けていない《経費予算申請書》の素表。科目・単価・数式のすべてが書き換え可能


判断 1:記入者に渡す範囲をどこで切るか

記入範囲はこのテンプレートの対外インターフェースそのものなので、最初にここを確定させる。サンプル表の構造は、4 行目が見出し、5〜16 行目が明細、17 行目が合計で、列は No. / 勘定科目 / 単位 / 申請数量(D) / 単価(E) / 申請金額(F) / 備考(G) である。開ける範囲は 2 つだけになる。

OPEN_RANGES = [("申請数量", "D5:D16"), ("備考", "G5:G16")]

この 2 つの範囲は2 系統の仕組みで同時に指定する。ひとつは記入者に見せるためのもので、記入範囲を薄い黄色で塗り、ファイルを開いた瞬間にどこへ書けるか分かるようにする。もうひとつは Excel に伝えるためのもので、範囲を「編集できる範囲」として登録し、名前を付ける。

from spire.xls import Workbook, ExcelVersion, SheetProtectionType
from spire.xls.common import Color

PWD = "BUD-2026Q4"
OPEN_RANGES = [("申請数量", "D5:D16"), ("備考", "G5:G16")]
LOCK_RANGES = ["A1:G4", "A5:C16", "E5:F16", "A17:G17"]

wb = Workbook()
wb.LoadFromFile("経費予算申請書_2026Q3_未保護.xlsx")
sheet = wb.Worksheets[0]

for _, addr in OPEN_RANGES:
    sheet.Range[addr].Style.Color = Color.get_LightYellow()

for name, addr in OPEN_RANGES:
    sheet.AddAllowEditRange(name, sheet.Range[addr])

2 系統を重ねるのは冗長ではない。セルのロックは「セルがロックされていて、かつシートが保護されているなら編集できない」という判定で、編集できる範囲はシート保護という層に開けた穴である。記入者が保護を外して掛け直した場合、編集できる範囲の登録は失われるが、記入範囲のセル自体が非ロックのままなので書ける。逆に、編集できる範囲が登録されていれば、ロックの印を消し忘れていても Excel は通す。両方を併用しておけば、どちらか一方を誤って操作されてもテンプレートが即座に使えなくなることはない。

判断 2:ロックの印はどこから来るか

保護したい範囲に Style.Locked = True を設定する——これは「掛けたい所に鍵を掛ける」という発想だが、実行すると表全体が動かなくなる。理由は Excel のセルの既定値がロックだからである。新しいブックのすべてのセルは Locked が True で、いったん保護を掛けると明示的に False にしていない領域がすべてロック状態に入る。サンプルの素表を読み戻すとこうなる。

シート保護=False、E5 のロック設定=True

まだ保護していない素表でも、E5 には既にロックの印が立っている。この状態は読み出して確認できる。

src_wb = Workbook()
src_wb.LoadFromFile("経費予算申請書_2026Q3_未保護.xlsx")
src_sheet = src_wb.Worksheets[0]
print("シート保護=%s、E5 のロック設定=%s"
      % (src_sheet.IsPasswordProtected, src_sheet.Range["E5"].Style.Locked))
src_wb.Dispose()

したがって正しい順序は先に全部を外し、それから区分ごとに掛け直すである。

sheet.Range["A1:G60"].Style.Locked = False        # まず広い範囲でロックの印を消す
for addr in LOCK_RANGES:
    sheet.Range[addr].Style.Locked = True         # 掛けるべき区間だけ戻す

クリアに A1:G60 を使い、AllocatedRange は使わない。AllocatedRange は現在内容のある範囲(サンプルでは注記のある 23 行目まで)しか覆わないが、記入者は下へ書き足したり右へ引いたりする。余白を残しておけば「端のセルだけロックされていて見た目では分からない」状態を避けられる。逆に広げすぎる(列全体など)と、生成した xlsx に不要なスタイルが大量に書き込まれ、ファイルサイズだけが膨らむ。

配布用テンプレート。薄黄色の範囲だけが記入できる

判断 3:数式を見せるか隠すか

金額欄と合計行は「見せるが変えさせない」対象だが、ロックだけでは足りない。金額のセルをクリックすると数式が数式バーにそのまま表示される。非表示は別途設定する。

sheet.Range["F5:F17"].IsFormulaHidden = True

IsFormulaHidden は Range のメンバーで、Style には無い。sheet.Range["F5:F17"].Style.IsFormulaHidden と書くとプロパティが見つからない。また非表示が意味を持つのはシート保護が有効なときだけで、変わるのは数式バーの表示であってセルの計算結果ではない。

保存した xlsx を展開して xl/styles.xml を見ると、hidden="1" を持つスタイルは 2 件で、13 件ではない。理由はスタイルが共有されるためで、F5:F16 の 12 セルが同じスタイルを共用し、合計行の F17 が別の 1 件になる。つまり数式の非表示はセルのスタイルに付く設定であり、範囲に対して指定しても実際に使われるスタイルにだけ反映され、セルごとに複製されるわけではない。

判断 4:保護はシートに掛けるかブックに掛けるか

Spire.XLS にはシート単位の sheet.Protect() とブック単位の wb.ProtectWorkbook() がある。ブック単位の代償は実測しないと分からない。サンプルにブック保護を掛けて別名保存し、ファイル先頭の 4 バイトを見る。

import os
import tempfile

probe_wb = Workbook()
probe_wb.LoadFromFile("経費予算申請書_2026Q3_未保護.xlsx")
probe_wb.ProtectWorkbook(True, True, PWD)
probe_path = os.path.join(tempfile.gettempdir(), "_wb_probe.xlsx")
probe_wb.SaveToFile(probe_path, ExcelVersion.Version2013)
probe_wb.Dispose()
with open(probe_path, "rb") as f:
    print("ファイル先頭:%r" % f.read(4))
ファイル先頭:b'\xd0\xcf\x11\xe0'

これは OLE2 複合文書のマジックナンバー、すなわち .xls の BIFF バイナリ形式である。ファイル名は .xlsx のままだが中身は OOXML パッケージではない。Excel で開くと拡張子と形式の不一致が警告される。ブック保護は「.xlsx のテンプレートを配る」用途に合わないため、本記事ではシート保護だけを使う。

シート保護には引数の落とし穴もある。Protect() はパスワードだけを渡すことも、SheetProtectionType を追加で渡すこともできる。

sheet.Protect(PWD)                                # 本記事の採用形
# sheet.Protect(PWD, SheetProtectionType.All)     # この書き方はしない

wb.SaveToFile("経費予算申請書_2026Q4_配布用テンプレート.xlsx", ExcelVersion.Version2013)
wb.Dispose()

同じテンプレート、同じロック範囲でも、パッケージに書き込まれる sheetProtection ノードは大きく変わる。次の関数は同じ組み立てを 2 回行い、最後の 1 行だけ書き方を変えて、両方のノード原文を取り出す。

import os
import re
import tempfile
import zipfile

def sheet_protection_tag(mode):
    m_wb = Workbook()
    m_wb.LoadFromFile("経費予算申請書_2026Q3_未保護.xlsx")
    m_sheet = m_wb.Worksheets[0]
    m_sheet.Range["A1:G60"].Style.Locked = False
    for addr in LOCK_RANGES:
        m_sheet.Range[addr].Style.Locked = True
    for name, addr in OPEN_RANGES:
        m_sheet.AddAllowEditRange(name, m_sheet.Range[addr])
    if mode == "all":
        m_sheet.Protect(PWD, SheetProtectionType.All)
    else:
        m_sheet.Protect(PWD)
    # ファイル名にパスワードを含め、2 つの書き方の成果物が上書きし合わないようにする
    m_path = os.path.join(tempfile.gettempdir(), "_prot_%s_%s.xlsx" % (mode, PWD))
    m_wb.SaveToFile(m_path, ExcelVersion.Version2013)
    m_wb.Dispose()
    with zipfile.ZipFile(m_path) as z:
        xml = z.read("xl/worksheets/sheet1.xml").decode("utf-8")
    return re.search(r"<sheetProtection[^>]*/>", xml).group(0)

print("Protect(PWD)      → %s" % sheet_protection_tag("plain"))
print("Protect(PWD, All) → %s" % sheet_protection_tag("all"))

2 つの探針ファイルはシステムの一時ディレクトリへ書き出し、納品ディレクトリには残さない。ファイル名は _prot_plain_BUD-2026Q4.xlsx と _prot_all_BUD-2026Q4.xlsx。

Protect(PWD)      → <sheetProtection password="9821" sheet="1" objects="1" scenarios="1" />
Protect(PWD, All) → <sheetProtection password="9821" sheet="1" formatCells="0" formatColumns="0" formatRows="0" insertColumns="0" insertRows="0" insertHyperlinks="0" deleteColumns="0" deleteRows="0" sort="0" autoFilter="0" pivotTables="0" />

OOXML でこれらの属性が表すのは「その操作を禁止する」であり、値が 0 なら禁止が解除され、操作は許可される。SheetProtectionType.All を追加で渡した結果、11 項目が余分に許可されている——セルの書式設定、列幅・行高の変更、行や列の挿入、行や列の削除、ハイパーリンクの挿入、並べ替え、フィルタ、ピボットテーブルである。本機の Excel で両方の成果物を開いて確認した。

確認項目 Protect(PWD) Protect(PWD, SheetProtectionType.All)
セルの保護 有効 有効
D5 申請数量を書ける 通る 通る
G9 備考を書ける 通る 通る
E5 単価の書き込み 遮断 遮断
B5 科目の書き込み 遮断 遮断
F5 金額の書き込み 遮断 遮断
行の挿入 遮断 通る

差は「行の挿入」の 1 行に出る。行を挿入すると合計の数式の参照範囲がずれる。これはこのテンプレートが最も避けたい操作の 1 つである。並べ替えやフィルタも、科目の並びが固定された申請書では開ける必要がない。したがってテンプレートのスクリプトではパスワードだけを渡し、明示的に開けない限りの機能は閉じたままにする。

同じテンプレートを 2 通りの Protect で保存し、Excel が解釈した権限を比べた結果(右側では行の挿入・並べ替え・フィルタなど 11 項目が余分に開く)

判断 5:パスワードをどう回収するか

テンプレートを各部署へ配ったあと、保護は外せなければならない。でなければ回収した表を統一のスクリプトで集計できない。解除には Unprotect() を使う。大文字小文字に注意が必要で、UnProtect ではない。

wb = Workbook()
wb.LoadFromFile("経費予算申請書_2026Q4_配布用テンプレート.xlsx")
s = wb.Worksheets[0]
before = s.IsPasswordProtected          # True
s.Unprotect(PWD)
after = s.IsPasswordProtected           # False
s.Range["G17"].Text = "2026-10-09 財務部確認済み"
wb.SaveToFile("経費予算申請書_2026Q4_保護解除版.xlsx", ExcelVersion.Version2013)
wb.Dispose()

chk = Workbook()
chk.LoadFromFile("経費予算申請書_2026Q4_保護解除版.xlsx")
still = chk.Worksheets[0].IsPasswordProtected
chk.Dispose()
print("解除前:%s → 解除後:%s → 別名保存して読み戻し:%s" % (before, after, still))

解除しても表上の注記(「このシートは保護されています……」)は自動では消えず、薄い黄色の塗りも残る。回収時にやることは、保護を外し、確認済みの印を書き込み、別名で保存する——この 3 つで、元のテンプレートには触れない。

解除前:True → 解除後:False → 別名保存して読み戻し:False

別名保存したファイルを読み直しても IsPasswordProtected は False のままで、解除の状態がファイルに書き込まれている。メモリ上のプロパティを書き換えただけではないことが確認できる。

配布前の確認:3 層の検証

テンプレートを生成したら「何が実際にロックされているか」を確かめる必要がある。スクリプト内の代入を見るだけでは足りない。Style.Locked はスタイル層の印であり、保存時に正しいスタイルへ書き込まれたかどうかで効き方が変わる。検証は層を分けて行い、それぞれ別の観測者を使う。

第 1 層:Spire.XLS で読み戻す。 生成物を再読み込みし、主要なセルを 1 つずつ確認する。

chk = Workbook()
chk.LoadFromFile("経費予算申請書_2026Q4_配布用テンプレート.xlsx")
s = chk.Worksheets[0]
checks = [
    ("シート保護", s.IsPasswordProtected),
    ("A4 見出しロック", s.Range["A4"].Style.Locked),
    ("B5 科目ロック", s.Range["B5"].Style.Locked),
    ("E5 単価ロック", s.Range["E5"].Style.Locked),
    ("F5 金額ロック", s.Range["F5"].Style.Locked),
    ("A17 合計ロック", s.Range["A17"].Style.Locked),
    ("D5 入力可", not s.Range["D5"].Style.Locked),
    ("D16 入力可", not s.Range["D16"].Style.Locked),
    ("G9 入力可", not s.Range["G9"].Style.Locked),
    ("F5:F17 数式非表示", s.Range["F5:F17"].IsFormulaHidden),
]
chk.Dispose()
for label, value in checks:
    print("  %-22s %s" % (label, value))
  シート保護                  True
  A4 見出しロック              True
  B5 科目ロック               True
  E5 単価ロック               True
  F5 金額ロック               True
  A17 合計ロック              True
  D5 入力可                 True
  D16 入力可                True
  G9 入力可                 True
  F5:F17 数式非表示           True

この層で見つかるのは「設定が効いていない」ことである。逆に「設定は効いたが意味が意図と逆向き」は見つけられない。読み戻している値はスクリプトが書き込んだ印そのものだからである。

第 2 層:本機の Excel で実際に開く。 Excel の COM インターフェースでテンプレートを開き、Excel 自身が解釈した権限フラグを読み、実際にセルへ書き込み、行を挿入してみる。記入者が見る画面の挙動と意図が一致していることを示せる唯一の手段であり、SheetProtectionType.All が余分に権限を開けていることに気付けたのもこの層である。

第 3 層:xlsx を zip として展開して XML を読む。 Office 系のライブラリをすべて迂回し、パッケージ内の原文を直接見る。

import re
import zipfile

z = zipfile.ZipFile("経費予算申請書_2026Q4_配布用テンプレート.xlsx")
sheet_xml = z.read("xl/worksheets/sheet1.xml").decode("utf-8")
styles_xml = z.read("xl/styles.xml").decode("utf-8")
z.close()
sp = re.search(r"<sheetProtection[^>]*/>", sheet_xml)
pr = re.search(r"<protectedRanges>.*?</protectedRanges>", sheet_xml, re.S)
hidden = len(re.findall(r'<protection[^>]*hidden="(?:1|true)"', styles_xml))
unlocked = len(re.findall(r'<protection[^>]*locked="(?:0|false)"', styles_xml))
print(sp.group(0))
print(pr.group(0))
print("非表示数式のスタイル %d 件、ロック解除セルのスタイル %d 件" % (hidden, unlocked))
<sheetProtection password="9821" sheet="1" objects="1" scenarios="1" />
<protectedRanges>
  <protectedRange sqref="D5:D16" name="申請数量" />
  <protectedRange sqref="G5:G16" name="備考" />
</protectedRanges>

得られる数字は 3 つ。非表示の印を持つスタイルが 2 件、ロック解除のスタイルが 9 件、編集できる範囲が 2 件である。この層の価値は、どのライブラリの読み取り実装にも影響されないことにある。あるライブラリが保護情報を誤って読んでも、XML の原文はそのまま調べられる。

使用したクラスとメンバー

クラス / メンバー 役割
Workbook.LoadFromFile / SaveToFile 読み込みと保存。ExcelVersion.Version2013 で OOXML を指定
Workbook.ProtectWorkbook ブック単位の保護。実測では BIFF バイナリで書き出されるため本方式では使わない
Worksheet.Range[addr] A1 形式のアドレスで範囲を取得
CellRange.Style 範囲のスタイル。Color で背景色、Font でサイズや太字
Style.Locked セルのロック印。保護より前に設定し、既定値の True から消しておく必要がある
CellRange.IsFormulaHidden 数式バーの表示を隠す。Style ではなく Range に付く
Worksheet.AddAllowEditRange 編集できる範囲を登録する。引数は範囲名と範囲オブジェクト
Worksheet.Protect シート保護。パスワードだけを渡す。SheetProtectionType は権限を余分に開ける
Worksheet.Unprotect 元のパスワードで保護を解除する
Worksheet.IsPasswordProtected 保護状態の読み戻し。検証用
Color.get_LightYellow 記入範囲の背景色

テンプレートを配布した後

このテンプレートの前提は「科目と単価は上位が決め、下位は数量だけを報告する」——記入の自由度を意図的に 2 列へ絞った構成である。実務上、部署が科目を自分で増減する必要があるなら、現在のロック範囲(A5:C16 と E5:F16)は障害になる。合計行と数式列だけをロックする形へ変えるか、科目表の保守を財務部に残したまま部署別に切り出して配布する形へ変える必要がある。

広げられる方向としては、記入範囲にデータの入力規則(単位の選択肢を限定)と条件付き書式(数量が空のとき赤く示す)を組み合わせ、記入の品質を入力の段階で縛る方法がある。テンプレートに「確認」欄を 1 行用意し、集計スクリプトから書き戻して配布・記入・確認・保管の流れを閉じる方法もある。パスワードとロック範囲を設定ファイルへ切り出せば、同じスクリプトで口径の異なる複数の申請書を扱える。いずれも使用する API は同じで、変更は OPEN_RANGES と LOCK_RANGES の 2 つの定数に集約される。

0
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
0
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?