岐阜県の区域調書表紙をQGISとPythonで自動作成する

岐阜県の区域調書表紙をQGISとPythonで自動作成する
― 土石流・急傾斜に対応 ―

岐阜県の土砂災害防止法に関する区域調書では、表紙に縮尺1:200,000の位置図と、 縮尺1:25,000の概況図を掲載します。

地点数が多い場合、地図の切り出し、Excelへの貼り付け、赤円、引き出し線、 箇所名、方位記号の配置を1件ずつ行うと、多くの時間がかかります。

そこで、QGISで地点ごとの地図画像を一括出力し、PythonからExcelを操作して、 表紙をまとめて仕上げるツールを作成しました。

今回のツールでは、次の処理を自動化しています。

QGIS画像切り出し 土石流・急傾斜を分離 Excel画像貼付 赤円 引き出し線 箇所名 方位記号 旧番号対応 バックアップ

1.土石流と急傾斜を分ける理由

区域調書の表紙は、土石流と急傾斜で地図枠の大きさと縦横比が異なります。 公示図書用の画像や、別形式用に切り出した画像をそのまま流用すると、 上下または左右が不足したり、地図が引き伸ばされたりします。

種類 20万図のExcel範囲 2.5万図のExcel範囲
土石流 B13:D28 F5:M28
急傾斜 B13:O22 Q7:AB22

そのため、QGISの切り出しとExcelへの貼り付けを、土石流用と急傾斜用に分けました。 ファイル名にも dosekikyukei を付け、 異なる画像を誤って貼り付けないようにしています。

2.完成までの全体手順

  1. 区域調書Excelを土石流用と急傾斜用のフォルダに分ける
  2. QGISへ国土地理院ベクトルタイルを登録する
  3. 対象箇所のポイントデータを準備する
  4. ポイントレイヤの箇所番フィールドへ箇所番号を入力する
  5. 土石流用または急傾斜用のQGISコードで画像を切り出す
  6. 切り出したPNGを対応するExcelフォルダへ保存する
  7. Excelを閉じた状態で、最終仕上げ用BATを実行する
  8. 画像、赤円、引き出し線、名称、方位記号を確認する

3.作業フォルダを分ける

土石流と急傾斜の区域調書Excelを別フォルダへ分けます。 QGISの画像出力先も、それぞれ対応するフォルダを指定します。

区域調書表紙 ├─ 表紙_土石流調書 │ ├─ 調書_G54031K.xlsx │ └─ 調書_GEN0259001.xlsx │ └─ 表紙_急傾斜調書 ├─ 調書_KEN02590030101.xlsx ├─ 調書_KEN02590040101.xlsx └─ 2135720101加須里4.xlsx

4.QGISで背景地図を準備する

背景には国土地理院のベクトルタイルを使用しました。 QGISのブラウザパネルで「ベクタタイル」を右クリックし、 新しい一般接続を追加します。

項目 設定内容
名称 国土地理院ベクトル
URL https://cyberjapandata.gsi.go.jp/xyz/experimental_bvmap/{z}/{x}/{y}.pbf
画像切り出し時には、QGISの地図画面で表示中の背景レイヤがそのまま出力されます。 不要なレイヤは非表示にします。ポイントレイヤ自体は画像へ出力しません。

プロジェクト座標系

縮尺と実寸から表示範囲を計算するため、プロジェクト座標系はメートル単位にします。 恵那市・中津川市周辺では、JGD2011の平面直角座標系第VII系を使用しました。

EPSG:6675
JGD2011 / Japan Plane Rectangular CS VII

5.ポイントデータを準備する

地図の中心となるポイントレイヤを用意します。 今回のコードでは次の名称を使用しています。

項目 設定
ポイントレイヤ名 表紙作成
箇所番号フィールド 箇所番

各ポイントの箇所番へ、画像ファイル名に使う番号を入力します。 現在の番号だけでなく、数字から始まる旧番号にも対応しています。

G54031K
GEN0259001
KEN02590030101
2135720101

出力対象の選び方

  • ポイントが選択されている場合:選択ポイントだけをすべて出力
  • 選択されていない場合:レイヤ内の全ポイントをすべて出力

番号の先頭がKENGGEN、数字のいずれであっても、 接頭辞では除外しません。

6.QGISから区域調書用画像を出力する

土石流用の出力

画像 縮尺 出力実寸 ファイル名
位置図 1:200,000 148.43125 × 101.6mm 箇所番_doseki_200k.png
概況図 1:25,000 135.46667 × 152.4mm 箇所番_doseki_25k.png

急傾斜用の出力

画像 縮尺 出力実寸 ファイル名
位置図 1:200,000 148.16667 × 89.95833mm 箇所番_kyukei_200k.png
概況図 1:25,000 127.0 × 134.67292mm 箇所番_kyukei_25k.png

QGISコードの実行方法

QGISのPythonコンソールから「エディタを表示」を選択し、 対応するコードを開いて実行します。 外部ファイルとして保存したコードを実行する場合は、次のようにします。

from pathlib import Path

path = Path(
    r"H:\任意のフォルダ\区域調書_急傾斜用_QGIS画像出力_全ポイント強制版_BOMなし.py"
)

exec(
    compile(
        path.read_text(encoding="utf-8"),
        str(path),
        "exec"
    )
)
PythonファイルはBOMなしUTF-8で保存しています。 ファイル先頭にBOMが付いた場合は、 read_text(encoding="utf-8-sig")とすると U+FEFFエラーを回避できます。
QGISコード:土石流用
# -*- coding: utf-8 -*-
"""
区域調書・土石流用 QGIS地図画像一括出力

・ポイントレイヤ名: 表紙作成
・箇所番号フィールド: 箇所番
・国土地理院ベクトルタイルなど、表示中の背景を出力
・ポイントレイヤ自体は画像へ出さない
・選択中のポイントがあれば、選択地物をすべて出力
・選択がなければ、レイヤ内の全ポイントをすべて出力
・接頭辞判定は一切しない
・箇所番が空欄の場合はFeatureIDを仮ファイル名として使用
・画像は区域調書の貼付枠と同じ縦横比で出力
"""

import re
from pathlib import Path

from qgis.core import (
    QgsApplication,
    QgsCoordinateTransform,
    QgsLayout,
    QgsLayoutExporter,
    QgsLayoutItemMap,
    QgsLayoutPoint,
    QgsLayoutSize,
    QgsProject,
    QgsRectangle,
    QgsUnitTypes,
    QgsWkbTypes,
)
from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox
from qgis.utils import iface


# ============================================================
# 設定
# ============================================================

POINT_LAYER_NAME = "表紙作成"
NAME_FIELD = "箇所番"
DPI = 300

TYPE_LABEL = "土石流"
KEY_PREFIXES = ('G',)

OUTPUT_SETTINGS = [
    {
        "suffix": "doseki_200k",
        "scale": 200000,
        "width_mm": 148.43125,
        "height_mm": 101.6,
    },
    {
        "suffix": "doseki_25k",
        "scale": 25000,
        "width_mm": 135.4666666667,
        "height_mm": 152.4,
    }
]


# ============================================================
# 補助関数
# ============================================================

def safe_filename(value):
    text = str(value).strip()
    text = re.sub(r'[\\/:*?"<>|]+', "_", text)
    return text if text else "no_name"


def key_is_target(value):
    key = str(value).strip().upper()
    return any(key.startswith(prefix.upper()) for prefix in KEY_PREFIXES)


def geometry_center(feature):
    geometry = feature.geometry()

    if geometry is None or geometry.isEmpty():
        return None

    if geometry.type() == QgsWkbTypes.PointGeometry:
        if geometry.isMultipart():
            points = geometry.asMultiPoint()
            return points[0] if points else None

        return geometry.asPoint()

    centroid = geometry.centroid()

    if centroid.isEmpty():
        return None

    return centroid.asPoint()


def extent_from_center(
    center_x,
    center_y,
    scale,
    width_mm,
    height_mm,
):
    ground_width = width_mm / 1000.0 * scale
    ground_height = height_mm / 1000.0 * scale

    return QgsRectangle(
        center_x - ground_width / 2.0,
        center_y - ground_height / 2.0,
        center_x + ground_width / 2.0,
        center_y + ground_height / 2.0,
    )


def create_layout(
    project,
    visible_layers,
    width_mm,
    height_mm,
):
    layout = QgsLayout(project)
    layout.initializeDefaults()

    page = layout.pageCollection().page(0)

    page.setPageSize(
        QgsLayoutSize(
            width_mm,
            height_mm,
            QgsUnitTypes.LayoutMillimeters,
        )
    )

    map_item = QgsLayoutItemMap(layout)
    layout.addLayoutItem(map_item)

    map_item.attemptMove(
        QgsLayoutPoint(
            0,
            0,
            QgsUnitTypes.LayoutMillimeters,
        )
    )

    map_item.attemptResize(
        QgsLayoutSize(
            width_mm,
            height_mm,
            QgsUnitTypes.LayoutMillimeters,
        )
    )

    map_item.setLayers(visible_layers)
    map_item.setFrameEnabled(False)

    return layout, map_item


