岐阜県の区域調書表紙をQGISとPythonで自動作成する
岐阜県の区域調書表紙をQGISとPythonで自動作成する
― 土石流・急傾斜に対応 ―
地点数が多い場合、地図の切り出し、Excelへの貼り付け、赤円、引き出し線、 箇所名、方位記号の配置を1件ずつ行うと、多くの時間がかかります。
そこで、QGISで地点ごとの地図画像を一括出力し、PythonからExcelを操作して、 表紙をまとめて仕上げるツールを作成しました。
今回のツールでは、次の処理を自動化しています。
QGIS画像切り出し 土石流・急傾斜を分離 Excel画像貼付 赤円 引き出し線 箇所名 方位記号 旧番号対応 バックアップ
1.土石流と急傾斜を分ける理由
区域調書の表紙は、土石流と急傾斜で地図枠の大きさと縦横比が異なります。 公示図書用の画像や、別形式用に切り出した画像をそのまま流用すると、 上下または左右が不足したり、地図が引き伸ばされたりします。
| 種類 | 20万図のExcel範囲 | 2.5万図のExcel範囲 |
|---|---|---|
| 土石流 | B13:D28 |
F5:M28 |
| 急傾斜 | B13:O22 |
Q7:AB22 |
そのため、QGISの切り出しとExcelへの貼り付けを、土石流用と急傾斜用に分けました。
ファイル名にも doseki と kyukei を付け、
異なる画像を誤って貼り付けないようにしています。
2.完成までの全体手順
- 区域調書Excelを土石流用と急傾斜用のフォルダに分ける
- QGISへ国土地理院ベクトルタイルを登録する
- 対象箇所のポイントデータを準備する
- ポイントレイヤの
箇所番フィールドへ箇所番号を入力する - 土石流用または急傾斜用のQGISコードで画像を切り出す
- 切り出したPNGを対応するExcelフォルダへ保存する
- Excelを閉じた状態で、最終仕上げ用BATを実行する
- 画像、赤円、引き出し線、名称、方位記号を確認する
3.作業フォルダを分ける
土石流と急傾斜の区域調書Excelを別フォルダへ分けます。 QGISの画像出力先も、それぞれ対応するフォルダを指定します。
4.QGISで背景地図を準備する
背景には国土地理院のベクトルタイルを使用しました。 QGISのブラウザパネルで「ベクタタイル」を右クリックし、 新しい一般接続を追加します。
| 項目 | 設定内容 |
|---|---|
| 名称 | 国土地理院ベクトル |
| URL | https://cyberjapandata.gsi.go.jp/xyz/experimental_bvmap/{z}/{x}/{y}.pbf |
プロジェクト座標系
縮尺と実寸から表示範囲を計算するため、プロジェクト座標系はメートル単位にします。 恵那市・中津川市周辺では、JGD2011の平面直角座標系第VII系を使用しました。
EPSG:6675
JGD2011 / Japan Plane Rectangular CS VII
5.ポイントデータを準備する
地図の中心となるポイントレイヤを用意します。 今回のコードでは次の名称を使用しています。
| 項目 | 設定 |
|---|---|
| ポイントレイヤ名 | 表紙作成 |
| 箇所番号フィールド | 箇所番 |
各ポイントの箇所番へ、画像ファイル名に使う番号を入力します。
現在の番号だけでなく、数字から始まる旧番号にも対応しています。
G54031K
GEN0259001
KEN02590030101
2135720101
出力対象の選び方
- ポイントが選択されている場合:選択ポイントだけをすべて出力
- 選択されていない場合:レイヤ内の全ポイントをすべて出力
番号の先頭がKEN、G、GEN、数字のいずれであっても、
接頭辞では除外しません。
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"
)
)
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を同じフォルダへ入れる
土石流の例
急傾斜の例
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
最終仕上げコードでは、次の方法で画像番号の候補を探します。
- 現在のExcelファイル名から取得した番号
- コード内の旧番号対応表
- 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へ誤って貼られていないか
- 印刷プレビューで表紙全体が正しく収まっているか
12.まとめ
区域調書Excelを土石流と急傾斜に分ける
↓
QGISで専用比率の20万図・2.5万図を一括出力する
↓
ExcelとPNGを同じフォルダへ入れる
↓
BATを実行する
↓
画像、赤円、引き出し線、名称、方位記号まで一括作成
土石流と急傾斜で異なる帳票サイズに対応し、 旧番号の画像も照合できるため、多数の区域調書表紙をまとめて処理できます。
※国土地理院データの利用条件、出典表記、測量法に基づく承認の要否については、
成果品の用途と発注者の指示に従って確認してください。
※本記事のコードは、使用するExcel帳票の様式やQGISのプロジェクト設定に合わせて
調整が必要になる場合があります。
コメント
コメントを投稿