岐阜県の公示図書様式-1(A3)広域図と詳細図を作成

岐阜県の公示図書「様式-1(A3)」をQGISとPythonで自動作成する

岐阜県の土砂災害防止法に関する公示図書では、様式-1(A3)に広域図と詳細図を掲載します。 左側は縮尺1:200,000、右側は縮尺1:25,000です。

今回、以前作成した公示図書作成ツールでExcel帳票を出力し、QGISで地点ごとの位置図を一括作成した後、 Pythonで各Excelへ地図画像を自動貼り付けする流れを作成しました。

全体の作業手順

  1. 公示図書作成ツールで、地点ごとのExcelを作成する
  2. 作成されたExcelを1つのフォルダへ集める
  3. QGISで国土地理院ベクトルタイルを背景に設定する
  4. 対象箇所のポイントデータを準備する
  5. ポイントデータの「箇所番」フィールドへ箇所番号を入力する
  6. QGISのPythonコンソールで画像切り出しコードを実行する
  7. 出力された20万図と2.5万図を、Excelと同じフォルダへ入れる
  8. Excel画像貼付用のBATを実行する
  9. 全Excelへ位置図、赤円、基本入力B12の文字を一括反映する

1.公示図書作成ツールでExcelを作成する

最初に、以前作成した公示図書作成ツールを使用し、地点ごとのExcelを作成します。 作成されたExcelを、今回の一括処理用フォルダへ集めます。

GEN0259001_その1.xlsx
KEN0259003_その1.xlsx
KEN0259004_その1.xlsx
G54031K_その1.xlsx

画像との照合には、Excelファイル名の最初のアンダーバーより左側を使用します。

GEN0259001_その1.xlsx → GEN0259001
KEN0259004_その1.xlsx → KEN0259004

2.QGISで国土地理院ベクトルタイルを設定する

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

項目 設定
名前 国土地理院ベクター
URL https://cyberjapandata.gsi.go.jp/xyz/experimental_bvmap/{z}/{x}/{y}.pbf
最小ズーム 4
最大ズーム 16

地図の線、注記、色などを確認し、不要なレイヤは非表示にしておきます。 画像切り出しコードは、QGISの地図画面で表示中の背景レイヤをそのまま使用します。

縮尺計算はメートル単位の座標系を前提とします。 恵那市・中津川市周辺では、プロジェクト座標系を EPSG:6675(JGD2011 / 平面直角座標系第VII系) に設定しました。

3.対象箇所のポイントデータを準備する

地図の中心とするポイントデータを作成します。 今回のコードでは、ポイントレイヤ名を 表紙作成、 ファイル名に使用するフィールド名を 箇所番 としています。

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

各ポイントの 箇所番 フィールドへ、Excelと対応する箇所番号を入力します。

GEN0259001
KEN0259003
KEN0259004
G54031K

ポイントの位置が、そのまま左右の地図画像の中心になります。 渓流出口、対象区域の中心など、業務で決めた基準位置へポイントを置きます。

4.QGISで20万図と2.5万図を一括出力する

QGISのメニューから「プラグイン」→「Pythonコンソール」を開きます。 コンソール上部の「エディタを表示」を押し、下記コードを貼り付けて実行します。

コードを実行すると出力先フォルダを選択する画面が表示されます。 各ポイントについて、次の2枚が出力されます。

GEN0259001_200k.png
GEN0259001_25k.png
画像 縮尺 用途
_200k.png 1:200,000 様式-1(A3)の左側
_25k.png 1:25,000 様式-1(A3)の右側
QGIS:地図画像一括出力コード
import os
import re

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


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

# 出力地点のポイントレイヤ名
POINT_LAYER_NAME = "表紙作成"

# ファイル名に使用するフィールド
NAME_FIELD = "箇所番"

# Excelの地図枠に合わせた出力実寸
PAGE_W_MM = 191.2
PAGE_H_MM = 215.4