# ============================================================
# メイン
# ============================================================

def main():
    folder_text = QFileDialog.getExistingDirectory(
        None,
        f"{TYPE_LABEL}用画像の出力先フォルダを選択",
    )

    if not folder_text:
        return

    output_folder = Path(folder_text)

    project = QgsProject.instance()

    point_layers = project.mapLayersByName(
        POINT_LAYER_NAME
    )

    if not point_layers:
        QMessageBox.critical(
            None,
            "レイヤなし",
            f"ポイントレイヤ「{POINT_LAYER_NAME}」がありません。",
        )
        return

    point_layer = point_layers[0]

    if NAME_FIELD not in point_layer.fields().names():
        QMessageBox.critical(
            None,
            "フィールドなし",
            f"フィールド「{NAME_FIELD}」がありません。",
        )
        return

    project_crs = project.crs()

    if project_crs.isGeographic():
        QMessageBox.critical(
            None,
            "座標系エラー",
            "プロジェクト座標系を、JGD2011平面直角座標系などの"
            "メートル単位の座標系へ変更してください。",
        )
        return

    visible_layers = [
        layer
        for layer in iface.mapCanvas().layers()
        if layer.id() != point_layer.id()
    ]

    if not visible_layers:
        QMessageBox.critical(
            None,
            "背景なし",
            "表示中の背景レイヤがありません。",
        )
        return

    selected = point_layer.selectedFeatures()

    # --------------------------------------------------------
    # 全ポイント強制出力ルール
    #
    # 1. 選択地物がある場合
    #    選択地物をすべて出力
    #
    # 2. 選択地物がない場合
    #    レイヤ内の全ポイントをすべて出力
    #
    # ※ 接頭辞判定は一切しない
    # --------------------------------------------------------

    all_features = list(
        point_layer.getFeatures()
    )

    if selected:
        features = selected
        selection_message = (
            "選択地物を強制出力"
        )

    else:
        features = all_features
        selection_message = (
            "全ポイントを強制出力"
        )

    if not features:
        QMessageBox.warning(
            None,
            "対象なし",
            "ポイントレイヤに地物がありません。",
        )
        return

    coordinate_transform = None

    if point_layer.crs() != project_crs:
        coordinate_transform = QgsCoordinateTransform(
            point_layer.crs(),
            project_crs,
            project,
        )

    success = 0
    failure = 0
    skipped = 0
    logs = []

    print("")
    print(f"{TYPE_LABEL}用画像出力開始")
    print(f"抽出方法: {selection_message}")
    print(f"対象ポイント: {len(features)}箇所")
    print("")

    for feature in features:
        raw_key = feature[NAME_FIELD]

        if raw_key is None or str(raw_key).strip() == "":
            key = f"FeatureID_{feature.id()}"
            logs.append(
                f"[注意] Feature ID={feature.id()}: "
                f"箇所番が空欄のため{key}を使用"
            )
        else:
            key = str(raw_key).strip()

        point = geometry_center(feature)

        if point is None:
            skipped += 1
            logs.append(
                f"[スキップ] {key}: 位置情報なし"
            )
            continue

        if coordinate_transform:
            point = coordinate_transform.transform(point)

        for setting in OUTPUT_SETTINGS:
            try:
                layout, map_item = create_layout(
                    project,
                    visible_layers,
                    setting["width_mm"],
                    setting["height_mm"],
                )

                map_item.setExtent(
                    extent_from_center(
                        point.x(),
                        point.y(),
                        setting["scale"],
                        setting["width_mm"],
                        setting["height_mm"],
                    )
                )

                map_item.setScale(setting["scale"])
                map_item.refresh()
                QgsApplication.processEvents()

                output_name = (
                    f"{safe_filename(key)}_"
                    f"{setting['suffix']}.png"
                )

                output_path = (
                    output_folder / output_name
                )

                export_settings = (
                    QgsLayoutExporter.ImageExportSettings()
                )

                export_settings.dpi = DPI
                export_settings.cropToContents = False

                result = QgsLayoutExporter(
                    layout
                ).exportToImage(
                    str(output_path),
                    export_settings,
                )

                if result == QgsLayoutExporter.Success:
                    success += 1

                    logs.append(
                        f"[完了] {output_name} "
                        f"{setting['width_mm']:.3f}×"
                        f"{setting['height_mm']:.3f}mm "
                        f"1:{setting['scale']:,}"
                    )
                else:
                    failure += 1
                    logs.append(
                        f"[失敗] {output_name} "
                        f"結果コード={result}"
                    )

            except Exception as exc:
                failure += 1
                logs.append(
                    f"[失敗] {key} "
                    f"{setting['suffix']}: {exc}"
                )

    log_path = (
        output_folder
        / "区域調書_土石流_QGIS画像出力結果.txt"
    )

    log_path.write_text(
        "\n".join(
            [
                f"{TYPE_LABEL}用 QGIS地図画像出力結果",
                f"出力先: {output_folder}",
                f"抽出方法: {selection_message}",
                f"対象ポイント: {len(features)}箇所",
                f"成功: {success}枚",
                f"失敗: {failure}枚",
                f"位置情報なし: {skipped}箇所",
                "",
            ]
            + logs
        ),
        encoding="utf-8-sig",
    )

    print("")
    print(f"成功: {success}枚")
    print(f"失敗: {failure}枚")
    print(f"ログ: {log_path}")

    QMessageBox.information(
        None,
        "完了",
        f"{TYPE_LABEL}用画像を出力しました。\n\n"
        f"成功: {success}枚\n"
        f"失敗: {failure}枚\n"
        f"位置情報なし: {skipped}箇所\n\n"
        f"ログ:\n{log_path}",
    )


main()
QGISコード:急傾斜用
# -*- coding: utf-8 -*-
"""
区域調書・急傾斜用 QGIS地図画像一括出力

・ポイントレイヤ名: 表紙作成
・箇所番号フィールド: 箇所番
・国土地理院ベクトルタイルなど、表示中の背景を出力
・ポイントレイヤ自体は画像へ出さない
・選択中のポイントがあれば、選択地物をすべて出力
・選択がなければ、レイヤ内の全ポイントをすべて出力
・接頭辞判定は一切しない
・箇所番が空欄の場合はFeatureIDを仮ファイル名として使用
・画像は区域調書の貼付枠と同じ縦横比で出力
"""

import re
from pathlib import Path

from qgis.core import (
    QgsApplication,
    QgsCoordinateTransform,
    QgsLayout,
    QgsLayoutExporter,
    QgsLayoutItemMap,
    QgsLayoutPoint,
    QgsLayoutSize,
    QgsProject,
    QgsRectangle,
    QgsUnitTypes,
    QgsWkbTypes,
)
from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox
from qgis.utils import iface


# ============================================================
# 設定
# ============================================================

POINT_LAYER_NAME = "表紙作成"
NAME_FIELD = "箇所番"
DPI = 300

TYPE_LABEL = "急傾斜"
KEY_PREFIXES = ('KEN',)

OUTPUT_SETTINGS = [
    {
        "suffix": "kyukei_200k",
        "scale": 200000,
        "width_mm": 148.1666666667,
        "height_mm": 89.9583333333,
    },
    {
        "suffix": "kyukei_25k",
        "scale": 25000,
        "width_mm": 127.0,
        "height_mm": 134.6729166667,
    }
]


# ============================================================
# 補助関数
# ============================================================

def safe_filename(value):
    text = str(value).strip()
    text = re.sub(r'[\\/:*?"<>|]+', "_", text)
    return text if text else "no_name"


def key_is_target(value):
    key = str(value).strip().upper()
    return any(key.startswith(prefix.upper()) for prefix in KEY_PREFIXES)


def geometry_center(feature):
    geometry = feature.geometry()

    if geometry is None or geometry.isEmpty():
        return None

    if geometry.type() == QgsWkbTypes.PointGeometry:
        if geometry.isMultipart():
            points = geometry.asMultiPoint()
            return points[0] if points else None

        return geometry.asPoint()

    centroid = geometry.centroid()

    if centroid.isEmpty():
        return None

    return centroid.asPoint()


def extent_from_center(
    center_x,
    center_y,
    scale,
    width_mm,
    height_mm,
):
    ground_width = width_mm / 1000.0 * scale
    ground_height = height_mm / 1000.0 * scale

    return QgsRectangle(
        center_x - ground_width / 2.0,
        center_y - ground_height / 2.0,
        center_x + ground_width / 2.0,
        center_y + ground_height / 2.0,
    )


