MENU

問い合わせ


    【CAD図面PDFをExcelへ自動転記するツール開発 第8回】部屋名とExcelの欄の対応付けを設定ファイル(YAML)で変えられるようにする

    前回(第7回)では、確認済みの部屋名をExcelテンプレートの決まった欄へ、書式や数式を壊さずに書き込めるようにしました。ただし書き込み先は「C5から下へ順に」というコード内の決め打ちでした。

    第8回は、部屋名とExcelの欄(セル位置)の対応付けを設定ファイル(YAML)に移し、様式が違うテンプレートにもコードを変えずに転記できるようにします。取引先ごとにExcelのフォーマットが違う、という転記ツールでよくある悩みへの対応です。結論から言うと、ポイントは次の3つです。

    • 欄の決め方は「順番に並べる型」と「部屋ごとに固定の欄がある型」の2通りを用意すれば、多くの様式を表せる
    • テンプレートが複数あっても、ファイル名・シート名・目印のセルで使う設定を自動で選ぶ
    • 設定ファイルは人が手で書くので、書き間違いを読み込んだ時点で分かりやすく止める。YAMLは必ず yaml.safe_load() で読む
    目次

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

    メイン画面に「2-2. 転記の設定ファイル(任意)」の欄を追加します。

    • 「設定を選ぶ…」でYAMLファイルを選ぶと、その場で内容を検証する
    • Excelテンプレートに合う設定を探し、「使う設定:…」と表示する。「6. 実行」ではその設定の欄へ書き込む
    • 設定ファイルを選ばなければ、第7回と同じ動作になる

    動作確認用に、レイアウトの違う架空のテンプレート「間取り確認表」(B列に部屋種別が印字され、C列の決まった行に書き込む様式)を追加し、1つの設定ファイルで第7回の「部屋一覧」と両方に書けることを確かめます。

    仕組みの説明:様式の違いを「欄の決め方」の2パターンで表す

    「部屋名をどの欄に書くか」の決め方は、おおむね次の2パターンに分けられます。

    型(layout)様式の例設定に書くこと
    sequential(順番に並べる)部屋名を上から順に書いていく一覧表開始セル・方向(下へ/右へ)・欄の数
    fixed(部屋ごとに固定の欄)「居間」「寝室1」などの行が印字済みのチェック表「部屋名: セル番地」の一覧

    固定の欄の型では、図面の表記が「リビング」でも「LDK」でも、居間の欄に入ってほしいはずです。そこで、第3回で作った部屋名辞書(rooms.yaml)を再利用し、代表表記+末尾の番号にそろえてから突き合わせます。

    たとえば「ベッドルーム2」は「寝室2」の欄に入ります。セルに書き込むのは確認画面で確定した名前そのもので、キーは欄を探すためだけに使います。

    テンプレートが複数ある場合は、設定ファイルに様式ごとの設定を並べ、上から順に「ファイル名」「対象シートの有無」「目印のセルの文字」を調べて、最初に当てはまったものを使います。

    実装

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

    code/
    ├ app/
    │  ├ core/
    │  │  ├ mapping.py               …【今回追加】設定ファイルの読み込み・検証・テンプレートの識別・欄の決定
    │  │  └ excel_writer.py          …【今回変更】書き込み先を mapping.py の設定で決めるようにした
    │  ├ config/
    │  │  └ mapping.example.yaml     …【今回追加】設定ファイルのサンプル(2つの架空テンプレート分)
    │  └ ui/
    │     └ main_window.py           …【今回変更】設定ファイルの選択欄を追加し、転記に配線
    ├ samples/
    │  └ floor_plan_sheet.xlsx       …【今回追加】レイアウトの違う架空テンプレート「間取り確認表」
    ├ tests/
    │  └ test_mapping.py             …【今回追加】pytest 73件
    ├ tools/
    │  ├ make_sample_template.py     …【今回変更】--floor-plan で2つ目のテンプレートを生成
    │  └ capture_screenshots.py      …【今回変更】第8回用の画面を撮影
    └ README.md                      …【今回変更】第8回時点の構成

    新しいパッケージは追加していません。YAMLの読み込みには、第3回で導入した PyYAML 6.0.3(2025年9月公開・MIT License)を使います。

    📰 出典:PyYAML 6.0.3(PyPI)

    設定ファイルの書き方(app/config/mapping.example.yaml)

    2つの架空テンプレート分の設定です。1つ目は第7回の動作をそのまま設定で書いたもの、2つ目が部屋ごとに固定の欄がある様式です。

    # app/config/mapping.example.yaml(抜粋。ファイル先頭に各キーの説明コメントあり)
    version: 1
    templates:
      - id: room_list
        name: 部屋一覧(上から順に並べる)
        match:
          file_name: ["room_list_template*.xlsx", "部屋一覧*.xlsx"]
        sheet: 部屋一覧
        layout: sequential
        start_cell: C5
        direction: down
        max_count: 10
    
      - id: floor_plan_sheet
        name: 間取り確認表(部屋種別ごとの欄)
        match:
          file_name: "floor_plan_sheet*.xlsx"
          marker:
            cell: A1
            contains: 間取り確認表
        sheet: 間取り表
        layout: fixed
        cells:
          居間: C4
          キッチン: C5
          寝室1: C6
          寝室2: C7
          寝室3: C8
          洗面所: C9
          浴室: C10
          トイレ: C11
          玄関: C12
          バルコニー: C13
        on_unmatched: error

    on_unmatched は、設定に欄が無い部屋名があったときの扱いです。既定の error は転記を止め、skip はその部屋名だけ書かずに進めて完了メッセージに表示します。黙って書き漏らすことがないよう、既定は止める方にしました。

    YAMLは yaml.safe_load() で読む(app/core/mapping.py)

    設定ファイルはメールやチャットで受け取ることもあります。yaml.load() は指定するLoaderによっては任意のPythonオブジェクトを作れてしまうため、基本的な型だけを扱う yaml.safe_load() を使います。PyYAML 6.0.3の説明文にも「信頼できない入力に対しても安全」とあります。

    # app/core/mapping.py(抜粋)
    def parse_mapping_config(text, *, source=None, dictionary=None) -> MappingConfig:
        where = source.name if source is not None else "設定"
        try:
            _check_duplicate_keys(text, where)
            data = yaml.safe_load(text)  # 任意のPythonオブジェクトを作らない読み込み方(yaml.loadは使わない)
        except yaml.YAMLError as exc:
            mark = getattr(exc, "problem_mark", None)
            line = f"({mark.line + 1}行目付近)" if mark is not None else ""
            raise MappingConfigError(f"{where}: YAMLとして読み込めません{line}。インデントや「:」の後の空白を確認してください。") from exc
        ...

    ただし safe_load() には落とし穴が1つあります。同じキーを2回書いても、黙って後の値で上書きするのです。実際に「寝室1: C6」「寝室1: C7」と書くと、エラーにならず {'寝室1': 'C7'} になりました。書き写しミスに気付けるよう、値を作る前の構文木(yaml.compose()。Pythonのオブジェクトは作らない)で重複を調べています。

    # app/core/mapping.py(抜粋)
    def _check_duplicate_keys(text: str, where: str) -> None:
        root = yaml.compose(text, Loader=yaml.SafeLoader)
        stack = [root] if root is not None else []
        visited: set[int] = set()
        while stack:
            node = stack.pop()
            if id(node) in visited:
                continue
            visited.add(id(node))
            if isinstance(node, yaml.MappingNode):
                seen: dict[str, int] = {}
                for key_node, value_node in node.value:
                    if isinstance(key_node, yaml.ScalarNode):
                        line = key_node.start_mark.line + 1
                        if key_node.value in seen:
                            raise MappingConfigError(
                                f"{where}: キー「{key_node.value}」が同じ場所に2回書かれています({seen[key_node.value]}行目と{line}行目)。"
                            )
                        seen[key_node.value] = line
                    stack.append(value_node)
            elif isinstance(node, yaml.SequenceNode):
                stack.extend(node.value)

    書き間違いを読み込み時に止める

    読み込んだ後は、テンプレートごとに次の点を確認します。メッセージには「どのファイルの、何番目の設定の、どの項目か」を入れます。

    確認することエラーの例
    必須キー・知らないキー(書き間違い)必須のキーがありません: sheet/知らないキーがあります: start_cel
    セル番地セル番地が不正です: ‘5C’
    同じセルの重複セル C4 が「居間」と「寝室1」の両方に指定されています
    辞書で同じ部屋になる名前「居間」と「リビング」は部屋名辞書で同じ部屋(居間)になるため…

    固定の欄の検証部分です。セル番地は全角・小文字を半角・大文字にそろえ、$C$5 や C5:C14 のような書き方は受け付けません。

    # app/core/mapping.py(抜粋)
    for label, raw_cell in cells.items():
        if not isinstance(label, str) or not label.strip():
            # 「1:」「yes:」のようなキーはYAMLで数値・真偽値として読まれてしまう
            raise MappingConfigError(f"{where}: cells の部屋名 {label!r} は文字列にしてください(必要なら「\"1\":」のように引用符で囲みます)。")
        try:
            cell = normalize_cell(raw_cell)
        except MappingError as exc:
            raise MappingConfigError(f"{where}: cells の「{label}」: {exc}") from None
        if cell in cell_owner:
            raise MappingConfigError(f"{where}: セル {cell} が「{cell_owner[cell]}」と「{label}」の両方に指定されています。")
        cell_owner[cell] = label
        key = room_key(label, dictionary)
        if key in key_owner:
            raise MappingConfigError(
                f"{where}: 「{key_owner[key]}」と「{label}」は部屋名辞書で同じ部屋({key})になるため、どちらの欄に書くか決められません。"
            )
        key_owner[key] = label
        slots.append(FixedSlot(label=label, cell=cell))

    表記ゆれは第3回の部屋名辞書でそろえる

    部屋名を欄のキーにそろえる関数です。辞書の照合(RoomDictionary.match())は第3回のものをそのまま使い、一致した表記の後ろに残った番号を代表表記に付け直します。

    # app/core/mapping.py(抜粋)
    def room_key(name: str, dictionary: RoomDictionary | None = None) -> str:
        normalized = unicodedata.normalize("NFKC", name).strip()
        if dictionary is None:
            return normalized
        matched = dictionary.match(normalized)
        if matched is None:
            return normalized
        canonical, alias = matched
        alias_normalized = unicodedata.normalize("NFKC", alias).strip()
        suffix = normalized[len(alias_normalized) :] if normalized != alias_normalized else ""
        return f"{canonical}{suffix}"

    書き込み先を決める処理では、欄が無い部屋名をまとめて報告し、2つの部屋名が同じ欄になる場合(「居間」と「リビング」が両方ある等)も止めます。

    # app/core/mapping.py(抜粋)
    for name in names:
        key = room_key(name, dictionary)
        slot = slots.get(key)
        if slot is None:
            unmatched.append(name if key == unicodedata.normalize("NFKC", name).strip() else f"{name}({key})")
            continue
        if slot.cell in used:
            raise MappingError(f"「{used[slot.cell]}」と「{name}」が同じ欄({slot.label}:{slot.cell})になります。どちらかの名前を確認画面で直してください。")
        used[slot.cell] = name
        assignments.append(CellAssignment(cell=slot.cell, name=name))

    テンプレートに合う設定を選ぶ

    テンプレートは読み取り専用で開き、シート名と目印のセルだけを読んですぐ閉じます。ファイル名は合ったのにシートが無いときは設定の書き間違いの可能性が高いため、次の設定を探さずにその場でエラーにします。

    # app/core/mapping.py(抜粋)
    for mapping in config.templates:
        name_given = bool(mapping.match.file_names)
        if name_given and not _file_name_matches(template.name, mapping.match.file_names):
            reasons.append(f"・{mapping.id}:ファイル名が {'/'.join(mapping.match.file_names)} に合いません")
            continue
        if mapping.sheet not in sheets:
            message = f"シート「{mapping.sheet}」がテンプレートにありません(あるシート:{'、'.join(sheets)})"
            if name_given:
                raise MappingError(f"設定「{mapping.id}」はファイル名が合いましたが、{message}。設定の sheet を確認してください。")
            reasons.append(f"・{mapping.id}:{message}")
            continue
        if mapping.match.marker_cell is not None and mapping.match.marker_text is not None:
            found = markers.get((mapping.sheet, mapping.match.marker_cell), "")
            if mapping.match.marker_text not in found:
                reasons.append(f"・{mapping.id}:{mapping.match.marker_cell} に「{mapping.match.marker_text}」がありません")
                continue
        return mapping

    どれにも当てはまらなければ、設定ごとに合わなかった理由を並べたエラーにします。

    第7回の書き込み処理とのつなぎ込み(app/core/excel_writer.py)

    第7回で書き込み本体を「どのセルに何を書くか」のリストを受け取るだけにしておいたため、変更は割り当ての部分だけで済みました。mapping を渡さなければ第7回と同じ既定で動きます。

    # app/core/excel_writer.py(抜粋)
    if mapping is None:
        layout = _sequential_layout(start_cell, max_rows)
        mapping = TemplateMapping(id=DEFAULT_MAPPING.id, name=DEFAULT_MAPPING.name, sheet=sheet_name, layout=layout)
    try:
        plan = plan_cells(mapping, names, dictionary)
    except MappingError as exc:
        raise ExcelWriteError(str(exc)) from None
    assignments = plan.assignments
    sheet_name = mapping.sheet

    画面から設定ファイルを選ぶ(app/ui/main_window.py)

    設定ファイルは選んだ時点で検証し、「6. 実行」のときにテンプレートに合う設定を選び直します。

    # app/ui/main_window.py(抜粋)
    def _on_choose_mapping(self) -> None:
        path = filedialog.askopenfilename(title="転記の設定ファイルを選択", filetypes=MAPPING_FILETYPES)
        if not path:
            return
        try:
            config = load_mapping_config(path, self._room_dictionary)
        except MappingError as exc:
            messagebox.showerror("転記の設定ファイルのエラー", str(exc))
            return
        self.mapping_config = config
        self.mapping_path_var.set(f"{path}({len(config.templates)}種類のテンプレートの設定)")
        self._refresh_mapping_status()
    
    def _resolve_mapping(self, template: Path) -> TemplateMapping | None:
        if self.mapping_config is None:
            return DEFAULT_MAPPING
        try:
            return identify_template(self.mapping_config, template)
        except MappingError as exc:
            messagebox.showerror("転記の設定エラー", str(exc))
            return None

    動作確認の方法

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

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

    中心となるテストは、同じ設定ファイルだけで、レイアウトの違う2つのテンプレートの両方に書けることです。

    # tests/test_mapping.py(抜粋)
    def test_same_config_writes_to_both_templates(config, dictionary, tmp_path: Path) -> None:
        names = ["居間", "寝室1", "キッチン", "洗面所"]
        results = {}
        for file_name in ("room_list_template.xlsx", "floor_plan_sheet.xlsx"):
            template = tmp_path / file_name
            shutil.copy(SAMPLES / file_name, template)
            mapping = identify_template(config, template)
            results[file_name] = write_rooms_to_excel(template, _rooms(names), mapping=mapping, dictionary=dictionary)
    
        room_list = load_workbook(results["room_list_template.xlsx"].output_path)["部屋一覧"]
        assert [room_list[f"C{r}"].value for r in range(5, 10)] == [*names, None]
        ...

    ほかに、表記ゆれ(リビング・ベッドルーム2・寝室1・DK・洗面脱衣所・WC)が正しい欄に入ること、設定を渡さない場合に第7回と同じセルになること、設定の書き間違い23パターンがそれぞれエラーになること、!!python/... のタグを含むYAMLでもコマンドが実行されないこと、yaml.load() の呼び出しが無いことを検証しています。

    次は、設定ファイルを選び、2つ目の「間取り確認表」へ「6. 実行」で転記したときの画面です。開発環境(Linux/Xvfb上・Ubuntu標準のTkテーマ)での確認画面で、Windows実機では見た目が異なります。ファイル名の日時は撮影スクリプトで固定しています。

    転記の設定ファイルを選び、間取り確認表へ転記した完了メッセージ。使う設定として「間取り確認表(部屋種別ごとの欄)」が表示され、C4に居間、C5にキッチン、C6に寝室1、C9に洗面所を書き込んでいる

    出力ファイルをopenpyxlで読み戻した内容です(一部)。図面の検出順(居間→寝室1→キッチン→洗面所)ではなく、様式の行に合わせて入っています。

    行B列(印字済み)C列(書き込み)D列
    4居間居間=IF(C4="","","有")(数式のまま)
    5キッチンキッチン同上
    6寝室1寝室1同上
    7寝室2(空)同上
    9洗面所洗面所同上

    画面からは、誤りのある設定ファイルを選ぶとエラーが出て選択されないこと、どの設定にも合わないテンプレートでは「使える設定がありません」と赤字で表示され、「6. 実行」でも理由付きのエラーで止まることを確認しました。

    確認できていないこと

    • Windows実機での画面表示と、Windowsのメモ帳などで保存した設定ファイル(文字コード・改行)の読み込み。UTF-8以外(Shift_JIS)で保存した場合はエラーになることまではテストで確認しています
    • 実在の取引先の様式(1部屋に複数の欄がある、階ごとに表が分かれている等)を今回の2つの型で表せるか

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

    • 人が修正した名前は辞書に無いことがある:第6回・第7回で「洗面所」を「洗面脱衣室」に修正した例は、固定の欄の型では欄が見つからずエラーになります。辞書(rooms.yaml)に表記を追加するか、設定の cells に欄を足します。順番に並べる型では問題になりません
    • YAMLの型の自動判定:1: は数値、yes: や on: は真偽値として読まれます。PyYAML 6.0.3で試すと、1: と yes: と on: を並べた設定は同じキー扱いになり1つに潰れました。部屋名は文字列でないとエラーにし、必要なら引用符で囲むよう案内しています
    • 設定ファイルも管理対象:取引先名をファイル名やidに入れると、それ自体が取引関係の情報になります。置き場所のアクセス権はテンプレートと同じ範囲に絞ります
    • yaml.load() を使わない:PyYAML 6.0.3の yaml.load() はLoaderの指定が必須ですが、指定次第で安全でない読み込みになりえます。読み込みは safe_load() に統一します

    発注者向けメモ

    フォーマットの数だけ設定ファイル(設定)を用意すれば対応できる

    取引先ごとにExcelのフォーマットが違っても、この回の仕組みがあれば、様式を1つ増やすたびにプログラムを直す必要はありません。様式ごとに設定を1つ書き足すだけで、社内の担当者が追加・修正できる可能性もあります。

    ただし、設定だけで対応できるのは「今回の2つの型で表せる様式」に限られます。たとえば次のような様式は、プログラムの改修が必要になりえます。

    様式の特徴設定だけで対応できるか
    部屋名を上から(または左から)順に並べるできる(sequential)
    部屋種別ごとに欄が決まっているできる(fixed)。辞書に無い表記は辞書の追加が必要
    シート名・欄の位置・欄の数が違うできる
    欄が足りないときに2枚目のシートや行を追加するできない(改修が必要)
    部屋名以外(面積・階)も書く、1部屋に複数の欄があるできない(改修が必要)
    様式に図形・マクロ・シート保護がある第7回の注意点のとおり、別途確認が必要

    コード改修が要らない範囲を最初に決めると、追加費用の交渉がしやすい

    「設定で吸収できる範囲」と「改修が必要な範囲」の線引きを契約前にすり合わせておくと、様式が増えたときに「設定の追加」か「機能追加(見積もり)」かを判断しやすくなります。線引きが曖昧なままだと、様式が増えるたびに費用の話し合いが必要になり、双方の負担になります。見積もりの前に、今ある様式と今後増えそうな様式の実物(顧客情報を消したもの)を集め、上の表のどれに当たるかを開発会社と一緒に確認するのがおすすめです。

    • ☐ 転記先の様式を、今あるもの・今後増えそうなものまで棚卸ししたか
    • ☐ 各様式が「順番に並べる」「部屋ごとに固定の欄」のどちらに当たるか確認したか
    • ☐ 設定の追加・修正を社内で行うか、開発会社に依頼するかを決めたか
    • ☐ 設定で対応できない様式が出てきたときの扱い(見積もりの出し方)を決めたか
    • ☐ 設定ファイルの保管場所と、変更したときの記録の残し方を決めたか

    開発会社への質問例

    • 「新しい取引先の様式が増えたとき、プログラムの改修なしで対応できるのはどこまでですか?」
    • 「設定ファイルは社内の担当者でも書けますか?書き間違えたときはどう表示されますか?」
    • 「設定ファイルの書き方の説明書と、サンプルの設定を納品物に含めてもらえますか?」
    • 「部屋名の表記ゆれ(リビング/LDKなど)は、どこで吸収していますか?新しい表記はどう追加しますか?」

    まとめと次回予告

    第8回では、部屋名とExcelの欄の対応付けを設定ファイル(YAML)に移す app/core/mapping.py を実装し、画面から設定ファイルを選べるようにしました。2通りの型で、レイアウトの違う2つの架空テンプレートにコードを変えずに書けることを確かめています。YAMLは safe_load() で読み、重複キーのような書き間違いも読み込み時に止めました。

    次回(第9回)は「複数のPDF・複数物件をまとめて処理する」です。フォルダを指定して複数の図面PDFを一括で処理し、1件が失敗しても残りを止めない仕組みとログ出力を作ります。

    この連載の記事一覧

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

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (2件)

      目次