# 出力解像度
DPI = 300

# 出力する縮尺
SCALES = {
    "200k": 200000,
    "25k": 25000
}


# =========================================
# 出力フォルダを選択
# =========================================

OUT_DIR = QFileDialog.getExistingDirectory(
    None,
    "地図画像の出力先フォルダを選択"
)

if not OUT_DIR:
    raise Exception("出力先フォルダが選択されませんでした。")

os.makedirs(OUT_DIR, exist_ok=True)


# =========================================
# プロジェクトとポイントレイヤを取得
# =========================================

project = QgsProject.instance()

point_layers = project.mapLayersByName(POINT_LAYER_NAME)

if not point_layers:
    raise Exception(
        f"ポイントレイヤ「{POINT_LAYER_NAME}」が見つかりません。"
    )

point_layer = point_layers[0]

if not point_layer.isValid():
    raise Exception(
        f"ポイントレイヤ「{POINT_LAYER_NAME}」が無効です。"
    )

if NAME_FIELD not in point_layer.fields().names():
    raise Exception(
        f"フィールド「{NAME_FIELD}」が見つかりません。"
    )

if point_layer.wkbType() == QgsWkbTypes.NoGeometry:
    raise Exception("ポイントレイヤに位置情報がありません。")


# =========================================
# 表示中の背景レイヤを取得
# ポイントレイヤ自体は画像へ出さない
# =========================================

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

if not visible_layers:
    raise Exception(
        "背景として出力する表示中レイヤがありません。"
    )

print("画像に出力するレイヤ")

for layer in visible_layers:
    print("  ・" + layer.name())


# =========================================
# 座標系の確認と変換
# =========================================

project_crs = project.crs()
layer_crs = point_layer.crs()

# 縮尺計算はメートル単位の座標系を前提とする
if project_crs.isGeographic():
    raise Exception(
        "プロジェクト座標系が緯度経度です。"
        "JGD2011の平面直角座標系など、"
        "メートル単位の座標系に変更してください。"
    )

coordinate_transform = None

if layer_crs != project_crs:
    coordinate_transform = QgsCoordinateTransform(
        layer_crs,
        project_crs,
        project
    )


# =========================================
# ファイル名を整える
# =========================================

def safe_filename(value):

    if value is None:
        return "no_name"

    text = str(value).strip()

    text = re.sub(
        r'[\\/:*?"<>|]+',
        "_",
        text
    )

    return text if text else "no_name"


# =========================================
# 中心座標と縮尺から表示範囲を作る
# =========================================

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():

    layout = QgsLayout(project)
    layout.initializeDefaults()

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

    page.setPageSize(
        QgsLayoutSize(
            PAGE_W_MM,
            PAGE_H_MM,
            QgsUnitTypes.LayoutMillimeters
        )
    )

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

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

    map_item.attemptResize(
        QgsLayoutSize(
            PAGE_W_MM,
            PAGE_H_MM,
            QgsUnitTypes.LayoutMillimeters
        )
    )

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

    return layout, map_item


# =========================================
# ポイントごとに画像を出力
# =========================================

success_count = 0
error_count = 0
skip_count = 0

features = list(point_layer.getFeatures())

print("")
print(f"対象ポイント数:{len(features)}")
print("出力開始")
print("")