def create_layout(
    project,
    visible_layers,
    width_mm,
    height_mm,
):
    layout = QgsLayout(project)
    layout.initializeDefaults()

    page = layout.pageCollection().page(0)

    page.setPageSize(
        QgsLayoutSize(
            width_mm,
            height_mm,
            QgsUnitTypes.LayoutMillimeters,
        )
    )

    map_item = QgsLayoutItemMap(layout)
    layout.addLayoutItem(map_item)

    map_item.attemptMove(
        QgsLayoutPoint(
            0,
            0,
            QgsUnitTypes.LayoutMillimeters,
        )
    )

    map_item.attemptResize(
        QgsLayoutSize(
            width_mm,
            height_mm,
            QgsUnitTypes.LayoutMillimeters,
        )
    )

    map_item.setLayers(visible_layers)
    map_item.setFrameEnabled(False)

    return layout, map_item


# ============================================================
# メイン
# ============================================================

def main():
    folder_text = QFileDialog.getExistingDirectory(
        None,
        f"{TYPE_LABEL}用画像の出力先フォルダを選択",
    )

    if not folder_text:
        return

    output_folder = Path(folder_text)

    project = QgsProject.instance()

    point_layers = project.mapLayersByName(
        POINT_LAYER_NAME
    )

    if not point_layers:
        QMessageBox.critical(
            None,
            "レイヤなし",
            f"ポイントレイヤ「{POINT_LAYER_NAME}」がありません。",
        )
        return

    point_layer = point_layers[0]

    if NAME_FIELD not in point_layer.fields().names():
        QMessageBox.critical(
            None,
            "フィールドなし",
            f"フィールド「{NAME_FIELD}」がありません。",
        )
        return

    project_crs = project.crs()

    if project_crs.isGeographic():
        QMessageBox.critical(
            None,
            "座標系エラー",
            "プロジェクト座標系を、JGD2011平面直角座標系などの"
            "メートル単位の座標系へ変更してください。",
        )
        return

    visible_layers = [
        layer
        for layer in iface.mapCanvas().layers()
        if layer.id() != point_layer.id()
    ]

    if not visible_layers:
        QMessageBox.critical(
            None,
            "背景なし",
            "表示中の背景レイヤがありません。",
        )
        return

    selected = point_layer.selectedFeatures()

    # --------------------------------------------------------
    # 全ポイント強制出力ルール
    #
    # 1. 選択地物がある場合
    #    選択地物をすべて出力
    #
    # 2. 選択地物がない場合
    #    レイヤ内の全ポイントをすべて出力
    #
    # ※ 接頭辞判定は一切しない
    # --------------------------------------------------------

    all_features = list(
        point_layer.getFeatures()
    )

    if selected:
        features = selected
        selection_message = (
            "選択地物を強制出力"
        )

    else:
        features = all_features
        selection_message = (
            "全ポイントを強制出力"
        )

    if not features:
        QMessageBox.warning(
            None,
            "対象なし",
            "ポイントレイヤに地物がありません。",
        )
        return

    coordinate_transform = None

    if point_layer.crs() != project_crs:
        coordinate_transform = QgsCoordinateTransform(
            point_layer.crs(),
            project_crs,
            project,
        )

    success = 0
    failure = 0
    skipped = 0
    logs = []

    print("")
    print(f"{TYPE_LABEL}用画像出力開始")
    print(f"抽出方法: {selection_message}")
    print(f"対象ポイント: {len(features)}箇所")
    print("")

    for feature in features:
        raw_key = feature[NAME_FIELD]

        if raw_key is None or str(raw_key).strip() == "":
            key = f"FeatureID_{feature.id()}"
            logs.append(
                f"[注意] Feature ID={feature.id()}: "
                f"箇所番が空欄のため{key}を使用"
            )
        else:
            key = str(raw_key).strip()

        point = geometry_center(feature)

        if point is None:
            skipped += 1
            logs.append(
                f"[スキップ] {key}: 位置情報なし"
            )
            continue

        if coordinate_transform:
            point = coordinate_transform.transform(point)

        for setting in OUTPUT_SETTINGS:
            try:
                layout, map_item = create_layout(
                    project,
                    visible_layers,
                    setting["width_mm"],
                    setting["height_mm"],
                )

                map_item.setExtent(
                    extent_from_center(
                        point.x(),
                        point.y(),
                        setting["scale"],
                        setting["width_mm"],
                        setting["height_mm"],
                    )
                )

                map_item.setScale(setting["scale"])
                map_item.refresh()
                QgsApplication.processEvents()

                output_name = (
                    f"{safe_filename(key)}_"
                    f"{setting['suffix']}.png"
                )

                output_path = (
                    output_folder / output_name
                )

                export_settings = (
                    QgsLayoutExporter.ImageExportSettings()
                )

                export_settings.dpi = DPI
                export_settings.cropToContents = False

                result = QgsLayoutExporter(
                    layout
                ).exportToImage(
                    str(output_path),
                    export_settings,
                )

                if result == QgsLayoutExporter.Success:
                    success += 1

                    logs.append(
                        f"[完了] {output_name} "
                        f"{setting['width_mm']:.3f}×"
                        f"{setting['height_mm']:.3f}mm "
                        f"1:{setting['scale']:,}"
                    )
                else:
                    failure += 1
                    logs.append(
                        f"[失敗] {output_name} "
                        f"結果コード={result}"
                    )

            except Exception as exc:
                failure += 1
                logs.append(
                    f"[失敗] {key} "
                    f"{setting['suffix']}: {exc}"
                )

    log_path = (
        output_folder
        / "区域調書_急傾斜_QGIS画像出力結果.txt"
    )

    log_path.write_text(
        "\n".join(
            [
                f"{TYPE_LABEL}用 QGIS地図画像出力結果",
                f"出力先: {output_folder}",
                f"抽出方法: {selection_message}",
                f"対象ポイント: {len(features)}箇所",
                f"成功: {success}枚",
                f"失敗: {failure}枚",
                f"位置情報なし: {skipped}箇所",
                "",
            ]
            + logs
        ),
        encoding="utf-8-sig",
    )

    print("")
    print(f"成功: {success}枚")
    print(f"失敗: {failure}枚")
    print(f"ログ: {log_path}")

    QMessageBox.information(
        None,
        "完了",
        f"{TYPE_LABEL}用画像を出力しました。\n\n"
        f"成功: {success}枚\n"
        f"失敗: {failure}枚\n"
        f"位置情報なし: {skipped}箇所\n\n"
        f"ログ:\n{log_path}",
    )


main()

7.画像とExcelを同じフォルダへ入れる

土石流の例

表紙_土石流調書 ├─ 調書_G54031K.xlsx ├─ G54031K_doseki_200k.png ├─ G54031K_doseki_25k.png ├─ 区域調書_土石流用_最終仕上げ一括処理_v3_BOMなし.py ├─ 区域調書_土石流用_最終仕上げ一括処理_v3_実行.bat └─ north_arrow.png

急傾斜の例

表紙_急傾斜調書 ├─ 調書_KEN02590040101.xlsx ├─ KEN02590040101_kyukei_200k.png ├─ KEN02590040101_kyukei_25k.png ├─ 区域調書_急傾斜用_最終仕上げ一括処理_v3_BOMなし.py ├─ 区域調書_急傾斜用_最終仕上げ一括処理_v3_実行.bat └─ north_arrow.png
north_arrow.pngは、Pythonファイルと同じフォルダに置きます。 方位記号を画像として配置するため、このファイルがない場合は方位記号が入りません。

8.Excelの最終仕上げ処理

Excelをすべて閉じてから、対応するBATをダブルクリックします。 画面でExcelとPNGの入ったフォルダを選ぶと、各ファイルをバックグラウンドで開き、 次の処理を行います。

処理 内容
既存図形の削除 以前貼った地図、赤円、引き出し線、文字、方位記号を削除して再作成
地図画像 土石流・急傾斜それぞれの専用範囲へ貼付
赤円 直径3cm、赤線2.25pt、塗りつぶしなし、地図中央
左の引き出し線 「調査位置」を表示
右の引き出し線 表紙シートから箇所名または渓流名を取得して表示
方位記号 左右の地図右上へ画像で配置
バックアップ 処理前のExcelを日時付きフォルダへコピー

初回のみ必要なPythonライブラリ

python -m pip install pywin32

9.旧番号と現在番号が異なる場合

古い区域調書では、現在のExcelファイル名とQGIS画像の番号が異なる場合があります。 たとえば、次の組み合わせです。

Excel
区域調書_KEN02570360101.xlsx

画像
KEN027036_kyukei_200k.png
KEN027036_kyukei_25k.png

最終仕上げコードでは、次の方法で画像番号の候補を探します。

  1. 現在のExcelファイル名から取得した番号
  2. コード内の旧番号対応表
  3. Excel内部に残っている旧保存パスや旧番号

このため、既知の旧番号の組み合わせも自動で処理できます。 新たな対応が必要な場合は、コード内の MANUAL_IMAGE_ALIASESへ追加します。

MANUAL_IMAGE_ALIASES = {
    "KEN02570360101": (
        "KEN027036",
    ),
}

10.Excelコード

次のコードは、画像貼付だけでなく、赤円、引き出し線、名称、方位記号まで含む 最終仕上げ版です。

