Windowsのファイル・ディレクトリ名の規則と制限事項に則って文字列を置き換える

Windowsのファイル名・ディレクトリ名にユーザーが入力した文字列を設定する場合、使用できない文字が含まれていると新しいファイル・ディレクトリ名として登録ができません。
使用できないファイル名だった場合、エラーにならない文字列に置き換える方法を模索してみました。

## 方針

- ShiftJISでシステムを利用している人にも文字化けしない文字を使用する
- 連続した空白文字などはスペース1文字にまとめる
- 使用禁止文字は全角に変換する

(2021.09.02追記)
どうやら、Linux上で文字コードを変換しても文字コードのマッピングで問題の起きる文字が存在するみたいです。
（参考）
https://qiita.com/motoki1990/items/fd7473f4d1e28c6a3ed6

## 仕様理解

公式資料引用
https://docs.microsoft.com/ja-jp/windows/win32/fileio/naming-a-file



> ピリオドを使用して、ベースファイル名をディレクトリまたはファイルの名前で拡張機能から分離します。
> \パス の コンポーネント を区切るには、円記号 () を使用します。 円記号は、ファイル名をパスから分割し、1つのディレクトリ名をパス内の別のディレクトリ名から分割します。 実際のファイルまたはディレクトリの名前に円記号を使用することはできません。これは、名前をコンポーネントに分割する予約文字であるためです。
> ボリューム名の一部として、必要に応じて円記号を使用し \ \ \ \ \ \ ます。たとえば、 \ \ \ \ \ 汎用名前付け規則 (UNC) 名の場合は、"c: path ファイル" の "c:"、"サーバー共有パスファイル" の "サーバー共有" などです。 UNC 名の詳細については、「 パスの最大長の制限 」を参照してください。
> 大文字と小文字の区別を想定しないでください。 たとえば、いくつかのファイルシステム (POSIX 準拠のファイルシステムなど) では異なるものと見なされる場合でも、"OSCAR"、"Oscar"、"oscar" という名前は同じであると考えてください。 NTFS では大文字と小文字の区別に POSIX セマンティクスがサポートされていますが、これは既定の動作ではありません。
> ボリュームの識別子 (ドライブ文字) は、大文字と小文字を区別しません。 たとえば、"D: \ " と "d: \ " は同じボリュームを参照します。


*  次の文字

| 文字 | 説明 |
| --- | --- |
| < | より小さい |
| > | より大きい |
| : | コロン |
| " | 二重引用符 |
| / | スラッシュ |
| \ | バックスラッシュ |
| &#124; | 縦棒 パイプ |
| ? | 疑問符 |
| * | アスタリスク |

*  次の文字列

CON
PRN
AUX
NUL
COM1
COM2
COM3
COM4
COM5
COM6
COM7
COM8
COM9
LPT1
LPT2
LPT3
LPT4
LPT5
LPT6
LPT7
LPT8
LPT9

これらは拡張子をつけてもだめです。(NUL.txtなどはだめ。)

* 文字コード変換上おかしくなる文字

～（ウェーブダッシュ）
－（全角マイナス）
￠（セント）
￡（ポンド）
￢（ノット）
―（全角マイナスより少し幅のある文字）
∥（平行記号）

これらは近い文字に置き換えるかするしか無いみたいです。

* それ以外の文字

Unicodeの文字カテゴリC（Cc，Cf，Cs，Co，およびCn）で登録されている制御文字は使用できません。



## コード例

```python
import unicodedata
import re


NG_CHR = '/\:*?"<>|〜―'
NG_CHR_WS =  "／＼：＊？”＜＞｜？？"
NG_STR = {
    'CON',
    'PRN',
    'AUX',
    'NUL',
    'COM1',
    'COM2',
    'COM3',
    'COM4',
    'COM5',
    'COM6',
    'COM7',
    'COM8',
    'COM9',
    'LPT1',
    'LPT2',
    'LPT3',
    'LPT4',
    'LPT5',
    'LPT6',
    'LPT7',
    'LPT8',
    'LPT9',
}

def replace_filename(src):
    # 制御文字をすべてスペースに置き換える
    rep_ctl_code_str = ''.join([' ' if unicodedata.category(c)[0] == 'C' else c for c in src])
    # 文字列の先頭と末尾から空白文字(全角を含む)を削除する
    strip_str = rep_ctl_code_str.strip().strip("　")
    # 連続するスペースを1つにする
    remove_duplication_str = re.sub(r'[ ]+', ' ', strip_str)
    # Shift-JISに変換して、またunicodeに戻す
    # 変換できなかった文字列は?に置き換えられている
    replace_not_available_in_sjis = remove_duplication_str.encode('Shift-JIS', 'replace').decode('Shift-JIS')
    # ファイル・フォルダ名として使えない文字は全角として変換する
    ng_chr = {c: i for i, c in enumerate(NG_CHR)}
    detox_str = "".join([NG_CHR_WS[ng_chr[c]] if c in ng_chr else c for c in replace_not_available_in_sjis])
    # 使用禁止文字があったらファイル名を丸括弧で括る
    pos = detox_str.rfind('.')
    if pos >= 0:
        # 拡張子がある場合
        filename_str = detox_str[:pos]
        ext_str = detox_str[pos:]
        return f"({filename_str}){ext_str}" if filename_str in NG_STR else detox_str
    else:
        # 拡張子がない場合
        return f"({detox_str})" if detox_str in NG_STR else detox_str


def main():
    print(replace_filename("12345.xlsx"))
    print(replace_filename(" \t12345.xlsx\t\t"))
    print(replace_filename(" \t12\t \t345.xlsx\t\t"))
    print(replace_filename("あいうえお.xlsx"))
    print(replace_filename(f'12{chr(0) + chr(0)}345.xlsx'))
    print(replace_filename(f'1\\2/:3*?4"<5>|.xlsx'))
    print(replace_filename("𠮷野家.xlsx"))
    print(replace_filename("COM1.xlsx"))
    print(replace_filename("  COM1  "))
    print(replace_filename("COM1. txt"))


if __name__ == '__main__':
    main()
```

**実行結果**

```
12345.xlsx
12345.xlsx
12 345.xlsx
あいうえお.xlsx
12 345.xlsx
1＼2／：3＊？4”＜5＞｜.xlsx
？野家.xlsx
(COM1).xlsx
(COM1)
(COM1). txt
```

## 参考


### 公式

https://docs.microsoft.com/ja-jp/windows/win32/fileio/naming-a-file
https://docs.microsoft.com/ja-jp/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd
https://www.unicode.org/reports/tr44/

### 非公式

https://all.undo.jp/asr/1st/document/01_03.html
https://www.curict.com/item/6e/6e3772c.html
http://tool-support.renesas.com/autoupdate/support/onlinehelp/ja-JP/csp/V4.01.00/CS+.chm/Editor.chm/Output/ed_RegularExpressions4-nav-3.html