for feature in features:

    geometry = feature.geometry()

    if geometry is None or geometry.isEmpty():

        print(
            f"[スキップ] 位置情報なし "
            f"Feature ID={feature.id()}"
        )

        skip_count += 1
        continue

    file_name = safe_filename(
        feature[NAME_FIELD]
    )

    # ポイント位置を取得
    if geometry.type() == QgsWkbTypes.PointGeometry:

        if geometry.isMultipart():

            points = geometry.asMultiPoint()

            if not points:
                print(
                    f"[スキップ] ポイント取得不可:"
                    f"{file_name}"
                )

                skip_count += 1
                continue

            point = points[0]

        else:
            point = geometry.asPoint()

    else:

        centroid = geometry.centroid()

        if centroid.isEmpty():

            print(
                f"[スキップ] 中心点取得不可:"
                f"{file_name}"
            )

            skip_count += 1
            continue

        point = centroid.asPoint()

    if coordinate_transform:
        point = coordinate_transform.transform(point)

    center_x = point.x()
    center_y = point.y()

    # 20万図と2.5万図を出力
    for suffix, scale in SCALES.items():

        layout, map_item = create_layout()

        extent = extent_from_center(
            center_x,
            center_y,
            scale,
            PAGE_W_MM,
            PAGE_H_MM
        )

        map_item.setExtent(extent)
        map_item.setScale(scale)
        map_item.refresh()

        QgsApplication.processEvents()

        output_path = os.path.join(
            OUT_DIR,
            f"{file_name}_{suffix}.png"
        )

        export_settings = (
            QgsLayoutExporter.ImageExportSettings()
        )

        export_settings.dpi = DPI
        export_settings.cropToContents = False

        exporter = QgsLayoutExporter(layout)

        result = exporter.exportToImage(
            output_path,
            export_settings
        )

        if result == QgsLayoutExporter.Success:

            print(
                f"[完了] {file_name}_{suffix}.png"
            )

            success_count += 1

        else:

            print(
                f"[失敗] {file_name}_{suffix}.png "
                f"結果コード={result}"
            )

            error_count += 1


# =========================================
# 結果
# =========================================

print("")
print("==================")
print("地図画像の出力が完了しました")
print("==================")
print(f"成功:{success_count}枚")
print(f"失敗:{error_count}枚")
print(f"スキップ:{skip_count}箇所")
print(f"出力先:{OUT_DIR}")

5.Excelと地図画像を1つのフォルダへ集める

QGISで出力したPNGを、公示図書作成ツールで作成したExcelと同じフォルダへ入れます。 Excelはすべて閉じておきます。

作業フォルダ
├─ GEN0259001_その1.xlsx
├─ GEN0259001_200k.png
├─ GEN0259001_25k.png
├─ KEN0259004_その1.xlsx
├─ KEN02590040101_200k.png
├─ KEN02590040101_25k.png
├─ Excel地図画像一括貼付_右画像トリミング版.py
└─ Excel地図画像一括貼付_右画像トリミング版_実行.bat

画像名に枝番号が付く場合

Excel名と画像名が完全一致しなくても、画像名がExcelの箇所番号で始まっていれば対応します。

Excel
KEN0259004_その1.xlsx

画像
KEN02590040101_200k.png
KEN02590040101_25k.png

この場合、Excel側の照合キーは KEN0259004 です。 画像名の先頭も KEN0259004 で始まるため、同じ箇所として貼り付けます。

6.Excel画像貼付用コードを実行する

PythonファイルとBATファイルを同じフォルダへ置き、 Excel地図画像一括貼付_右画像トリミング版_実行.bat をダブルクリックします。

実行後は次の順に操作します。

  1. ExcelとPNGが入ったフォルダを選択する
  2. 全Excelの「基本入力」シートB12へ入れる共通文字を入力する
  3. 実行ボタンを押す

Excelは閉じた状態で問題ありません。 Pythonが各Excelをバックグラウンドで開き、編集、保存、終了まで行います。

自動で行われる処理