Excelコード:土石流用
# -*- coding: utf-8 -*-
"""
区域調書・土石流用 最終仕上げ一括処理 v3

修正版
・方位記号を少し大きく
・右側の引き出し線を左側と同じ雰囲気に統一
・引き出し線の始点を赤丸に接する位置へ調整
・旧番号画像にも柔軟対応
"""

import re
import shutil
import traceback
import zipfile
from datetime import datetime
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox

TYPE_LABEL = "土石流"
COVER_SHEET_NAME = "表紙"

RANGE_200K = "B13:D28"
RANGE_25K = "F5:M28"

SUFFIX_200K = "doseki_200k"
SUFFIX_25K = "doseki_25k"

PLACE_NAME_CELLS = ('C9', 'D9', 'C8')
PLACE_NAME_LABELS = ('渓流名', '箇所名')

MANUAL_IMAGE_ALIASES = {}

EDGE_INSET_LEFT_PT = 0.5
EDGE_INSET_RIGHT_PT = 0.5
EDGE_INSET_TOP_PT = 0.5
EDGE_INSET_BOTTOM_PT = 0.5

CIRCLE_CM = 3.0
CIRCLE_LINE_PT = 2.25
LEADER_LINE_PT = 1.25
LABEL_FONT_PT = 12.0

# 方位記号(v2より3周りほど大きめ)
NORTH_IMG_WIDTH_PT = 27.0
NORTH_IMG_HEIGHT_PT = 36.0

MSO_FALSE = 0
MSO_TRUE = -1
MSO_SHAPE_OVAL = 9
MSO_TEXT_ORIENTATION_HORIZONTAL = 1
MSO_BRING_TO_FRONT = 0
MSO_SEND_TO_BACK = 1
MSO_LINKED_PICTURE = 11
MSO_PICTURE = 13
XL_MOVE_AND_SIZE = 1

def choose_folder():
    root = tk.Tk()
    root.withdraw()
    root.attributes('-topmost', True)
    folder = filedialog.askdirectory(title=f'{TYPE_LABEL}の区域調書Excelと専用画像が入ったフォルダを選択')
    root.destroy()
    return folder

def show_message(title, text, is_error=False):
    root = tk.Tk()
    root.withdraw()
    root.attributes('-topmost', True)
    if is_error:
        messagebox.showerror(title, text, parent=root)
    else:
        messagebox.showinfo(title, text, parent=root)
    root.destroy()

def normalize_text(value):
    if value is None:
        return ''
    return re.sub(r'[\s ]+', '', str(value))

def normalize_key(value):
    return re.sub(r'[\s _\--]+', '', str(value).strip()).upper()

def get_excel_key(excel_path):
    stem = excel_path.stem.strip()
    prefixes = ('区域調書_', '調書_', '区域調書', '調書')
    changed = True
    while changed:
        changed = False
        for prefix in prefixes:
            if stem.startswith(prefix):
                stem = stem[len(prefix):].lstrip('_  ')
                changed = True
                break
    stem = re.sub(r'\(\d+\)$', '', stem)
    match = re.match(r'^(G\d+K|GEN\d+|KEN\d+|\d+)', stem, flags=re.IGNORECASE)
    if match:
        return match.group(1)
    return stem.split('_', 1)[0].strip()

def add_unique_key(keys, value):
    if value is None:
        return
    value = str(value).strip()
    if not value:
        return
    normalized = normalize_key(value)
    if not normalized:
        return
    if all(normalize_key(existing) != normalized for existing in keys):
        keys.append(value)

def get_candidate_image_keys(excel_path):
    keys = []
    primary_key = get_excel_key(excel_path)
    add_unique_key(keys, primary_key)

    manual = MANUAL_IMAGE_ALIASES.get(normalize_key(primary_key), ())
    if isinstance(manual, str):
        manual = (manual,)
    for alias in manual:
        add_unique_key(keys, alias)

    try:
        with zipfile.ZipFile(excel_path, 'r') as z:
            for member in ('xl/workbook.xml', 'docProps/app.xml', 'docProps/core.xml'):
                try:
                    xml = z.read(member).decode('utf-8', errors='ignore')
                except Exception:
                    continue
                for match in re.findall(r'(GEN\d+|KEN\d+|G\d+K|\d{8,})', xml, flags=re.IGNORECASE):
                    add_unique_key(keys, match)
    except Exception:
        pass
    return keys

def list_excel_files(folder):
    files = []
    for pattern in ('*.xlsx', '*.xlsm'):
        files.extend(folder.glob(pattern))
    return sorted([
        p for p in files
        if not p.name.startswith('~$') and '_バックアップ_' not in str(p)
    ], key=lambda p: p.name.lower())

def find_matching_image(folder, candidate_keys, suffix):
    exact_suffix = f'_{suffix}.png'.lower()
    for key in candidate_keys:
        exact = folder / f'{key}_{suffix}.png'
        if exact.exists():
            return exact, key, '完全一致'

    candidates = []
    for image_path in folder.glob('*.png'):
        if not image_path.name.lower().endswith(exact_suffix):
            continue
        image_key = image_path.name[:-len(exact_suffix)]
        image_normalized = normalize_key(image_key)
        for key_order, key in enumerate(candidate_keys):
            key_normalized = normalize_key(key)
            if image_normalized.startswith(key_normalized) or key_normalized.startswith(image_normalized):
                candidates.append((key_order, abs(len(image_normalized)-len(key_normalized)), image_path.name.lower(), image_path, key))
    if not candidates:
        return None, None, None
    candidates.sort(key=lambda item: (item[0], item[1], item[2]))
    _, _, _, image_path, matched_key = candidates[0]
    return image_path, matched_key, '前方一致'

def get_cover_sheet(workbook):
    target = normalize_text(COVER_SHEET_NAME)
    for ws in workbook.Worksheets:
        if normalize_text(ws.Name) == target:
            return ws
    raise RuntimeError('「表紙」シートが見つかりません。')

def cm_to_points(cm):
    return float(cm) * 28.3464566929

def rgb(r, g, b):
    return int(r) + int(g) * 256 + int(b) * 65536

def get_place_name(cover_sheet, fallback):
    for address in PLACE_NAME_CELLS:
        try:
            value = cover_sheet.Range(address).Value
            if value is not None and str(value).strip():
                return str(value).strip()
        except Exception:
            pass
    label_set = {normalize_text(label) for label in PLACE_NAME_LABELS}
    try:
        for row in range(1, 16):
            for col in range(1, 20):
                value = cover_sheet.Cells(row, col).Value
                if normalize_text(value) not in label_set:
                    continue
                for offset in range(1, 13):
                    cand = cover_sheet.Cells(row, col + offset).Value
                    if cand is not None and str(cand).strip():
                        return str(cand).strip()
    except Exception:
        pass
    return fallback

def rectangles_intersect(left1, top1, width1, height1, left2, top2, width2, height2):
    return not (
        left1 + width1 <= left2 or left2 + width2 <= left1 or top1 + height1 <= top2 or top2 + height2 <= top1
    )

def shape_intersects_range(shape, target_range):
    return rectangles_intersect(
        float(shape.Left), float(shape.Top), float(shape.Width), float(shape.Height),
        float(target_range.Left), float(target_range.Top), float(target_range.Width), float(target_range.Height)
    )

def get_shape_text(shape):
    try:
        if shape.TextFrame2.HasText:
            return str(shape.TextFrame2.TextRange.Text).strip()
    except Exception:
        pass
    try:
        return str(shape.TextFrame.Characters().Text).strip()
    except Exception:
        return ''

def is_tool_shape(shape):
    try:
        name = normalize_text(shape.Name).upper()
    except Exception:
        return False
    prefixes = ('AUTO_MAP_CHOSHO', 'AUTO_CIRCLE_CHOSHO', 'AUTO_LEADER_CHOSHO', 'AUTO_LABEL_CHOSHO', 'AUTO_NORTH_CHOSHO')
    return any(name.startswith(prefix) for prefix in prefixes)

def delete_old_map_objects(worksheet, target_ranges, annotation_texts):
    deleted = []
    normalized_annotation_texts = {normalize_text(text) for text in annotation_texts if text}
    for index in range(worksheet.Shapes.Count, 0, -1):
        shape = worksheet.Shapes.Item(index)
        try:
            if not any(shape_intersects_range(shape, target_range) for target_range in target_ranges):
                continue
            shape_type = int(shape.Type)
            text = normalize_text(get_shape_text(shape))
            should_delete = (
                shape_type in (MSO_PICTURE, MSO_LINKED_PICTURE)
                or is_tool_shape(shape)
                or (text and text in normalized_annotation_texts)
            )
            if should_delete:
                try:
                    deleted.append(str(shape.Name))
                except Exception:
                    deleted.append(f'Shape{index}')
                shape.Delete()
        except Exception:
            continue
    return deleted

def bring_to_front(shape):
    try:
        shape.ZOrder(MSO_BRING_TO_FRONT)
    except Exception:
        pass

