ConvertTo-CSV や Export-CSV の出力結果から二重引用符を外す

# 概要
久しぶりの投稿です。
[こちら](https://qiita.com/tak458/items/d008ebd660be9a8a7ba8 "Export-CsvでCSV出力する際に二重引用符を外す[PowerShell]")の記事に触発されて標記のタスクに挑戦してみました。

下記条件の下で、ConvertTo-CSV や Export-CSV の出力結果（各フィールドの値が二重引用符でクォートされている）から、できる限り二重引用符を外します。

- CSV の各フィールドのデータに、半角スペース、タブ、改行、カンマ、二重引用符が含まれていても正しく動作する。
- ConvertFrom-CSV や Import-CSV の結果が元の二重引用符つきのCSVと同一となる。

（条件がマニアックというかパラノイアすぎて）ほとんど実用性はないと思いますが、力試しというか暇つぶしです。


# 事前調査
始めに、各コマンドレットの動作仕様を確認してみました（ソースコードを見て確認した訳ではありません）。
### ConvertTo-CSV、Export-CSV の仕様
1. データに二重引用符が含まれている場合は、連続した2個の二重引用符に置換される。（例：`「You said "Yes".」 → 「"You said ""Yes""."」`）
2. 各フィールドのデータが二重引用符でクォートされる。
3. 行の項目数がヘッダー行の項目数に満たない場合は、不足分の項目について末尾にカンマが追加される。

### ConvertFrom-CSV、Import-CSV の仕様
1. フィールド先頭の連続した半角スペースおよびタブは無視される。（例：「  abc  」→「abc  」）
2. フィールドの（半角スペースやタブを除いた）最初の文字が二重引用符の場合は、対となる二重引用符までの文字列が、二重引用符に囲まれた文字列（カンマ、改行や先頭の半角スペース・タブを含むことができる）に置換される。その際、2個の連続した二重引用符はクォートの終端となる「対となる二重引用符」とは見なされず、1個の二重引用符に置換される。
3. フィールドの（半角スペースやタブを除いた）最初の文字からカンマ（または改行）の手前までの文字列が当該フィールドのデータとなる。
4. 行末尾の半角スペースおよびタブは（二重引用符によるクォートの途中でない限り）無視される。
5. 行の項目数がヘッダー行の項目数に満たない場合は、不足分の項目のデータは NULL となる。

以上により、下記に該当するフィールドについては二重引用符を外すことができません。

- カンマが含まれている。（外すとフィールドの範囲が変わってしまうため）
- 改行が含まれている。（外すとフィールドの範囲が変わってしまうため）
- 先頭に半角スペースまたはタブまたは二重引用符が含まれている。（外すと先頭の半角スペースやタブが消失したり、文字列リテラルとしての二重引用符がクォートの開始記号として扱われたりしてしまうため。）
- 空文字列（""）で行末尾に位置している。（外すとNULLとの区別が付かなくなるため）。


# 実践
### 正規表現を用いた文字列置換で二重引用符を外す

- 一行目で、各フィールドの先頭にある半角スペースとタブを取り除きます。
- 二行目で、行末尾にある半角スペースとタブを取り除きます。
- 三行目で、二重引用符を外す対象のフィールドの、二重引用符に囲まれた文字列内の2個の連続した二重引用符を一旦NULL文字（`` `0``）に変換します[^1]。
- 四行目で、二重引用符を外す対象のフィールドから二重引用符を外します。
- 五行目で、2個の連続した二重引用符から一時的に変換されている NULL を、1個の二重引用符に変換し直します。

ConverTo-CSV や Export-CSV の出力には余分なスペース等は含まれないため、それらのコマンドレットの出力に対して直接処理を行なうだけなら、一、二行目の処理は不要です。しかし、半角スペースやタブが含まれていてもCSV形式としては正しく、ConvertFrom-CSV や Import-CSV もそのような CSV の入力を許容するので一、二行目の処理を含めています。

[^1]: データには NULL文字 が含まれていない前提です。

```
$csvData | % { $_ -Replace '(?<=\A(?:[ \t]*(?![ \t])(?:"(?:[^"]|"")*")?(?!")[^,\n]*[,\n])*)[ \t]+' `
                  -Replace '(?<=\A(?:(?:"(?:[^"]|"")*")?(?!")[^,\n]*[,\n])*(?:"(?:[^"]|"")*")?(?!")[^,\n]*)(?<![ \t])[ \t]+(?=\r?\n|\z)' `
                  -Replace '(?<=\A(?:(?:"(?:[^"]|"")*")?(?!")[^,\n]*[,\n])*"(?![ \t]|"")(?:[^",\n]|"")*)""',"`0" `
                  -Replace '(?<=\A(?:(?:"(?:[^"]|"")*")?(?!")[^,\n]*[,\n])*)"(?![ \t]|""|"(?:\r?\n|\z))((?:[^",\n]|"")*)"(?!")','$1' `
                  -Replace "`0",'"'
             }
```

データに改行が含まれていないことが予め判っている場合は、上記の処理を行毎に行なうことができます。

### 正規表現のパーツの解説
`(?<=\A(?:フィールドのパターン[,\n])*)フィールドのパターン`：
先行する（複数の）フィールドのパターンを肯定後読みで先頭まで遡って照合することで、フィールドの開始位置をテストします。

`[ \t]*(?![ \t])`：
半角スペースとタブからなる文字列（長さ 0 以上）。後半の否定先読みのパターンは、後続の正規表現パターンに半角スペース等がマッチするのを防ぐ役割をしています。

`(?:"(?:[^"]|"")*")?(?!")`：
0個または1個の、二重引用符に囲まれてクォートされた文字列。二重引用符は2個重ねることでクォートの終端記号として扱われません。

`[^,\n]*`：
カンマや改行を含まない文字列（長さ 0 以上）

`(?<![ \t])[ \t]+(?=\r?\n|\z)`
行末尾（`\r?\n|\z`）に位置する半角スペースとタブからなる文字列（長さ 1 以上）。前半の否定後読みのパターンは先行する正規表現パターンに半角スペース等がマッチするのを防ぐ役割をしています。

`"(?![ \t]|""|"(?:\r?\n|\z))((?:[^",\n]|"")*)"(?!")`：
下記条件を満たす、二重引用符に囲まれた文字列。
・ 文字列の先頭が半角スペース、タブ、二重引用符でない。
・ 行末尾に位置する空文字列("")でない。
・ 文字列にカンマや改行が含まれない。


# テスト

### テスト用コード

- ヘッダー行を除く５行のテストデータのうち、3～5行目の末尾のデータは特殊な形式（ConvertFrom-CSV やImport-CSV への入力として許容されるが、ConvertTo-CSV や Export-CSV では出力されない形式）になっています。

``` PowerShell:test.ps1
"`r`n---- 元のCSVデータ ----"
( $csvData = @"
"1st","2nd","3rd","4th","5th","6th","7th","8th","9th","10th"
"012","345","678","9ab","cde","fgh","ijk","lmn","opq","stuv" 
" ","  "," ab","c d","ef "," gh ","","　","　ｇｈ　",i
",",",,",",ab","c,d","ef,",",gh,","i","This cell contains `r`na new line.","123","5"6"7"
"""","""""","""ab","c""d","ef""","""gh""","",, 	
"@ )

"`r`n---- 読込結果（ConvertFrom-CSV）、スペースを@で表示 ----"
$csvData | ConvertFrom-CSV | % { $_.PSObject.Properties | % { if ($_.Value) {$_.Value = $_.Value.Replace(" ","@").Replace("　","＠") }
                                                              if ($_.Value -eq $null) { $_.Value = "NULL" }
                                                            }
                                 $_
                               }

"`r`n---- 出力結果（ConvertTo-CSV, 引用符付き）---"
$csvData | ConvertFrom-CSV | ConvertTo-CSV -NoTypeInformation

"`r`n ---- できるだけ引用符を外したCSVデータ ----"
( $strippedCsvData = $csvData | % { $_ -Replace '(?<=\A(?:[ \t]*(?![ \t])(?:"(?:[^"]|"")*")?(?!")[^,\n]*[,\n])*)[ \t]+' `
                                       -Replace '(?<=\A(?:(?:"(?:[^"]|"")*")?(?!")[^,\n]*[,\n])*(?:"(?:[^"]|"")*")?(?!")[^,\n]*)(?<![ \t])[ \t]+(?=\r?\n|\z)' `
                                       -Replace '(?<=\A(?:(?:"(?:[^"]|"")*")?(?!")[^,\n]*[,\n])*"(?![ \t]|"")(?:[^",\n]|"")*)""',"`0" `
                                       -Replace '(?<=\A(?:(?:"(?:[^"]|"")*")?(?!")[^,\n]*[,\n])*)"(?![ \t]|""|"(?:\r?\n|\z))((?:[^",\n]|"")*)"(?!")','$1'
                                       -Replace "`0",'"' `
                                } )

"`r`n---- 読込結果（ConvertFrom-CSV）、スペースを@で表示 ----"
$strippedCsvData | ConvertFrom-Csv | % { $_.PSObject.Properties | % { if ($_.Value) {$_.Value = $_.Value.Replace(" ","@").Replace("　","＠") }
                                                                      if ($_.Value -eq $null) { $_.Value = "NULL" }
                                                                    }
                                         $_
                                       }

"`r`n---- 出力結果（ConvertTo-CSV, 引用符付き）---"
$strippedCsvData | ConvertFrom-Csv | ConvertTo-Csv -NoTypeInformation
```

### 実行結果

```
---- 元のCSVデータ ----
"1st","2nd","3rd","4th","5th","6th","7th","8th","9th","10th"
"012","345","678","9ab","cde","fgh","ijk","lmn","opq","stuv"
" ","  "," ab","c d","ef "," gh ","","　","　ｇｈ　",i
",",",,",",ab","c,d","ef,",",gh,","i","This cell contains
a new line.","123","5"6"7"
"""","""""","""ab","c""d","ef""","""gh""","",,

---- 読込結果（ConvertFrom-CSV）、スペースを@で表示 ----


1st  : 012
2nd  : 345
3rd  : 678
4th  : 9ab
5th  : cde
6th  : fgh
7th  : ijk
8th  : lmn
9th  : opq
10th : stuv

1st  : @
2nd  : @@
3rd  : @ab
4th  : c@d
5th  : ef@
6th  : @gh@
7th  :
8th  : ＠
9th  : ＠ｇｈ＠
10th : i

1st  : ,
2nd  : ,,
3rd  : ,ab
4th  : c,d
5th  : ef,
6th  : ,gh,
7th  : i
8th  : This@cell@contains@
       a@new@line.
9th  : 123
10th : 56"7"

1st  : "
2nd  : ""
3rd  : "ab
4th  : c"d
5th  : ef"
6th  : "gh"
7th  :
8th  :
9th  : NULL
10th : NULL


---- 出力結果（ConvertTo-CSV, 引用符付き）---
"1st","2nd","3rd","4th","5th","6th","7th","8th","9th","10th"
"012","345","678","9ab","cde","fgh","ijk","lmn","opq","stuv"
" ","  "," ab","c d","ef "," gh ","","　","　ｇｈ　","i"
",",",,",",ab","c,d","ef,",",gh,","i","This cell contains
a new line.","123","56""7"""
"""","""""","""ab","c""d","ef""","""gh""","","",,

 ---- できるだけ引用符を外したCSVデータ ----
1st,2nd,3rd,4th,5th,6th,7th,8th,9th,10th
012,345,678,9ab,cde,fgh,ijk,lmn,opq,stuv
" ","  "," ab",c d,ef ," gh ",,　,　ｇｈ　,i
",",",,",",ab","c,d","ef,",",gh,",i,"This cell contains
a new line.",123,56"7"
"""","""""","""ab",c"d,ef","""gh""",,,

---- 読込結果（ConvertFrom-CSV）、スペースを@で表示 ----
1st  : 012
2nd  : 345
3rd  : 678
4th  : 9ab
5th  : cde
6th  : fgh
7th  : ijk
8th  : lmn
9th  : opq
10th : stuv

1st  : @
2nd  : @@
3rd  : @ab
4th  : c@d
5th  : ef@
6th  : @gh@
7th  :
8th  : ＠
9th  : ＠ｇｈ＠
10th : i

1st  : ,
2nd  : ,,
3rd  : ,ab
4th  : c,d
5th  : ef,
6th  : ,gh,
7th  : i
8th  : This@cell@contains@
       a@new@line.
9th  : 123
10th : 56"7"

1st  : "
2nd  : ""
3rd  : "ab
4th  : c"d
5th  : ef"
6th  : "gh"
7th  :
8th  :
9th  : NULL
10th : NULL


---- 出力結果（ConvertTo-CSV, 引用符付き）---
"1st","2nd","3rd","4th","5th","6th","7th","8th","9th","10th"
"012","345","678","9ab","cde","fgh","ijk","lmn","opq","stuv"
" ","  "," ab","c d","ef "," gh ","","　","　ｇｈ　","i"
",",",,",",ab","c,d","ef,",",gh,","i","This cell contains
a new line.","123","56""7"""
"""","""""","""ab","c""d","ef""","""gh""","","",,
```

　

クリエイティブ・コモンズ 表示 - 継承 4.0 国際