処理 内容
左側の画像 様式-1(A3)のB3:C34へ20万図を貼付
右側の画像 様式-1(A3)のG3:I34へ2.5万図を貼付し、左側を微調整してトリミング
既存図形 貼付範囲に重なる古い画像や赤円を先に削除
赤円 左右の地図枠中央へ、直径3cm、赤色、線幅2.25ptの円を追加
基本入力B12 実行時に入力した文字を、全Excelへ反映
バックアップ 処理前のExcelを日時付きバックアップフォルダへコピー
Excel:画像一括貼付Pythonコード
# -*- coding: utf-8 -*-
"""
Excel地図画像一括貼付 右画像トリミング版

処理内容
・フォルダを選択
・全Excelの「基本入力」B12へ共通文字を入力
・Excel名の最初の「_」より左側を照合キーにする
・完全一致、または画像名が照合キーで始まるPNGを使用
・様式-1(A3)!B3:C34へ20万図
・様式-1(A3)!G3:I34へ2.5万図
・貼付範囲に重なる既存図形をすべて削除してから貼付
・地図中央へ直径3cm、赤線2.25ptの円を追加
・処理前のExcelをバックアップ
"""

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


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

FORM_SHEET = "様式-1(A3)"
INPUT_SHEET = "基本入力"
INPUT_CELL = "B12"

LEFT_RANGE = "B3:C34"
RIGHT_RANGE = "G3:I34"

LEFT_KIND = "200k"
RIGHT_KIND = "25k"

# 完成形の画像サイズ
LEFT_WIDTH_CM = 19.09
LEFT_HEIGHT_CM = 21.51

RIGHT_WIDTH_CM = 19.24
RIGHT_HEIGHT_CM = 21.51

# 右側画像だけに適用する手動調整値
# Excelの記録マクロをそのまま反映
RIGHT_INCREMENT_LEFT_PT = 6.0678740157
RIGHT_SCALE_WIDTH = 0.9888740182
RIGHT_CROP_PICTURE_WIDTH = 545.0
RIGHT_CROP_PICTURE_HEIGHT = 609.0
RIGHT_CROP_OFFSET_X = -3.0
RIGHT_CROP_OFFSET_Y = 0.0

# 赤円
CIRCLE_CM = 3.0
CIRCLE_LINE_PT = 2.25

# Office定数
MSO_FALSE = 0
MSO_TRUE = -1
MSO_SHAPE_OVAL = 9
MSO_BRING_TO_FRONT = 0
MSO_SCALE_FROM_TOP_LEFT = 0
XL_MOVE_AND_SIZE = 1


# ============================================================
# GUI
# ============================================================

def choose_folder():
    root = tk.Tk()
    root.withdraw()
    root.attributes("-topmost", True)

    folder = filedialog.askdirectory(
        title="ExcelとPNGが入っているフォルダを選択"
    )

    root.destroy()
    return folder


def ask_common_text(default_text=""):
    result = {"value": None}

    win = tk.Tk()
    win.title("基本入力 B12")
    win.geometry("760x310")
    win.minsize(620, 260)
    win.attributes("-topmost", True)

    tk.Label(
        win,
        text="全Excelの「基本入力」B12へ入れる文字を入力してください。",
        anchor="w",
        justify="left"
    ).pack(fill="x", padx=12, pady=(12, 6))

    text_box = tk.Text(win, wrap="word", height=10)
    text_box.pack(fill="both", expand=True, padx=12, pady=4)
    text_box.insert("1.0", default_text or "")
    text_box.focus_set()
    text_box.tag_add("sel", "1.0", "end-1c")

    button_frame = tk.Frame(win)
    button_frame.pack(fill="x", padx=12, pady=12)

    def on_ok():
        value = text_box.get("1.0", "end-1c")

        if value == "":
            answer = messagebox.askyesno(
                "確認",
                "B12を空欄にして処理しますか?",
                parent=win
            )

            if not answer:
                return

        result["value"] = value
        win.destroy()

    def on_cancel():
        result["value"] = None
        win.destroy()

    tk.Button(
        button_frame,
        text="実行",
        width=12,
        command=on_ok
    ).pack(side="right", padx=(6, 0))

    tk.Button(
        button_frame,
        text="キャンセル",
        width=12,
        command=on_cancel
    ).pack(side="right")

    win.protocol("WM_DELETE_WINDOW", on_cancel)
    win.bind("<Control-Return>", lambda event: on_ok())

    win.mainloop()
    return result["value"]


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 get_excel_key(excel_path):
    """
    KEN0259004_その1.xlsx → KEN0259004
    """
    return excel_path.stem.split("_", 1)[0].strip()