def add_picture_to_range(worksheet, image_path, target_range, shape_name):
    left = float(target_range.Left) + EDGE_INSET_LEFT_PT
    top = float(target_range.Top) + EDGE_INSET_TOP_PT
    width = max(1.0, float(target_range.Width) - EDGE_INSET_LEFT_PT - EDGE_INSET_RIGHT_PT)
    height = max(1.0, float(target_range.Height) - EDGE_INSET_TOP_PT - EDGE_INSET_BOTTOM_PT)

    picture = worksheet.Shapes.AddPicture(str(image_path), MSO_FALSE, MSO_TRUE, left, top, width, height)
    picture.LockAspectRatio = MSO_FALSE
    picture.Left = left
    picture.Top = top
    picture.Width = width
    picture.Height = height
    try:
        picture.Placement = XL_MOVE_AND_SIZE
    except Exception:
        pass
    try:
        picture.Name = shape_name
    except Exception:
        pass
    try:
        picture.ZOrder(MSO_SEND_TO_BACK)
    except Exception:
        pass
    return picture

def add_center_circle(worksheet, target_range, shape_name):
    diameter = cm_to_points(CIRCLE_CM)
    center_x = float(target_range.Left) + float(target_range.Width) / 2.0
    center_y = float(target_range.Top) + float(target_range.Height) / 2.0
    circle = worksheet.Shapes.AddShape(MSO_SHAPE_OVAL, center_x - diameter/2.0, center_y - diameter/2.0, diameter, diameter)
    circle.LockAspectRatio = MSO_TRUE
    circle.Fill.Visible = MSO_FALSE
    circle.Line.Visible = MSO_TRUE
    circle.Line.ForeColor.RGB = rgb(255, 0, 0)
    circle.Line.Weight = CIRCLE_LINE_PT
    try:
        circle.Placement = XL_MOVE_AND_SIZE
    except Exception:
        pass
    try:
        circle.Name = shape_name
    except Exception:
        pass
    bring_to_front(circle)
    return circle

def set_line_style(line, color, weight):
    line.Line.Visible = MSO_TRUE
    line.Line.ForeColor.RGB = color
    line.Line.Weight = weight
    bring_to_front(line)

def add_text_box(worksheet, text, left, top, width, height, shape_name, font_size, align_center=False):
    box = worksheet.Shapes.AddTextbox(MSO_TEXT_ORIENTATION_HORIZONTAL, left, top, width, height)
    box.Fill.Visible = MSO_FALSE
    box.Line.Visible = MSO_FALSE
    try:
        box.Name = shape_name
    except Exception:
        pass
    try:
        box.TextFrame2.TextRange.Text = text
        box.TextFrame2.MarginLeft = 0
        box.TextFrame2.MarginRight = 0
        box.TextFrame2.MarginTop = 0
        box.TextFrame2.MarginBottom = 0
        font = box.TextFrame2.TextRange.Font
        font.Size = font_size
        font.Bold = MSO_TRUE
        font.Fill.ForeColor.RGB = rgb(0, 0, 0)
        try:
            font.Name = 'MS Pゴシック'
            font.NameFarEast = 'MS Pゴシック'
        except Exception:
            pass
        try:
            box.TextFrame2.TextRange.ParagraphFormat.Alignment = 2 if align_center else 1
        except Exception:
            pass
    except Exception:
        try:
            box.TextFrame.Characters().Text = text
        except Exception:
            pass
    try:
        box.Placement = XL_MOVE_AND_SIZE
    except Exception:
        pass
    bring_to_front(box)
    return box

def add_leader_annotation(worksheet, target_range, text, side, shape_prefix):
    """
    左右とも同じ形:
    赤丸に接する斜線 → 右上の水平線 → 文字
    """
    range_left = float(target_range.Left)
    range_top = float(target_range.Top)
    range_width = float(target_range.Width)
    range_height = float(target_range.Height)

    diameter = cm_to_points(CIRCLE_CM)
    radius = diameter / 2.0
    center_x = range_left + range_width / 2.0
    center_y = range_top + range_height / 2.0

    # 赤丸の外周に接する始点(右上あたり)
    start_x = center_x + radius * 0.92
    start_y = center_y - radius * 0.18

    text_length = max(4, len(str(text)))
    box_width = min(range_width * 0.24, max(60.0, text_length * 13.5))
    box_height = 20.0 if text_length <= 8 else 26.0

    # 右側も左と同じ雰囲気に統一
    box_left = range_left + range_width * 0.67
    box_top = range_top + range_height * 0.18
    if side == 'right':
        box_top = range_top + range_height * 0.10
        box_left = range_left + range_width * 0.66

    max_left = range_left + range_width - box_width - 10.0
    box_left = min(box_left, max_left)

    text_anchor_x = box_left
    elbow_y = box_top + box_height * 0.58
    elbow_x = text_anchor_x - 22.0

    line1 = worksheet.Shapes.AddLine(start_x, start_y, elbow_x, elbow_y)
    try:
        line1.Name = shape_prefix + '_LINE1'
    except Exception:
        pass
    set_line_style(line1, rgb(255, 0, 0), LEADER_LINE_PT)

    line2 = worksheet.Shapes.AddLine(elbow_x, elbow_y, text_anchor_x, elbow_y)
    try:
        line2.Name = shape_prefix + '_LINE2'
    except Exception:
        pass
    set_line_style(line2, rgb(255, 0, 0), LEADER_LINE_PT)

    text_box = add_text_box(
        worksheet,
        str(text),
        text_anchor_x + 2.0,
        box_top,
        box_width,
        box_height,
        shape_prefix.replace('LEADER', 'LABEL'),
        LABEL_FONT_PT,
        align_center=False,
    )
    return line1, line2, text_box

def add_north_mark(worksheet, target_range, shape_prefix):
    asset_path = Path(__file__).with_name('north_arrow.png')
    if not asset_path.exists():
        return None

    range_left = float(target_range.Left)
    range_top = float(target_range.Top)
    range_width = float(target_range.Width)

    left = range_left + range_width - NORTH_IMG_WIDTH_PT - 4.0
    top = range_top + 4.0

    picture = worksheet.Shapes.AddPicture(str(asset_path), MSO_FALSE, MSO_TRUE, left, top, NORTH_IMG_WIDTH_PT, NORTH_IMG_HEIGHT_PT)
    try:
        picture.Name = shape_prefix + '_IMG'
    except Exception:
        pass
    try:
        picture.Placement = XL_MOVE_AND_SIZE
    except Exception:
        pass
    bring_to_front(picture)
    return picture

