PythonによるExcelファイルソートスクリプト

## 概要
　このスクリプトは、bashスクリプトの中でPythonを実行し、EXCELファイルを読み込んで先頭列をキーにしてソートする。
　一行目はラベル。セルの中には式も含まれている。コピーされた式の式中の行番号は今あるところの行番号にする。書式も含めてソートする。 

## 注意
　文字色・背景色・罫線についてソートがうまくできなかった。改善法についてご教示ください。

## スクリプト

~~~python
#!/bin/bash

FILE1=$1

# Bashスクリプトの内容
python3 <<EOF
import openpyxl
from openpyxl.utils import get_column_letter
from openpyxl.styles import NamedStyle
from copy import copy
import re
import os

def print_write_progress(row_index, col_index, value):
    """書き込み進行状況を出力"""
    print(f"書き込み中 - 行: {row_index}, 列: {col_index}, 値: {value}")

def copy_cell_styles(source_cell, target_cell):
    """セルのスタイルをコピー"""
    if source_cell.has_style:
        target_cell.font = copy(source_cell.font)
        target_cell.border = copy(source_cell.border)
        target_cell.fill = copy(source_cell.fill)
        target_cell.number_format = source_cell.number_format
        target_cell.protection = copy(source_cell.protection)
        target_cell.alignment = copy(source_cell.alignment)

    else:
        target_cell.font = openpyxl.styles.Font()
        target_cell.border = openpyxl.styles.Border()
        target_cell.fill = openpyxl.styles.PatternFill()
        target_cell.number_format = 'General'
        target_cell.protection = openpyxl.styles.Protection()
        target_cell.alignment = openpyxl.styles.Alignment()

def update_formula(formula, row_shift):
    """式中の行番号を更新する"""
    if not isinstance(formula, str) or not formula.startswith('='):
        return formula

    # 行番号を正規表現で抽出して更新
    def replace_row(match):
        col = match.group(1)
        old_row = int(match.group(2))
        new_row = old_row + row_shift
        return f"{col}{new_row}"

    pattern = r'([A-Z]+)(\d+)'
    new_formula = re.sub(pattern, replace_row, formula)
    return new_formula

def sort_excel(excel_file):
    try:
        # Excelファイルを開く
        _, file_extension = os.path.splitext(excel_file)
        keep_vba = file_extension.lower() == '.xlsm'

        wb = openpyxl.load_workbook(excel_file, keep_vba=keep_vba)
        ws = wb.active

        # データとスタイルを取得
        data = []
        for row_index, row in enumerate(ws.iter_rows(min_row=2, values_only=False), start=2):  # ヘッダーを除く
            row_values = [cell.value for cell in row]
            row_styles = [cell for cell in row]  # スタイルを保持
            data.append((row_index, row_values[0], row_values, row_styles))

        # ソート
        data.sort(key=lambda x: (x[1] is None, str(x[1]) if x[1] is not None else ''))

        # 書き込み
        for new_row_idx, (old_row_idx, _, row_values, row_styles) in enumerate(data, start=2):
            row_shift = new_row_idx - old_row_idx  # 正しいシフト量を計算
            for col_idx, (value, style) in enumerate(zip(row_values, row_styles), start=1):
                target_cell = ws.cell(row=new_row_idx, column=col_idx)
                copy_cell_styles(style, target_cell)

                # 式の場合、行番号を更新
                if isinstance(value, str) and value.startswith('='):
                    value = update_formula(value, row_shift)

                target_cell.value = value

                if col_idx == 1:
                    print_write_progress(new_row_idx, col_idx, target_cell.value)

        # ファイルを保存
        wb.save(excel_file)
        print(f"ファイルをソートしました: {excel_file}")

    except FileNotFoundError:
        print(f"ファイルが見つかりません: {excel_file}")
    except Exception as e:
        print(f"エラーが発生しました: {e}")

if __name__ == "__main__":
    excel_file = "${FILE1}"

    # 関数を呼び出して更新処理を実行
    sort_excel(excel_file)

EOF
~~~

## 解説

### Excelファイルの読み込み

~~~python
        # Excelファイルを開く
        _, file_extension = os.path.splitext(excel_file)
        keep_vba = file_extension.lower() == '.xlsm'

        wb = openpyxl.load_workbook(excel_file, keep_vba=keep_vba)
        ws = wb.active