def parse_png_name(png_path):
    """
    KEN02590040101_200k.png
      → ("KEN02590040101", "200k")
    """
    match = re.match(
        r"^(.*)_(200k|25k)$",
        png_path.stem,
        flags=re.IGNORECASE
    )

    if not match:
        return None

    return match.group(1), match.group(2).lower()


def find_matching_png(excel_key, image_kind, png_files):
    """
    優先順位
    1. 完全一致
    2. PNGのベース名がExcelキーで始まる
    """
    key_lower = excel_key.lower()
    kind_lower = image_kind.lower()

    exact = []
    prefix = []

    for png_path in png_files:
        parsed = parse_png_name(png_path)

        if not parsed:
            continue

        base_name, found_kind = parsed

        if found_kind.lower() != kind_lower:
            continue

        base_lower = base_name.lower()

        if base_lower == key_lower:
            exact.append(png_path)

        elif base_lower.startswith(key_lower):
            extra_length = len(base_lower) - len(key_lower)

            prefix.append(
                (
                    extra_length,
                    png_path.name.lower(),
                    png_path
                )
            )

    if exact:
        return sorted(
            exact,
            key=lambda path: path.name.lower()
        )[0]

    if prefix:
        prefix.sort(
            key=lambda item: (item[0], item[1])
        )
        return prefix[0][2]

    return None


# ============================================================
# Excel補助
# ============================================================

def get_sheet(workbook, requested_name):
    candidates = {
        requested_name,
        requested_name.replace("-", "-"),
        requested_name.replace("-", "-"),
    }

    for worksheet in workbook.Worksheets:
        if str(worksheet.Name) in candidates:
            return worksheet

    raise RuntimeError(
        f"シート「{requested_name}」が見つかりません。"
    )


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 rectangles_intersect(
    left1,
    top1,
    width1,
    height1,
    left2,
    top2,
    width2,
    height2
):
    right1 = left1 + width1
    bottom1 = top1 + height1

    right2 = left2 + width2
    bottom2 = top2 + height2

    return not (
        right1 <= left2
        or right2 <= left1
        or bottom1 <= top2
        or bottom2 <= 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 delete_existing_shapes(worksheet, target_ranges):
    """
    対象地図範囲へ少しでも重なる図形をすべて削除する。
    古い画像、古い赤円、グループ化された図形も対象。
    """
    deleted_names = []

    for index in range(worksheet.Shapes.Count, 0, -1):
        shape = worksheet.Shapes.Item(index)

        try:
            should_delete = any(
                shape_intersects_range(shape, target_range)
                for target_range in target_ranges
            )

            if should_delete:
                try:
                    deleted_names.append(str(shape.Name))
                except Exception:
                    deleted_names.append(f"Shape{index}")

                shape.Delete()

        except Exception:
            continue

    return deleted_names


def calculate_centered_position(
    target_range,
    width_cm,
    height_cm
):
    """
    指定サイズをセル範囲の中央へ配置する位置を返す。
    """
    width_pt = cm_to_points(width_cm)
    height_pt = cm_to_points(height_cm)

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

    left = range_left + (range_width - width_pt) / 2.0
    top = range_top + (range_height - height_pt) / 2.0

    return left, top, width_pt, height_pt


def add_picture(
    worksheet,
    image_path,
    target_range,
    width_cm,
    height_cm,
    shape_name
):
    """
    完成形の実寸で、対象範囲の中央へ画像を貼る。
    """
    left, top, width_pt, height_pt = (
        calculate_centered_position(
            target_range,
            width_cm,
            height_cm
        )
    )

    picture = worksheet.Shapes.AddPicture(
        str(image_path),
        False,
        True,
        left,
        top,
        width_pt,
        height_pt
    )

    picture.LockAspectRatio = MSO_FALSE
    picture.Left = left
    picture.Top = top
    picture.Width = width_pt
    picture.Height = height_pt

    try:
        picture.Placement = XL_MOVE_AND_SIZE
    except Exception:
        pass

    try:
        picture.Name = shape_name
    except Exception:
        pass

    return picture


def adjust_right_picture(picture):
    """
    右側の2.5万図だけ、手動操作を記録したExcelマクロと
    同じ値で左側をトリミングする。

    VBA相当:
      IncrementLeft 6.0678740157
      ScaleWidth 0.9888740182, msoFalse, msoScaleFromTopLeft
      Crop.PictureWidth = 545
      Crop.PictureHeight = 609
      Crop.PictureOffsetX = -3
      Crop.PictureOffsetY = 0
    """
    try:
        picture.LockAspectRatio = MSO_FALSE

        picture.IncrementLeft(
            RIGHT_INCREMENT_LEFT_PT
        )

        picture.ScaleWidth(
            RIGHT_SCALE_WIDTH,
            MSO_FALSE,
            MSO_SCALE_FROM_TOP_LEFT
        )

        crop = picture.PictureFormat.Crop
        crop.PictureWidth = RIGHT_CROP_PICTURE_WIDTH
        crop.PictureHeight = RIGHT_CROP_PICTURE_HEIGHT
        crop.PictureOffsetX = RIGHT_CROP_OFFSET_X
        crop.PictureOffsetY = RIGHT_CROP_OFFSET_Y

    except Exception as exc:
        raise RuntimeError(
            f"右画像のトリミング処理に失敗しました: {exc}"
        ) from exc

    return picture


def add_center_circle(
    worksheet,
    target_range,
    shape_name
):
    """
    セル範囲中央へ直径3cmの赤円を追加。
    """
    diameter_pt = 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_pt / 2.0,
        center_y - diameter_pt / 2.0,
        diameter_pt,
        diameter_pt
    )

    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

    try:
        circle.ZOrder(MSO_BRING_TO_FRONT)
    except Exception:
        pass

    return circle