def main():
    folder_text = choose_folder()
    if not folder_text:
        return

    folder = Path(folder_text)
    all_excels = list_excel_files(folder)

    work_items = []
    ignored = []

    for excel_file in all_excels:
        candidate_keys = get_candidate_image_keys(excel_file)
        image_200k, key_200k, match_200k = find_matching_image(folder, candidate_keys, SUFFIX_200K)
        image_25k, key_25k, match_25k = find_matching_image(folder, candidate_keys, SUFFIX_25K)
        if image_200k is None or image_25k is None:
            ignored.append(f'{excel_file.name} (専用画像が2枚そろっていない/候補番号: {", ".join(candidate_keys)})')
            continue
        work_items.append({
            'excel': excel_file,
            'primary_key': get_excel_key(excel_file),
            'candidate_keys': candidate_keys,
            '200k': image_200k,
            '25k': image_25k,
            'match_200k': (key_200k, match_200k),
            'match_25k': (key_25k, match_25k),
        })

    if not work_items:
        show_message('対象なし', '対応画像が2枚そろった区域調書Excelがありません。', True)
        return

    try:
        import pythoncom
        import win32com.client
    except ImportError:
        show_message('pywin32が必要です', 'python -m pip install pywin32 を実行してください。', True)
        return

    pythoncom.CoInitialize()
    excel = None
    workbook = None
    try:
        excel = win32com.client.DispatchEx('Excel.Application')
        excel.Visible = False
        excel.DisplayAlerts = False
        excel.ScreenUpdating = False
        excel.EnableEvents = False
        excel.AskToUpdateLinks = False

        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        backup_root = folder / '_土石流区域調書最終仕上げバックアップ_v3'
        backup_folder = backup_root / f'_{timestamp}'
        backup_root.mkdir(exist_ok=True)
        backup_folder.mkdir(exist_ok=True)

        completed = 0
        errors = 0
        logs = []

        for item in work_items:
            workbook = None
            excel_file = item['excel']
            try:
                shutil.copy2(excel_file, backup_folder / excel_file.name)
                workbook = excel.Workbooks.Open(str(excel_file), UpdateLinks=0, ReadOnly=False, IgnoreReadOnlyRecommended=True)
                try:
                    workbook.Activate()
                except Exception:
                    pass
                cover = get_cover_sheet(workbook)
                try:
                    cover.Activate()
                except Exception:
                    pass

                place_name = get_place_name(cover, item['primary_key'])
                range_200k = cover.Range(RANGE_200K)
                range_25k = cover.Range(RANGE_25K)
                deleted = delete_old_map_objects(cover, [range_200k, range_25k], ['調査位置', place_name, 'N'])

                add_picture_to_range(cover, item['200k'], range_200k, 'AUTO_MAP_CHOSHO_' + TYPE_LABEL + '_200K')
                add_center_circle(cover, range_200k, 'AUTO_CIRCLE_CHOSHO_' + TYPE_LABEL + '_200K')
                add_picture_to_range(cover, item['25k'], range_25k, 'AUTO_MAP_CHOSHO_' + TYPE_LABEL + '_25K')
                add_center_circle(cover, range_25k, 'AUTO_CIRCLE_CHOSHO_' + TYPE_LABEL + '_25K')

                add_leader_annotation(cover, range_200k, '調査位置', 'left', 'AUTO_LEADER_CHOSHO_' + TYPE_LABEL + '_200K')
                add_leader_annotation(cover, range_25k, place_name, 'right', 'AUTO_LEADER_CHOSHO_' + TYPE_LABEL + '_25K')
                add_north_mark(cover, range_200k, 'AUTO_NORTH_CHOSHO_' + TYPE_LABEL + '_200K')
                add_north_mark(cover, range_25k, 'AUTO_NORTH_CHOSHO_' + TYPE_LABEL + '_25K')

                try:
                    workbook.Application.Calculate()
                except Exception:
                    pass
                workbook.Save()
                workbook.Close(SaveChanges=False)
                workbook = None
                completed += 1
                logs.append(
                    f"[完了] {excel_file.name} - 箇所名:{place_name} / 20万図:{item['200k'].name}({item['match_200k'][1]}) / 2.5万図:{item['25k'].name}({item['match_25k'][1]}) / 旧図形削除:{len(deleted)}個"
                )
            except Exception as exc:
                errors += 1
                logs.append(f'[エラー] {excel_file.name} - {type(exc).__name__}: {exc}')
                if workbook is not None:
                    try:
                        workbook.Close(SaveChanges=False)
                    except Exception:
                        pass
                    workbook = None

        log_path = folder / f"区域調書_土石流_最終仕上げ結果_v3_{timestamp}.txt"
        header = [
            f'区域調書 {TYPE_LABEL}用 最終仕上げ結果 v3',
            '処理日時: ' + datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            f'対象フォルダ: {folder}',
            f'処理対象: {len(work_items)}件',
            f'完了: {completed}件',
            f'エラー: {errors}件',
            f'未処理: {len(ignored)}件',
            '',
            '処理内容:',
            '  地図画像2枚',
            '  中央の赤円',
            '  左右とも統一した引き出し線',
            '  大きめの方位記号画像',
            '',
            f'20万図: {RANGE_200K}',
            f'2.5万図: {RANGE_25K}',
            '',
            f'バックアップ: {backup_folder}',
            '',
        ]
        if ignored:
            header.append('未処理Excel:')
            header.extend('  ' + item for item in ignored)
            header.append('')
        log_path.write_text('\n'.join(header + logs), encoding='utf-8-sig')

        show_message(
            '完了',
            f'{TYPE_LABEL}の区域調書を最終仕上げしました。\n\n処理対象: {len(work_items)}件\n完了: {completed}件\nエラー: {errors}件\n未処理: {len(ignored)}件\n\nログ:\n{log_path}'
        )
    except Exception:
        show_message('処理エラー', traceback.format_exc(), True)
    finally:
        if workbook is not None:
            try:
                workbook.Close(SaveChanges=False)
            except Exception:
                pass
        if excel is not None:
            try:
                excel.EnableEvents = True
                excel.ScreenUpdating = True
                excel.DisplayAlerts = True
                excel.Quit()
            except Exception:
                pass
        try:
            pythoncom.CoUninitialize()
        except Exception:
            pass

if __name__ == '__main__':
    main()
Excelコード:急傾斜用
# -*- coding: utf-8 -*-
"""
区域調書・急傾斜用 最終仕上げ一括処理 v3

修正版
・方位記号を少し大きく
・右側の引き出し線を左側と同じ雰囲気に統一
・引き出し線の始点を赤丸に接する位置へ調整
・旧番号画像にも柔軟対応
"""

import re
import shutil
import traceback
import zipfile
from datetime import datetime
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox

TYPE_LABEL = "急傾斜"
COVER_SHEET_NAME = "表紙"

RANGE_200K = "B13:O22"
RANGE_25K = "Q7:AB22"

SUFFIX_200K = "kyukei_200k"
SUFFIX_25K = "kyukei_25k"

PLACE_NAME_CELLS = ('F9', 'F8', 'G9')
PLACE_NAME_LABELS = ('箇所名',)

MANUAL_IMAGE_ALIASES = {'KEN02570360101': ('KEN027036',)}

EDGE_INSET_LEFT_PT = 0.8
EDGE_INSET_RIGHT_PT = 0.8
EDGE_INSET_TOP_PT = 2.2
EDGE_INSET_BOTTOM_PT = 0.8

CIRCLE_CM = 3.0
CIRCLE_LINE_PT = 2.25
LEADER_LINE_PT = 1.25
LABEL_FONT_PT = 12.0

# 方位記号(v2より3周りほど大きめ)
NORTH_IMG_WIDTH_PT = 27.0
NORTH_IMG_HEIGHT_PT = 36.0

MSO_FALSE = 0
MSO_TRUE = -1
MSO_SHAPE_OVAL = 9
MSO_TEXT_ORIENTATION_HORIZONTAL = 1
MSO_BRING_TO_FRONT = 0
MSO_SEND_TO_BACK = 1
MSO_LINKED_PICTURE = 11
MSO_PICTURE = 13
XL_MOVE_AND_SIZE = 1

def choose_folder():
    root = tk.Tk()
    root.withdraw()
    root.attributes('-topmost', True)
    folder = filedialog.askdirectory(title=f'{TYPE_LABEL}の区域調書Excelと専用画像が入ったフォルダを選択')
    root.destroy()
    return folder

def show_message(title, text, is_error=False):
    root = tk.Tk()
    root.withdraw()
    root.attributes('-topmost', True)
    if is_error:
        messagebox.showerror(title, text, parent=root)
    else:
        messagebox.showinfo(title, text, parent=root)
    root.destroy()

def normalize_text(value):
    if value is None:
        return ''
    return re.sub(r'[\s ]+', '', str(value))

def normalize_key(value):
    return re.sub(r'[\s _\--]+', '', str(value).strip()).upper()

def get_excel_key(excel_path):
    stem = excel_path.stem.strip()
    prefixes = ('区域調書_', '調書_', '区域調書', '調書')
    changed = True
    while changed:
        changed = False
        for prefix in prefixes:
            if stem.startswith(prefix):
                stem = stem[len(prefix):].lstrip('_  ')
                changed = True
                break
    stem = re.sub(r'\(\d+\)$', '', stem)
    match = re.match(r'^(G\d+K|GEN\d+|KEN\d+|\d+)', stem, flags=re.IGNORECASE)
    if match:
        return match.group(1)
    return stem.split('_', 1)[0].strip()

def add_unique_key(keys, value):
    if value is None:
        return
    value = str(value).strip()
    if not value:
        return
    normalized = normalize_key(value)
    if not normalized:
        return
    if all(normalize_key(existing) != normalized for existing in keys):
        keys.append(value)

def get_candidate_image_keys(excel_path):
    keys = []
    primary_key = get_excel_key(excel_path)
    add_unique_key(keys, primary_key)

    manual = MANUAL_IMAGE_ALIASES.get(normalize_key(primary_key), ())
    if isinstance(manual, str):
        manual = (manual,)
    for alias in manual:
        add_unique_key(keys, alias)

    try:
        with zipfile.ZipFile(excel_path, 'r') as z:
            for member in ('xl/workbook.xml', 'docProps/app.xml', 'docProps/core.xml'):
                try:
                    xml = z.read(member).decode('utf-8', errors='ignore')
                except Exception:
                    continue
                for match in re.findall(r'(GEN\d+|KEN\d+|G\d+K|\d{8,})', xml, flags=re.IGNORECASE):
                    add_unique_key(keys, match)
    except Exception:
        pass
    return keys

def list_excel_files(folder):
    files = []
    for pattern in ('*.xlsx', '*.xlsm'):
        files.extend(folder.glob(pattern))
    return sorted([
        p for p in files
        if not p.name.startswith('~$') and '_バックアップ_' not in str(p)
    ], key=lambda p: p.name.lower())

def find_matching_image(folder, candidate_keys, suffix):
    exact_suffix = f'_{suffix}.png'.lower()
    for key in candidate_keys:
        exact = folder / f'{key}_{suffix}.png'
        if exact.exists():
            return exact, key, '完全一致'

    candidates = []
    for image_path in folder.glob('*.png'):
        if not image_path.name.lower().endswith(exact_suffix):
            continue
        image_key = image_path.name[:-len(exact_suffix)]
        image_normalized = normalize_key(image_key)
        for key_order, key in enumerate(candidate_keys):
            key_normalized = normalize_key(key)
            if image_normalized.startswith(key_normalized) or key_normalized.startswith(image_normalized):
                candidates.append((key_order, abs(len(image_normalized)-len(key_normalized)), image_path.name.lower(), image_path, key))
    if not candidates:
        return None, None, None
    candidates.sort(key=lambda item: (item[0], item[1], item[2]))
    _, _, _, image_path, matched_key = candidates[0]
    return image_path, matched_key, '前方一致'

