MENU

問い合わせ


    【CAD図面PDFをExcelへ自動転記するツール開発 第7回】確認済みの部屋名を指定のExcelフォーマットの決まった欄へ転記する

    前回(第6回)では、AI・OCRで読み取った部屋名を人が図面と見比べて確認・修正し、確認済みの一覧だけを確定させる画面を作りました。いよいよ、その一覧をExcelへ書き込みます。

    第7回は、確認済みの部屋名を、既存のExcelフォーマット(テンプレート)の決まった欄へ自動で転記する処理を作ります。Pythonでの Excel 自動転記は openpyxl(Excelファイルを読み書きするPythonライブラリ)で行います。結論から言うと、ポイントは次の3つです。

    • テンプレートを開いてセルの値だけを書き込み、書式・数式・罫線には触れない
    • 元のテンプレートは上書きせず、別名で保存する
    • 結合セル・既に値がある欄・数式の欄・不正な部屋名は、書き込む前にまとめて確認し、1件でも問題があれば何も保存しない
    目次

    今回作る機能と完成イメージ

    メイン画面の「6. 実行」ボタンが、ようやく動くようになります。

    • 「5.」で確定した部屋名を、テンプレートのシート「部屋一覧」のC5から下へ順に書き込む
    • 保存先はテンプレートと同じフォルダの <テンプレート名>_転記_<日時>.xlsx(元のファイルは変更しない)
    • 書き込んだセルと保存先を完了メッセージで表示する。確認画面で確定していなければ書き込まない

    部屋名とセル位置の対応は、今回は「C5から下へ順に最大10行」というコード内の簡単な規則にしています。取引先ごとに違う様式へ対応できるよう設定ファイルに外出しするのは、次回(第8回)です。

    仕組みの説明:「開いて、値だけ書いて、別名で保存」

    要件は「決まっている様式への転記」なので、openpyxl の load_workbook() でテンプレートを開き、決まったセルに値を入れて保存します。値だけを差し替えれば、そのセルの罫線・フォント・表示形式は残ります。ただし実際の様式には、機械的に書き込むと事故につながる要素があるため、次のように扱いを決めました。

    状況扱い理由
    書き込み先が結合セルの左上書き込む結合セルの値は左上のセルが持つため
    書き込み先が結合セルの途中エラー(何も保存しない)openpyxlでは読み取り専用のセルのため
    書き込み先に数式があるエラー(上書きの了承があっても書かない)テンプレートの計算を壊さないため
    書き込み先に既に値がある上書きしてよいか画面で確認消し忘れか様式の変更かを人が判断するため
    部屋名の先頭が「= + – @」エラー数式として扱われうるため(二重チェック)
    部屋数が欄の数(10行)を超えるエラー合計行などを上書きしないため
    出力先がテンプレート自身・既存のファイルエラー原本や前回の結果を上書きしないため

    実装

    ファイル構成(第7回時点の差分)

    code/
    ├ app/
    │  ├ core/
    │  │  └ excel_writer.py          …【今回追加】テンプレートへの転記・別名保存・各種チェック
    │  └ ui/
    │     └ main_window.py           …【今回変更】「6. 実行」を確認済みリスト→Excel出力につないだ
    ├ samples/
    │  └ room_list_template.xlsx     …【今回追加】架空の「部屋一覧」テンプレート
    ├ tests/
    │  └ test_excel_writer.py        …【今回追加】pytest 51件
    ├ tools/
    │  ├ make_sample_template.py     …【今回追加】架空テンプレートの生成
    │  └ capture_screenshots.py      …【今回変更】第7回用の画面を撮影
    ├ requirements.txt               …【今回変更】openpyxl==3.1.5 を有効化
    └ .gitignore                     …【今回変更】転記結果のファイル(*_転記_*.xlsx)を追跡しない

    使うパッケージは openpyxl 3.1.5(2024年6月公開・MIT License)です。

    📰 出典:openpyxl 3.1.5(PyPI)

    架空の「部屋一覧」テンプレートを用意する(tools/make_sample_template.py)

    実務の様式にありがちな要素を詰め込んだ架空のテンプレートを作り、samples/room_list_template.xlsx として置きました。結合セル・背景色・罫線・表示形式・合計の数式・入力規則・列幅・印刷範囲を含みます。

    # tools/make_sample_template.py(抜粋)
    for row in range(FIRST_ROW, LAST_ROW + 1):          # 5〜14行目が記入欄
        ws[f"A{row}"] = row - FIRST_ROW + 1
        ws[f"D{row}"].number_format = "0.00"
        for col in "ABCDE":
            ws[f"{col}{row}"].border = _BORDER
    
    ws[f"A{TOTAL_ROW}"] = "合計面積"
    ws.merge_cells(f"A{TOTAL_ROW}:C{TOTAL_ROW}")
    ws[f"D{TOTAL_ROW}"] = f"=SUM(D{FIRST_ROW}:D{LAST_ROW})"
    ws[f"A{COUNT_ROW}"] = "部屋数"
    ws.merge_cells(f"A{COUNT_ROW}:C{COUNT_ROW}")
    ws[f"D{COUNT_ROW}"] = f"=COUNTA(C{FIRST_ROW}:C{LAST_ROW})"

    書き込み先を決める:C5から下へ順に(app/core/excel_writer.py)

    確定した一覧は図面を読む順に並んでいるので、その順にC5・C6・C7…と割り当てます。欄の数を超える場合はエラーにします。

    # app/core/excel_writer.py(抜粋)
    DEFAULT_SHEET_NAME = "部屋一覧"
    DEFAULT_START_CELL = "C5"
    DEFAULT_MAX_ROWS = 10
    
    
    def assign_cells(names, *, start_cell=DEFAULT_START_CELL, max_rows=DEFAULT_MAX_ROWS) -> list[CellAssignment]:
        try:
            column, first_row = coordinate_from_string(start_cell.upper())
        except (CellCoordinatesException, ValueError):
            raise ExcelWriteError(f"書き込み開始セルの指定が不正です: {start_cell}") from None
        if len(names) > max_rows:
            raise ExcelWriteError(
                f"部屋名が{len(names)}件あり、テンプレートの記入欄({column}{first_row}から{max_rows}行)に収まりません。"
            )
        return [CellAssignment(cell=f"{column}{first_row + i}", name=name) for i, name in enumerate(names)]

    この関数は次回、設定ファイルを読む処理に置き換えます。書き込み本体は「どのセルに何を書くか」のリストを受け取るだけなので、影響は小さく済みます。

    書き込む直前にも「= + – @」をチェックする

    第6回の確認画面で拒否した先頭の「=」「+」「-」「@」を、書き込む直前にも同じ基準でチェックします。確認画面を通らない経路(将来の一括処理など)から呼ばれても安全側に倒すためです。全角の「=」もNFKC正規化で「=」になるため拒否します。

    # app/core/excel_writer.py(抜粋)
    from app.core.review_model import FORMULA_PREFIXES, MAX_NAME_LENGTH, ReviewedRoom
    
    
    def check_cell_text(text: str) -> str:
        normalized = unicodedata.normalize("NFKC", text).strip()
        if not normalized:
            raise ExcelWriteError("空の部屋名は書き込めません。")
        if len(normalized) > MAX_NAME_LENGTH:
            raise ExcelWriteError(f"部屋名が{MAX_NAME_LENGTH}文字を超えています: {normalized[:MAX_NAME_LENGTH]}…")
        if any(unicodedata.category(ch).startswith("C") for ch in text):
            raise ExcelWriteError(f"部屋名に改行・タブなどの制御文字が含まれています: {text!r}")
        if text.lstrip().startswith(FORMULA_PREFIXES) or normalized.startswith(FORMULA_PREFIXES):
            raise ExcelWriteError(
                f"部屋名「{text}」は先頭が「=」「+」「-」「@」のため書き込めません(Excelで数式として扱われうるため)。"
            )
        return text

    openpyxl 3.1.5で試すと、数式(data_type が f)になるのは先頭が「=」の文字列だけで、「+1」「@A1」などは文字列のまま保存されました。それでも4文字とも拒否するのは、CSVへの書き出しや人による再編集で数式として解釈されうるためです。

    書き込み先のセルを全件確認してから書く

    結合セル・数式・既存の値は、書き込みを始める前に全件確認し、「半分だけ書いたファイル」ができるのを防ぎます。

    # app/core/excel_writer.py(抜粋)
    def _check_targets(worksheet, assignments, *, overwrite: bool) -> list[str]:
        not_empty: list[str] = []
        for assignment in assignments:
            cell = worksheet[assignment.cell]
            if isinstance(cell, MergedCell):             # 結合セルの左上以外
                merged = _merged_range_of(worksheet, assignment.cell) or "不明"
                top_left = merged.split(":")[0]
                raise ExcelWriteError(
                    f"{assignment.cell} は結合セル({merged})の途中のため書き込めません。"
                    f"結合の左上({top_left})を指定するか、テンプレートの結合を見直してください。"
                )
            if cell.data_type == "f" or (isinstance(cell.value, str) and cell.value.startswith("=")):
                raise ExcelWriteError(f"{assignment.cell} には数式が入っているため書き込みません(テンプレートの数式を守るため)。")
            if cell.value is not None and cell.value != "":
                not_empty.append(assignment.cell)
        if not_empty and not overwrite:
            raise CellNotEmptyError(f"書き込み先のセルに既に値が入っています: {'、'.join(not_empty)}", cells=not_empty)
        return not_empty

    openpyxlでは結合セルの左上以外は読み取り専用の MergedCell で、代入すると英語のエラーになります。事前に見分けて、原因の結合範囲を日本語で伝えます。

    テンプレートの開き方と、別名での保存

    書き込み本体は、パスの確認→部屋名のチェック→セルの割り当て→テンプレートを開く→書き込み先の確認→書き込み→保存、の順です。

    # app/core/excel_writer.py(抜粋)
    def write_rooms_to_excel(template_path, rooms, *, output_path=None, sheet_name=DEFAULT_SHEET_NAME,
                             start_cell=DEFAULT_START_CELL, max_rows=DEFAULT_MAX_ROWS,
                             overwrite=False) -> ExcelWriteResult:
        template = Path(template_path)
        output = Path(output_path) if output_path is not None else default_output_path(template)
        _check_paths(template, output)      # 原本と同じパス・既存ファイル・拡張子違いを拒否
    
        names = [check_cell_text(room.name) for room in rooms]
        if not names:
            raise ExcelWriteError("書き込む部屋名がありません。")
        assignments = assign_cells(names, start_cell=start_cell, max_rows=max_rows)
    
        workbook = _load_template(template)
        try:
            worksheet = _get_sheet(workbook, sheet_name)
            overwritten = _check_targets(worksheet, assignments, overwrite=overwrite)
            for assignment in assignments:
                cell = worksheet[assignment.cell]
                cell.value = assignment.name
                if cell.data_type != "s":
                    raise ExcelWriteError(f"{assignment.cell} が文字列として書き込まれませんでした。")
            _save_atomically(workbook, output)   # 一時ファイルに保存してから名前を変える
        finally:
            workbook.close()
        return ExcelWriteResult(template_path=template, output_path=output, sheet_name=sheet_name,
                                assignments=assignments, overwritten=overwritten)

    テンプレートの開き方には、openpyxlの既定のままだと様式を壊す落とし穴が3つあります。

    # app/core/excel_writer.py(抜粋)
    def _load_template(template: Path) -> Workbook:
        # - data_only=False(既定)で開く。Trueにすると数式の代わりに前回の計算結果が読み込まれ、
        #   そのまま保存すると数式が消える(計算結果の値、または結果が無ければ空欄になる)。
        # - rich_text=True で開く。既定(False)では、セル内の一部だけ太字・色付きにした文字が
        #   保存時にただの文字列になってしまう(openpyxl 3.1.5で確認)。
        # - .xlsm は keep_vba=True で開く。既定ではマクロ(vbaProject.bin)が保存時に消える。
        keep_vba = template.suffix.lower() == MACRO_ENABLED_SUFFIX
        try:
            return load_workbook(template, keep_vba=keep_vba, rich_text=True)
        except (InvalidFileException, zipfile.BadZipFile, KeyError, OSError) as exc:
            raise ExcelWriteError(f"Excelテンプレートを開けません(壊れているか、Excel形式ではありません): {template.name}") from exc

    3つとも、既定のままだと消えることを実際に保存・読み戻して確かめました(引数の意味は load_workbook() の説明文で確認)。

    出力ファイル名は日時入りの別名で、同名があれば _2、_3 と番号を付けます。拡張子はテンプレートにそろえます(.xlsmの中身を .xlsx の名前で保存すると食い違うため)。

    # app/core/excel_writer.py(抜粋)
    def default_output_path(template_path, *, now: datetime | None = None) -> Path:
        template = Path(template_path)
        stamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S")
        base = f"{template.stem}_{OUTPUT_LABEL}_{stamp}"        # OUTPUT_LABEL = "転記"
        candidate = template.with_name(f"{base}{template.suffix}")
        counter = 2
        while candidate.exists():
            candidate = template.with_name(f"{base}_{counter}{template.suffix}")
            counter += 1
        return candidate

    「6. 実行」ボタンを転記につなぐ(app/ui/main_window.py)

    画面側は、確定済みかを確かめてコア処理を呼び、結果を表示するだけです。記入欄に値が残っていた場合だけ上書きしてよいかを聞きます(既定は「いいえ」)。

    # app/ui/main_window.py(抜粋)
    def _on_run(self) -> None:
        template = self.selected.excel_path
        if template is None:
            return
        if self.reviewed_rooms is None:
            messagebox.showwarning(
                "部屋名の確認が済んでいません",
                "先に「4.」で部屋名候補を絞り込み、「5.」の確認画面で確定してください。",
                detail="AI・OCRの読み取り結果を、人の確認を通さずにExcelへ書き込まないためです。",
            )
            return
        try:
            result = write_rooms_to_excel(template, self.reviewed_rooms)
        except CellNotEmptyError as exc:
            if not messagebox.askyesno("記入欄に値が入っています", str(exc),
                                       detail="上書きして転記しますか?(元のテンプレートは変更しません。数式の欄は上書きしません)",
                                       icon="warning", default="no"):
                self.status_var.set("転記を取りやめました(ファイルは作成していません)。")
                return
            result = self._write_excel(template, overwrite=True)
        except ExcelWriteError as exc:
            messagebox.showerror("Excel転記エラー", str(exc))
            return
        if result is None:
            return
        messagebox.showinfo("Excelへ転記しました",
                            f"{len(result.assignments)}件の部屋名を書き込み、別名で保存しました。",
                            detail=describe_result(result))

    動作確認の方法

    Linux開発環境で確認できたこと

    • pytest:合計195件が成功(うち tests/test_excel_writer.py が新規51件。Tesseract 5.3.4+日本語データの環境)
    • ruff check .:エラーなし

    テストの中心は「書き込んだ後に読み戻して比べる」ことです。書き込んだ4セル以外の値・数式・表示形式・フォント・塗り・罫線・配置を1セルずつ比べ、結合セル・列幅・入力規則・印刷範囲なども比べます。元のテンプレートのファイルが1バイトも変わっていないこと(ハッシュ値)も確かめています。

    # tests/test_excel_writer.py(抜粋)
    def test_write_keeps_all_other_cells_and_sheet_settings(template: Path, tmp_path: Path) -> None:
        original = load_workbook(template)[DEFAULT_SHEET_NAME]
    
        result = write_rooms_to_excel(template, _rooms(NAMES), output_path=tmp_path / "out.xlsx")
    
        written = load_workbook(result.output_path)[DEFAULT_SHEET_NAME]
        assert _cell_snapshot(written, TARGETS) == _cell_snapshot(original, TARGETS)
        assert _sheet_settings(written) == _sheet_settings(original)

    なお cell.font などが返す StyleProxy は、中身が同じでも == で一致しないため、copy() してから比べます(最初はこれで比較が失敗しました)。ほかに、不正な部屋名・結合セルの途中・既存の値(上書きの了承あり/なし)・数式の欄・11部屋以上・壊れたファイル・原本や既存ファイルへの保存を、ファイルを作らずに止められることも検証しています。

    次は、架空のサンプル図面で「4.」「5.」を実行し(「洗面所」を「洗面脱衣室」に修正)、「6. 実行」を押したときの完了メッセージです。開発環境(Linux/Xvfb上・Ubuntu標準のTkテーマ)での画面で、Windows実機では見た目が異なります。ファイル名の日時は撮影スクリプトで固定しています。

    Excelへ転記したことを知らせる完了メッセージ。保存先のファイル名と、C5からC8に書き込んだ4件の部屋名が表示されている

    出力ファイルをLibreOffice Calc 24.2(Linux)でPDFに変換して切り出したものです(Excel実機の表示ではありません)。罫線・見出しの色・結合セルが残り、部屋数の数式が4を返しています。

    転記後の部屋一覧表。部屋名の列に居間・寝室1・キッチン・洗面脱衣室が入り、部屋数が4になっている

    openpyxlで読み戻した内容は次のとおりです(5〜14行目のうち5〜9行目と、合計の行)。

    セル読み戻した値種類
    C5〜C8居間/寝室1/キッチン/洗面脱衣室文字列
    C9(空)―
    D15=SUM(D5:D14)数式
    D16=COUNTA(C5:C14)数式

    画面からも、未確定なら警告だけで書かないこと、値のある欄で「いいえ」ならファイルを作らないことを確認しました。

    確認できていないこと

    • Windows実機のExcelで出力ファイルを開いたときの表示・再計算(今回はLibreOfficeとopenpyxlの読み戻しで確認)
    • 実在の取引先の様式(複雑な結合・保護されたシート・外部参照を含むもの)での動作
    • 本物のマクロ入り .xlsm での動作(ダミーのマクロ部品が保存後も同じ中身で残ることまでは確認)

    つまずきやすい点・セキュリティ上の注意

    openpyxl 3.1.5で、実際に保存して読み戻して確かめた範囲をまとめます。

    テンプレートに含まれるもの結果対応
    罫線・フォント・塗り・表示形式・結合セル・列幅・入力規則残ったそのまま
    数式残った(data_only=True で開くと消えた)既定の data_only=False で開く
    セル内の一部だけ太字・色付きの文字既定ではただの文字列になったrich_text=True で開く
    マクロ(.xlsm)既定では消えたkeep_vba=True で開き、拡張子も .xlsm のまま保存
    貼り付けた画像(ロゴ等)残ったそのまま
    図形・テキストボックス消えた様式から外すか、別の方法を検討(下記)
    • 図形・テキストボックスは消える:吹き出しや押印欄の枠を図形で描いた様式は要注意です。罫線で描き直すなどを様式の持ち主と相談します
    • マクロは「残す」だけ:keep_vba=True は部品を持ち運ぶだけで、docstringにも「使えるという意味ではない」とあります。本物の .xlsm はExcel実機で確認が必要です
    • グラフは確認しきれていない:openpyxlで作った棒グラフは保存後も残りましたが、Excelで作り込んだグラフの見た目が保たれるかは未確認です
    • 数式の計算結果は保存されない:openpyxlは数式を計算しないため、別のプログラムで出力の計算結果を読むと空になります(data_only=True で読み戻すと None)。Excelで開けば再計算されます
    • 出力ファイルにも顧客情報が入る:サンプルでは *_転記_*.xlsx をgitの追跡対象外にしています。保存場所のアクセス権は原本と同じ範囲に絞ります

    発注者向けメモ

    この回で確認しておきたいこと・工数の勘所

    「テンプレートの書式・数式を壊さない」ことは、業務で使うための前提条件です。崩れたファイルに気付かず取引先へ出すと、信頼を損ねます。開発会社には、値だけを書き込む実装か、書き込み後に「他のセルが変わっていないこと」をどう確かめているかを聞いてみてください。読み戻して比べるテストがあれば、様式を変えたときも同じ確認を繰り返せます。

    工数とリスクを左右するのは様式の作りです。次の点を棚卸しし、様式の実物(顧客情報を消したもの)を渡すと見積もりの精度が上がります。

    様式の特徴影響
    図形・テキストボックス・グラフがある消える・変わるおそれ。様式の見直しか別の方式の検討が必要になり、費用や動作条件が変わりうる
    マクロ付き(.xlsm)マクロを残す設定と、実機での動作確認が必要
    記入欄に結合セルが多いどのセルに書くかの指定が複雑になる
    記入欄の数が決まっている欄が足りない物件の扱い(エラー/2枚目/行の追加)を決める必要がある
    シートの保護・パスワード今回は未対応。対応の要否を確認する

    「原本や前回の結果を上書きしない」は小さな配慮ですが、事故防止の効果は大きい部分です。出力ファイルの名前の付け方や置き場所は、社内のファイル管理ルールに合わせて早めに決めておきます。

    • ☐ 転記先の様式の実物(顧客情報を消したもの)を用意したか
    • ☐ 様式に図形・グラフ・マクロ・シート保護があるかを確認したか
    • ☐ 記入欄が足りない物件(部屋数が多い場合)の扱いを決めたか
    • ☐ 出力ファイルの名前の付け方と保存場所のルールを決めたか
    • ☐ 記入欄に前回の値が残っていた場合に、上書きしてよいかを誰が判断するか決めたか

    開発会社への質問例

    • 「値だけを書き込む実装ですか?書式・数式・結合セルが変わっていないことを、どのように確認していますか?」
    • 「元のテンプレートを上書きしてしまうことはありますか?出力ファイルの名前はどう決まりますか?」
    • 「うちの様式に含まれる図形・グラフ・マクロは、転記後も残りますか?実物で試してもらえますか?」
    • 「記入欄に既に値が入っていた場合や、欄が足りない場合はどうなりますか?」

    まとめと次回予告

    第7回では、確認済みの部屋名をExcelテンプレートの決まった欄へ転記する app/core/excel_writer.py を実装し、「6. 実行」ボタンにつなぎました。値だけを書く、原本は別名で残す、問題があれば書く前に止める、の3点がポイントです。openpyxlの既定のままだと数式・セル内の書式・マクロが消えうるため、開き方にも注意が必要でした。

    次回(第8回)は「部屋名とExcelの欄の対応付けを設定ファイルで変えられるようにする」です。今回コード内に書いた「C5から下へ順に」という規則をYAMLの設定ファイルに移し、様式が違う取引先にもコードを変えずに対応できるようにします。

    この連載の記事一覧

    この記事は連載「CAD図面PDFをExcelへ自動転記するツール開発」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次