def read_default_b12(excel, excel_files):
    if not excel_files:
        return ""

    workbook = None

    try:
        workbook = excel.Workbooks.Open(
            str(excel_files[0]),
            UpdateLinks=0,
            ReadOnly=True,
            IgnoreReadOnlyRecommended=True
        )

        value = get_sheet(
            workbook,
            INPUT_SHEET
        ).Range(INPUT_CELL).Value

        return "" if value is None else str(value)

    except Exception:
        return ""

    finally:
        if workbook is not None:
            workbook.Close(SaveChanges=False)


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

def main():
    folder_text = choose_folder()

    if not folder_text:
        return

    folder = Path(folder_text)

    excel_files = sorted(
        [
            path
            for extension in ("*.xlsx", "*.xlsm")
            for path in folder.glob(extension)
            if not path.name.startswith("~$")
        ],
        key=lambda path: path.name.lower()
    )

    png_files = sorted(
        [
            path
            for path in folder.glob("*.png")
            if path.is_file()
        ],
        key=lambda path: path.name.lower()
    )

    if not excel_files:
        show_message(
            "対象なし",
            "選択したフォルダにExcelファイルがありません。",
            True
        )
        return

    if not png_files:
        show_message(
            "対象なし",
            "選択したフォルダにPNG画像がありません。",
            True
        )
        return

    try:
        import pythoncom
        import win32com.client

    except ImportError:
        show_message(
            "pywin32が必要です",
            "コマンドプロンプトで次を実行してください。\n\n"
            "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

        default_text = read_default_b12(
            excel,
            excel_files
        )

        common_text = ask_common_text(
            default_text
        )

        if common_text is None:
            return

        timestamp = datetime.now().strftime(
            "%Y%m%d_%H%M%S"
        )

        backup_folder = (
            folder
            / f"_Excelバックアップ_{timestamp}"
        )

        backup_folder.mkdir()

        logs = []
        completed_count = 0
        partial_count = 0
        error_count = 0
        missing_count = 0

        for number, excel_path in enumerate(
            excel_files,
            start=1
        ):
            key = get_excel_key(excel_path)

            left_image = find_matching_png(
                key,
                LEFT_KIND,
                png_files
            )

            right_image = find_matching_png(
                key,
                RIGHT_KIND,
                png_files
            )

            messages = []

            print(
                f"[{number}/{len(excel_files)}] "
                f"{excel_path.name}",
                flush=True
            )

            try:
                shutil.copy2(
                    excel_path,
                    backup_folder / excel_path.name
                )

                workbook = excel.Workbooks.Open(
                    str(excel_path),
                    UpdateLinks=0,
                    ReadOnly=False,
                    IgnoreReadOnlyRecommended=True
                )

                input_sheet = get_sheet(
                    workbook,
                    INPUT_SHEET
                )

                form_sheet = get_sheet(
                    workbook,
                    FORM_SHEET
                )

                input_sheet.Range(
                    INPUT_CELL
                ).Value = common_text

                left_range = form_sheet.Range(
                    LEFT_RANGE
                )

                right_range = form_sheet.Range(
                    RIGHT_RANGE
                )

                deleted = delete_existing_shapes(
                    form_sheet,
                    [left_range, right_range]
                )

                if deleted:
                    messages.append(
                        f"既存図形削除:{len(deleted)}個"
                    )

                left_ok = False
                right_ok = False

                if left_image is not None:
                    add_picture(
                        form_sheet,
                        left_image,
                        left_range,
                        LEFT_WIDTH_CM,
                        LEFT_HEIGHT_CM,
                        "AUTO_MAP_LEFT"
                    )

                    add_center_circle(
                        form_sheet,
                        left_range,
                        "AUTO_CIRCLE_LEFT"
                    )

                    left_ok = True
                    messages.append(
                        f"左:{left_image.name}"
                    )

                else:
                    missing_count += 1
                    messages.append(
                        f"左画像なし:"
                        f"{key}で始まる_{LEFT_KIND}.png"
                    )

                if right_image is not None:
                    right_picture = add_picture(
                        form_sheet,
                        right_image,
                        right_range,
                        RIGHT_WIDTH_CM,
                        RIGHT_HEIGHT_CM,
                        "AUTO_MAP_RIGHT"
                    )

                    # 右側だけ、手動記録マクロと同じトリミングを適用
                    adjust_right_picture(
                        right_picture
                    )

                    add_center_circle(
                        form_sheet,
                        right_range,
                        "AUTO_CIRCLE_RIGHT"
                    )

                    right_ok = True
                    messages.append(
                        f"右:{right_image.name}"
                    )

                else:
                    missing_count += 1
                    messages.append(
                        f"右画像なし:"
                        f"{key}で始まる_{RIGHT_KIND}.png"
                    )

                try:
                    workbook.Application.Calculate()
                except Exception:
                    pass

                workbook.Save()
                workbook.Close(SaveChanges=False)
                workbook = None

                if left_ok and right_ok:
                    completed_count += 1
                    status = "完了"
                else:
                    partial_count += 1
                    status = "一部完了"

                logs.append(
                    f"[{status}] {excel_path.name} - "
                    + " / ".join(messages)
                )

            except Exception as exc:
                error_count += 1

                logs.append(
                    f"[エラー] {excel_path.name} - "
                    f"{type(exc).__name__}: {exc}"
                )

                if workbook is not None:
                    try:
                        workbook.Close(
                            SaveChanges=False
                        )
                    except Exception:
                        pass

                    workbook = None

        log_path = (
            folder
            / f"Excel地図画像貼付_結果_{timestamp}.txt"
        )

        header = [
            "Excel地図画像一括貼付 結果",
            f"処理日時: {datetime.now():%Y-%m-%d %H:%M:%S}",
            f"対象フォルダ: {folder}",
            f"対象Excel: {len(excel_files)}件",
            f"完了: {completed_count}件",
            f"一部完了: {partial_count}件",
            f"エラー: {error_count}件",
            f"不足画像: {missing_count}枚",
            "",
            "画像照合:",
            "  完全一致、または画像名がExcelの箇所番号で始まる場合",
            "",
            "配置とサイズ:",
            (
                f"  {LEFT_RANGE}: _{LEFT_KIND}.png "
                f"幅{LEFT_WIDTH_CM}cm 高さ{LEFT_HEIGHT_CM}cm"
            ),
            (
                f"  {RIGHT_RANGE}: _{RIGHT_KIND}.png "
                f"幅{RIGHT_WIDTH_CM}cm 高さ{RIGHT_HEIGHT_CM}cm"
            ),
            "",
            "既存図形:",
            "  地図範囲へ少しでも重なる図形を削除後に貼付",
            "",
            f"バックアップ: {backup_folder}",
            "",
        ]

        log_path.write_text(
            "\n".join(header + logs),
            encoding="utf-8-sig"
        )

        show_message(
            "完了",
            (
                "処理が終わりました。\n\n"
                f"完了:{completed_count}件\n"
                f"一部完了:{partial_count}件\n"
                f"エラー:{error_count}件\n"
                f"不足画像:{missing_count}枚\n\n"
                f"結果ログ:\n{log_path}\n\n"
                f"バックアップ:\n{backup_folder}"
            )
        )

    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

        pythoncom.CoUninitialize()


if __name__ == "__main__":
    main()
実行用BATコード
@echo off
chcp 65001 >nul
title Excel地図画像一括貼付 右画像トリミング版

cd /d "%~dp0"

python -c "import win32com.client" >nul 2>&1
if errorlevel 1 (
    echo.
    echo Excel操作用の pywin32 が入っていません。
    echo.
    echo 次のコマンドでインストールしてください。
    echo     python -m pip install pywin32
    echo.
    pause
    exit /b 1
)

python "%~dp0Excel地図画像一括貼付_右画像トリミング版.py"

if errorlevel 1 (
    echo.
    echo エラーが発生しました。
)

echo.
pause

7.初回だけ必要な設定

Excel操作には pywin32 を使用します。 未導入の場合は、コマンドプロンプトで次を実行します。

python -m pip install pywin32

一度インストールすれば、次回からはBATをダブルクリックするだけです。

8.完成後に確認する項目

  • 左側に1:200,000、右側に1:25,000の画像が入っているか
  • 各画像の中心が対象ポイントと一致しているか
  • 画像が黒枠からはみ出していないか
  • 右側画像の左端が完成例と同じ位置でトリミングされているか
  • 赤円が左右の地図枠中央にあるか
  • 基本入力シートB12の文字が正しいか
  • 枝番号付き画像が別のExcelへ誤って貼られていないか
  • 印刷プレビューでA3の1ページに収まっているか

まとめ

作業の流れは、次のとおりです。

公示図書作成ツールでExcel作成 → Excelを1フォルダへ集約 → QGISで国土地理院ベクトルタイルを設定 → ポイントデータの「箇所番」を入力 → Pythonで20万図・2.5万図を一括出力 → PNGをExcelと同じフォルダへ格納 → BATを実行 → 様式-1(A3)を一括完成

手作業で地図を1枚ずつ切り出し、Excelへ位置と大きさを合わせて貼り付ける必要がなくなりました。 地点数が多い公示図書ほど、作業時間の短縮と画像の取り違え防止に効果があります。

※国土地理院データの利用条件、出典表記、測量法に基づく承認の要否については、 成果品の用途と発注者の指示に従って確認してください。
※元Excelの列幅、行高、様式が異なる場合は、画像寸法や右側のトリミング値を調整する必要があります。

コメント

このブログの人気の投稿

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

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

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