def get_cover_sheet(workbook):
    target = normalize_text(COVER_SHEET_NAME)
    for ws in workbook.Worksheets:
        if normalize_text(ws.Name) == target:
            return ws
    raise RuntimeError('「表紙」シートが見つかりません。')

def cm_to_points(cm):
    return float(cm) * 28.3464566929

def rgb(r, g, b):
    return int(r) + int(g) * 256 + int(b) * 65536

def get_place_name(cover_sheet, fallback):
    for address in PLACE_NAME_CELLS:
        try:
            value = cover_sheet.Range(address).Value
            if value is not None and str(value).strip():
                return str(value).strip()
        except Exception:
            pass
    label_set = {normalize_text(label) for label in PLACE_NAME_LABELS}
    try:
        for row in range(1, 16):
            for col in range(1, 20):
                value = cover_sheet.Cells(row, col).Value
                if normalize_text(value) not in label_set:
                    continue
                for offset in range(1, 13):
                    cand = cover_sheet.Cells(row, col + offset).Value
                    if cand is not None and str(cand).strip():
                        return str(cand).strip()
    except Exception:
        pass
    return fallback

def rectangles_intersect(left1, top1, width1, height1, left2, top2, width2, height2):
    return not (
        left1 + width1 <= left2 or left2 + width2 <= left1 or top1 + height1 <= top2 or top2 + height2 <= top1
    )

def shape_intersects_range(shape, target_range):
    return rectangles_intersect(
        float(shape.Left), float(shape.Top), float(shape.Width), float(shape.Height),
        float(target_range.Left), float(target_range.Top), float(target_range.Width), float(target_range.Height)
    )

def get_shape_text(shape):
    try:
        if shape.TextFrame2.HasText:
            return str(shape.TextFrame2.TextRange.Text).strip()
    except Exception:
        pass
    try:
        return str(shape.TextFrame.Characters().Text).strip()
    except Exception:
        return ''

def is_tool_shape(shape):
    try:
        name = normalize_text(shape.Name).upper()
    except Exception:
        return False
    prefixes = ('AUTO_MAP_CHOSHO', 'AUTO_CIRCLE_CHOSHO', 'AUTO_LEADER_CHOSHO', 'AUTO_LABEL_CHOSHO', 'AUTO_NORTH_CHOSHO')
    return any(name.startswith(prefix) for prefix in prefixes)

def delete_old_map_objects(worksheet, target_ranges, annotation_texts):
    deleted = []
    normalized_annotation_texts = {normalize_text(text) for text in annotation_texts if text}
    for index in range(worksheet.Shapes.Count, 0, -1):
        shape = worksheet.Shapes.Item(index)
        try:
            if not any(shape_intersects_range(shape, target_range) for target_range in target_ranges):
                continue
            shape_type = int(shape.Type)
            text = normalize_text(get_shape_text(shape))
            should_delete = (
                shape_type in (MSO_PICTURE, MSO_LINKED_PICTURE)
                or is_tool_shape(shape)
                or (text and text in normalized_annotation_texts)
            )
            if should_delete:
                try:
                    deleted.append(str(shape.Name))
                except Exception:
                    deleted.append(f'Shape{index}')
                shape.Delete()
        except Exception:
            continue
    return deleted

def bring_to_front(shape):
    try:
        shape.ZOrder(MSO_BRING_TO_FRONT)
    except Exception:
        pass

def add_picture_to_range(worksheet, image_path, target_range, shape_name):
    left = float(target_range.Left) + EDGE_INSET_LEFT_PT
    top = float(target_range.Top) + EDGE_INSET_TOP_PT
    width = max(1.0, float(target_range.Width) - EDGE_INSET_LEFT_PT - EDGE_INSET_RIGHT_PT)
    height = max(1.0, float(target_range.Height) - EDGE_INSET_TOP_PT - EDGE_INSET_BOTTOM_PT)

    picture = worksheet.Shapes.AddPicture(str(image_path), MSO_FALSE, MSO_TRUE, left, top, width, height)
    picture.LockAspectRatio = MSO_FALSE
    picture.Left = left
    picture.Top = top
    picture.Width = width
    picture.Height = height
    try:
        picture.Placement = XL_MOVE_AND_SIZE
    except Exception:
        pass
    try:
        picture.Name = shape_name
    except Exception:
        pass
    try:
        picture.ZOrder(MSO_SEND_TO_BACK)
    except Exception:
        pass
    return picture

def add_center_circle(worksheet, target_range, shape_name):
    diameter = cm_to_points(CIRCLE_CM)
    center_x = float(target_range.Left) + float(target_range.Width) / 2.0
    center_y = float(target_range.Top) + float(target_range.Height) / 2.0
    circle = worksheet.Shapes.AddShape(MSO_SHAPE_OVAL, center_x - diameter/2.0, center_y - diameter/2.0, diameter, diameter)
    circle.LockAspectRatio = MSO_TRUE
    circle.Fill.Visible = MSO_FALSE
    circle.Line.Visible = MSO_TRUE
    circle.Line.ForeColor.RGB = rgb(255, 0, 0)
    circle.Line.Weight = CIRCLE_LINE_PT
    try:
        circle.Placement = XL_MOVE_AND_SIZE
    except Exception:
        pass
    try:
        circle.Name = shape_name
    except Exception:
        pass
    bring_to_front(circle)
    return circle

def set_line_style(line, color, weight):
    line.Line.Visible = MSO_TRUE
    line.Line.ForeColor.RGB = color
    line.Line.Weight = weight
    bring_to_front(line)

def add_text_box(worksheet, text, left, top, width, height, shape_name, font_size, align_center=False):
    box = worksheet.Shapes.AddTextbox(MSO_TEXT_ORIENTATION_HORIZONTAL, left, top, width, height)
    box.Fill.Visible = MSO_FALSE
    box.Line.Visible = MSO_FALSE
    try:
        box.Name = shape_name
    except Exception:
        pass
    try:
        box.TextFrame2.TextRange.Text = text
        box.TextFrame2.MarginLeft = 0
        box.TextFrame2.MarginRight = 0
        box.TextFrame2.MarginTop = 0
        box.TextFrame2.MarginBottom = 0
        font = box.TextFrame2.TextRange.Font
        font.Size = font_size
        font.Bold = MSO_TRUE
        font.Fill.ForeColor.RGB = rgb(0, 0, 0)
        try:
            font.Name = 'MS Pゴシック'
            font.NameFarEast = 'MS Pゴシック'
        except Exception:
            pass
        try:
            box.TextFrame2.TextRange.ParagraphFormat.Alignment = 2 if align_center else 1
        except Exception:
            pass
    except Exception:
        try:
            box.TextFrame.Characters().Text = text
        except Exception:
            pass
    try:
        box.Placement = XL_MOVE_AND_SIZE
    except Exception:
        pass
    bring_to_front(box)
    return box

def add_leader_annotation(worksheet, target_range, text, side, shape_prefix):
    """
    左右とも同じ形:
    赤丸に接する斜線 → 右上の水平線 → 文字
    """
    range_left = float(target_range.Left)
    range_top = float(target_range.Top)
    range_width = float(target_range.Width)
    range_height = float(target_range.Height)

    diameter = cm_to_points(CIRCLE_CM)
    radius = diameter / 2.0
    center_x = range_left + range_width / 2.0
    center_y = range_top + range_height / 2.0

    # 赤丸の外周に接する始点(右上あたり)
    start_x = center_x + radius * 0.92
    start_y = center_y - radius * 0.18

    text_length = max(4, len(str(text)))
    box_width = min(range_width * 0.24, max(60.0, text_length * 13.5))
    box_height = 20.0 if text_length <= 8 else 26.0

    # 右側も左と同じ雰囲気に統一
    box_left = range_left + range_width * 0.67
    box_top = range_top + range_height * 0.18
    if side == 'right':
        box_top = range_top + range_height * 0.10
        box_left = range_left + range_width * 0.66

    max_left = range_left + range_width - box_width - 10.0
    box_left = min(box_left, max_left)

    text_anchor_x = box_left
    elbow_y = box_top + box_height * 0.58
    elbow_x = text_anchor_x - 22.0

    line1 = worksheet.Shapes.AddLine(start_x, start_y, elbow_x, elbow_y)
    try:
        line1.Name = shape_prefix + '_LINE1'
    except Exception:
        pass
    set_line_style(line1, rgb(255, 0, 0), LEADER_LINE_PT)

    line2 = worksheet.Shapes.AddLine(elbow_x, elbow_y, text_anchor_x, elbow_y)
    try:
        line2.Name = shape_prefix + '_LINE2'
    except Exception:
        pass
    set_line_style(line2, rgb(255, 0, 0), LEADER_LINE_PT)

    text_box = add_text_box(
        worksheet,
        str(text),
        text_anchor_x + 2.0,
        box_top,
        box_width,
        box_height,
        shape_prefix.replace('LEADER', 'LABEL'),
        LABEL_FONT_PT,
        align_center=False,
    )
    return line1, line2, text_box