~~~

　Excelファイルのワークブック全体を読み込む。
　keep_vbaはExcelファイルのVBA（Visual Basic for Applications）マクロを保持するためのオプション。

### データの抽出

~~~python
        # データとスタイルを取得
        data = []
        for row_index, row in enumerate(ws.iter_rows(min_row=2, values_only=False), start=2):  # ヘッダーを除く
            row_values = [cell.value for cell in row]
            row_styles = [cell for cell in row]  # スタイルを保持
            data.append((row_index, row_values[0], row_values, row_styles))
~~~

・データの初期化:　dataという空のリストを作成。
・行のイテレーション:
　ws.iter_rows(min_row=2, values_only=False) を使用して、2行目からシートの最後の行までをイテレートする。min_row=2 はヘッダー行（1行目）をスキップするためのもの。values_only=False は、セルの値だけでなくセル自体（スタイルを含む）を取得するためのオプション。
　enumerate 関数で各行のインデックス（row_index）を取得し、start=2 によりインデックスが2から始まるようにする。
・各行のデータとスタイルの抽出:
　row_values = [cell.value for cell in row]: 各セルの値をリストにする。
　row_styles = [cell for cell in row]: 各セルのオブジェクト自体（スタイル情報を含む）をリストにする。これによりスタイル情報を保持する。
・データのリストへの追加:
　data.append((row_index, row_values[0], row_values, row_styles)): 各行のデータをタプルとしてdataリストに追加する。このタプルには以下の情報が含まれる：
　　row_index: 元の行番号
　　row_values[0]: ソートのキーとなる最初のセルの値。これによりソートが可能になる。
　　row_values: 行全体の値のリスト
　　row_styles: 行全体のセルのスタイル情報のリスト

### ソート

~~~python
        # ソート
        data.sort(key=lambda x: (x[1] is None, str(x[1]) if x[1] is not None else ''))
~~~

　dataリスト内の要素を、各タプルの2番目の要素（x[1]）を基準にしてソート。リストを直接変更し、元のリストを新しいソートされたリストに置き換える。

(x[1] is None, str(x[1]) if x[1] is not None else ''): この部分がソートのキー。
　x[1] is None: 最初のソート基準。x[1]がNoneかどうかをチェック。Noneは他のどんな値よりも「大きい」として扱うことで、Noneを含む項目はリストの最後に移動する。
　str(x[1]) if x[1] is not None else '': x[1]がNoneでない場合、文字列に変換し、Noneの場合は空文字列を返す。これにより、None以外の値は文字列として比較される。

### 書き込み

~~~python
        # 書き込み
        for new_row_idx, (old_row_idx, _, row_values, row_styles) in enumerate(data, start=2):
            row_shift = new_row_idx - old_row_idx  # 正しいシフト量を計算
            for col_idx, (value, style) in enumerate(zip(row_values, row_styles), start=1):
                target_cell = ws.cell(row=new_row_idx, column=col_idx)
                copy_cell_styles(style, target_cell)

                # 式の場合、行番号を更新
                if isinstance(value, str) and value.startswith('='):
                    value = update_formula(value, row_shift)

                target_cell.value = value
~~~

・enumerate(data, start=2)を使って、dataリストをループ。start=2は、Excelの行番号が1から始まるため、インデックスを2から始める。
・row_shift = new_row_idx - old_row_idx　：ソート前後の行番号の差を計算。
・for col_idx, (value, style) in enumerate(zip(row_values, row_styles), start=1):　列ごとの処理。
　zip(row_values, row_styles)を使って、各行の値とスタイルを同時にループする。col_idxは列番号で、1から始まる。
・target_cell = ws.cell(row=new_row_idx, column=col_idx)　：ソート後の位置に新しいセルを指定。
・copy_cell_styles(style, target_cell)　：元のセルのスタイル（フォント、罫線、塗りつぶしなど）を新しいセルにコピー。
・if isinstance(value, str) and value.startswith('='):
    value = update_formula(value, row_shift)
　値が文字列で、かつ=で始まる場合（つまり、セルに式がある場合）、update_formula関数を使って式中の行番号を更新する。row_shiftを使って、式が指す行番号を新しい位置に合わせる。
・target_cell.value = value　：値（更新された場合も含めて）をセルに設定。