def add_north_mark(worksheet, target_range, shape_prefix):
    asset_path = Path(__file__).with_name('north_arrow.png')
    if not asset_path.exists():
        return None

    range_left = float(target_range.Left)
    range_top = float(target_range.Top)
    range_width = float(target_range.Width)

    left = range_left + range_width - NORTH_IMG_WIDTH_PT - 4.0
    top = range_top + 4.0

    picture = worksheet.Shapes.AddPicture(str(asset_path), MSO_FALSE, MSO_TRUE, left, top, NORTH_IMG_WIDTH_PT, NORTH_IMG_HEIGHT_PT)
    try:
        picture.Name = shape_prefix + '_IMG'
    except Exception:
        pass
    try:
        picture.Placement = XL_MOVE_AND_SIZE
    except Exception:
        pass
    bring_to_front(picture)
    return picture

def main():
    folder_text = choose_folder()
    if not folder_text:
        return

    folder = Path(folder_text)
    all_excels = list_excel_files(folder)

    work_items = []
    ignored = []

    for excel_file in all_excels:
        candidate_keys = get_candidate_image_keys(excel_file)
        image_200k, key_200k, match_200k = find_matching_image(folder, candidate_keys, SUFFIX_200K)
        image_25k, key_25k, match_25k = find_matching_image(folder, candidate_keys, SUFFIX_25K)
        if image_200k is None or image_25k is None:
            ignored.append(f'{excel_file.name} (専用画像が2枚そろっていない/候補番号: {", ".join(candidate_keys)})')
            continue
        work_items.append({
            'excel': excel_file,
            'primary_key': get_excel_key(excel_file),
            'candidate_keys': candidate_keys,
            '200k': image_200k,
            '25k': image_25k,
            'match_200k': (key_200k, match_200k),
            'match_25k': (key_25k, match_25k),
        })

    if not work_items:
        show_message('対象なし', '対応画像が2枚そろった区域調書Excelがありません。', True)
        return

    try:
        import pythoncom
        import win32com.client
    except ImportError:
        show_message('pywin32が必要です', 'python -m pip install pywin32 を実行してください。', True)
        return

    pythoncom.CoInitialize()
    excel = None
    workbook = None
    try:
        excel = win32com.client.DispatchEx('Excel.Application')
        excel.Visible = False
        excel.DisplayAlerts = False
        excel.ScreenUpdating = False
        excel.EnableEvents = False
        excel.AskToUpdateLinks = False

        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        backup_root = folder / '_急傾斜区域調書最終仕上げバックアップ_v3'
        backup_folder = backup_root / f'_{timestamp}'
        backup_root.mkdir(exist_ok=True)
        backup_folder.mkdir(exist_ok=True)

        completed = 0
        errors = 0
        logs = []

        for item in work_items:
            workbook = None
            excel_file = item['excel']
            try:
                shutil.copy2(excel_file, backup_folder / excel_file.name)
                workbook = excel.Workbooks.Open(str(excel_file), UpdateLinks=0, ReadOnly=False, IgnoreReadOnlyRecommended=True)
                try:
                    workbook.Activate()
                except Exception:
                    pass
                cover = get_cover_sheet(workbook)
                try:
                    cover.Activate()
                except Exception:
                    pass

                place_name = get_place_name(cover, item['primary_key'])
                range_200k = cover.Range(RANGE_200K)
                range_25k = cover.Range(RANGE_25K)
                deleted = delete_old_map_objects(cover, [range_200k, range_25k], ['調査位置', place_name, 'N'])

                add_picture_to_range(cover, item['200k'], range_200k, 'AUTO_MAP_CHOSHO_' + TYPE_LABEL + '_200K')
                add_center_circle(cover, range_200k, 'AUTO_CIRCLE_CHOSHO_' + TYPE_LABEL + '_200K')
                add_picture_to_range(cover, item['25k'], range_25k, 'AUTO_MAP_CHOSHO_' + TYPE_LABEL + '_25K')
                add_center_circle(cover, range_25k, 'AUTO_CIRCLE_CHOSHO_' + TYPE_LABEL + '_25K')

                add_leader_annotation(cover, range_200k, '調査位置', 'left', 'AUTO_LEADER_CHOSHO_' + TYPE_LABEL + '_200K')
                add_leader_annotation(cover, range_25k, place_name, 'right', 'AUTO_LEADER_CHOSHO_' + TYPE_LABEL + '_25K')
                add_north_mark(cover, range_200k, 'AUTO_NORTH_CHOSHO_' + TYPE_LABEL + '_200K')
                add_north_mark(cover, range_25k, 'AUTO_NORTH_CHOSHO_' + TYPE_LABEL + '_25K')

                try:
                    workbook.Application.Calculate()
                except Exception:
                    pass
                workbook.Save()
                workbook.Close(SaveChanges=False)
                workbook = None
                completed += 1
                logs.append(
                    f"[完了] {excel_file.name} - 箇所名:{place_name} / 20万図:{item['200k'].name}({item['match_200k'][1]}) / 2.5万図:{item['25k'].name}({item['match_25k'][1]}) / 旧図形削除:{len(deleted)}個"
                )
            except Exception as exc:
                errors += 1
                logs.append(f'[エラー] {excel_file.name} - {type(exc).__name__}: {exc}')
                if workbook is not None:
                    try:
                        workbook.Close(SaveChanges=False)
                    except Exception:
                        pass
                    workbook = None

        log_path = folder / f"区域調書_急傾斜_最終仕上げ結果_v3_{timestamp}.txt"
        header = [
            f'区域調書 {TYPE_LABEL}用 最終仕上げ結果 v3',
            '処理日時: ' + datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            f'対象フォルダ: {folder}',
            f'処理対象: {len(work_items)}件',
            f'完了: {completed}件',
            f'エラー: {errors}件',
            f'未処理: {len(ignored)}件',
            '',
            '処理内容:',
            '  地図画像2枚',
            '  中央の赤円',
            '  左右とも統一した引き出し線',
            '  大きめの方位記号画像',
            '',
            f'20万図: {RANGE_200K}',
            f'2.5万図: {RANGE_25K}',
            '',
            f'バックアップ: {backup_folder}',
            '',
        ]
        if ignored:
            header.append('未処理Excel:')
            header.extend('  ' + item for item in ignored)
            header.append('')
        log_path.write_text('\n'.join(header + logs), encoding='utf-8-sig')

        show_message(
            '完了',
            f'{TYPE_LABEL}の区域調書を最終仕上げしました。\n\n処理対象: {len(work_items)}件\n完了: {completed}件\nエラー: {errors}件\n未処理: {len(ignored)}件\n\nログ:\n{log_path}'
        )
    except Exception:
        show_message('処理エラー', traceback.format_exc(), True)
    finally:
        if workbook is not None:
            try:
                workbook.Close(SaveChanges=False)
            except Exception:
                pass
        if excel is not None:
            try:
                excel.EnableEvents = True
                excel.ScreenUpdating = True
                excel.DisplayAlerts = True
                excel.Quit()
            except Exception:
                pass
        try:
            pythoncom.CoUninitialize()
        except Exception:
            pass

if __name__ == '__main__':
    main()

11.実行後に確認する項目

  • 20万図と2.5万図が正しい場所へ入っているか
  • 画像が罫線を隠していないか
  • 赤円の中心が対象ポイントと合っているか
  • 左側に「調査位置」が表示されているか
  • 右側に正しい箇所名または渓流名が表示されているか
  • 引き出し線が赤円に接しているか
  • 方位記号が左右の地図右上に入っているか
  • 旧番号の画像が別のExcelへ誤って貼られていないか
  • 印刷プレビューで表紙全体が正しく収まっているか
Excelの列幅、行高、元帳票の図形構成が異なる場合は、 画像の余白、引き出し線の角度、文字位置、方位記号の大きさを コード内の数値で微調整します。

12.まとめ

今回の作業フロー

区域調書Excelを土石流と急傾斜に分ける

QGISで専用比率の20万図・2.5万図を一括出力する

ExcelとPNGを同じフォルダへ入れる

BATを実行する

画像、赤円、引き出し線、名称、方位記号まで一括作成

土石流と急傾斜で異なる帳票サイズに対応し、 旧番号の画像も照合できるため、多数の区域調書表紙をまとめて処理できます。

※国土地理院データの利用条件、出典表記、測量法に基づく承認の要否については、 成果品の用途と発注者の指示に従って確認してください。
※本記事のコードは、使用するExcel帳票の様式やQGISのプロジェクト設定に合わせて 調整が必要になる場合があります。

コメント

このブログの人気の投稿

IJCAD(AutoCAD互換CAD) コマンド早見表

設計定数を求めるための代表N値について

WEB地図(国土地理院など)のズームレベルについて