【.NET】UTF-8対応Iniファイルの読み込みと書き込み

# はじめに
これは、[Visual Basic Advent Calendar 2017](https://qiita.com/advent-calendar/2017/vb)の24日目の記事となります。

設定ファイルをxml形式で作成したところ、ユーザーからIni形式で慣れているので変更して欲しいという要望がありました。
既に本体プログラムはReadXmlでDataSet型にして設定を読み込むようになっていたため、本体プログラムに出来るだけ影響しないようにIni形式をDataSet型に変換するクラスを作成しました。

# 説明
下記のようなiniファイルがあった場合、Dictionary型およびDataSet型にして取得できます。
特徴として、セクション名が同じで末尾数字が違った場合、同一グループとして扱います。また、キー名も同様で末尾数字が同じなら同一グループになります。
iniファイルのパースはAPIを使わず独自に行っているため、UTF-8に対応しています。
また、読み込みだけでなく書き込みも出来ます。

※内部ではini形式をXML形式に変換してDataSetのReadXmlメソッドを読んでいます。

```ini:Sample.ini
; データベース
[DATABASE]
Host = 192.168.0.1
User = username
Pass = password

; 文字コード
[ENCODING]
enc = SJIS

; フォント情報
[FONT1]
FontType = Meiryo UI
FontSize = 36
FontColor = white
[FONT2]
FontType = ＭＳ ゴシック
FontSize = 24
FontColor = red
[FONT3]
FontType = Century Gothic
FontSize = 12
FontColor = green

; データ
[DATA1]
Code1 = 100
Str1 = Hoge
Code2 = 101
Str2 = Fuga
[DATA2]
Code1 = 200
Str1 = Hoge2
Code2 = 201
Str2 = Fuga2
```
## 読み込み

```vb:Sample.vb
Dim path As String = "Sample.ini"
Dim iniManager As IniManager = New IniManager()

' 文字コードをUTF-8とする。
iniManager.Encording = Encoding.UTF8
' Iniファイルの読み込み。FalseはDictionary型のみ、TrueならDataSet型にも変換する。
iniManager.ReadIni(path, True)

' Dictionary型による取得
Dim host As String = iniManager.GetString("DATABASE", "Host")

' DataSet型による取得
Dim ds As DataSet = iniManager.SectionDataSet
Dim dtFont As DataTable = ds.Tables("FONT")
Dim dtData As DataTable = ds.Tables("DATA")
```

セクション名(DATABASE)のDictionary型の内容
![IniDic.png](https://qiita-image-store.s3.amazonaws.com/0/14053/cc776751-b002-b439-deea-749d18fdbb16.png)

セクション名(DATABASE)のDataTable型の内容
![IniDataSet1.png](https://qiita-image-store.s3.amazonaws.com/0/14053/88f4f7ce-9053-835a-539e-e0c885fcbb34.png)

セクション名(ENCODING)のDataTable型の内容
![IniDataSet2.png](https://qiita-image-store.s3.amazonaws.com/0/14053/a6ffabf9-3106-a849-ca82-23a22e035c5d.png)

セクション名(FONT1,FONT2,FONT3)のDataTable型の内容
同一セクション名(FONT)でまとめています。Grpの列が自動で追加され末尾数字がセットされます。末尾数字が無い場合、0 になります。
![IniDataSet3.png](https://qiita-image-store.s3.amazonaws.com/0/14053/b6875587-83df-64d6-bb79-07e3099f2996.png)

セクション名(DATA1,DATA2)のDataTable型の内容
同一セクション名(DATA)と同一キー名(Code,Str)でまとめています。GrpとIdxの列が自動で追加され末尾数字がセットされます。
![IniDataSet4.png](https://qiita-image-store.s3.amazonaws.com/0/14053/33703596-39ef-6415-197b-eac128cac6cb.png)

## 書き込み
### Dictionary型を指定
SetStringメソッドでセクション、キー、値をセットするとSectionDicプロパティを書き換えます。
また、SectionDicプロパティを使わず、書き換える部分のみのDictionary型を作成して指定することも出来ます。

```vb:Sample.vb
' hostを1920.168.0.1から127.0.0.1に変更する。
iniManager.SetString("DATABASE", "Host", "127.0.0.1")
' encをSJISからUTF-8に変更する。
iniManager.SetString("ENCODING", "enc", "UTF-8")
' 書き込み
iniManager.WriteIni(path, iniManager.SectionDic)
```
![WriteIni3.png](https://qiita-image-store.s3.amazonaws.com/0/14053/43f90c66-c434-1841-70f8-2063dd90b69f.png)

### DataTable型を指定
例ではDataTable型を指定して2回に分けて書き込みしていますが、DataSet型で一度に書き込むことが出来ます。

```vb:Sample.vb
' データテーブルで書き換える
' FONT2のFontColorをredからblueに書き込える
dtFont.Rows(1).Item("FontColor") = "blue"
iniManager.WriteIni(path, dtFont)
' DATA2のStr2のHoge2をHellowに書き込み
dtData.Rows(1).Item("Str") = "Hellow"
iniManager.WriteIni(path, dtData)
```

![WriteIni4.png](https://qiita-image-store.s3.amazonaws.com/0/14053/9efcc282-d16a-fbe7-5576-958e83522d16.png)

# ソースコード
IniManagerクラスを作成しています。

## Visual Basic版
```vb:IniManager.vb
Imports System.IO
Imports System.Text
Imports System.Text.RegularExpressions

Public Class IniManager
    ' Iniファイル情報格納(OrderedDictionary型)
    Public Property SectionDic() As OrderedDictionary(Of String, OrderedDictionary(Of String, String))
    ' Iniファイル情報格納(DataSet型)
    Public Property SectionDataSet() As DataSet

    ' エンコーディング
    Public Property Encording() As Encoding

    ' セクション番号区切り文字
    Public Property SectionSeparator() As String
    ' キー番号区切り文字
    Public Property KeySeparator() As String
    ' セクションと値の間の空白
    Public Property InsertSpace() As String
    ' ルート名
    Public Property RootName() As String

    ' セクショングループ用属性値
    Public Const SECTION_GROUP_ATTR As String = "Grp"
    ' パラメーターグループ用属性値
    Public Const PARAMETER_INDEX_ATTR As String = "Idx"

    ' XML用ルート名
    Public Const DEFAULT_ROOT_NAMER As String = "Root"

    ' セクション用正規表現パターン
    Const SECTION_PATTERN As String = "^\s*\[(?<section>[^\]]+)\].*$"
    ' パラメーター用正規表現パターン
    Const PARAMETER_PATTERN As String = "^\s*(?<name>[^=]+)=(?<value>.*?)(\s+;(?<comment>.*))?$"

    ' コンストラクタ
    Public Sub New()
        Encording = Encoding.UTF8
        SectionSeparator = ""
        KeySeparator = ""
        InsertSpace = " "
        RootName = DEFAULT_ROOT_NAMER
    End Sub

    ''' <summary>
    ''' 設定値を取得する
    ''' </summary>
    ''' <param name="section">セクション名</param>
    ''' <param name="keyname">キー名</param>
    ''' <returns> 設定値</returns>
    Public Function GetString(section As String, keyname As String) As String
        If Not SectionDic.ContainsKey(section) OrElse Not SectionDic(section).ContainsKey(keyname) Then Return ""

        Return SectionDic(section)(keyname)
    End Function

    ''' <summary>
    ''' 設定値を取得する
    ''' </summary>
    ''' <param name="keyname">キー名</param>
    ''' <returns>設定値</returns>
    Public Function GetString(keyname As String) As String
        Return GetString("", keyname)
    End Function

    ''' <summary>
    ''' 設定情報を取得する
    ''' </summary>
    ''' <param name="tableName">テーブル名</param>
    ''' <returns>設定情報</returns>
    Public Function GetTable(tableName As String) As DataTable
        Return SectionDataSet.Tables(tableName)
    End Function

    ''' <summary>
    ''' 設定値を更新する
    ''' </summary>
    ''' <param name="section">セクション名</param>
    ''' <param name="keyname">キー名</param>
    ''' <param name="value">設定値</param>
    Public Sub SetString(section As String, keyname As String, value As String)
        SectionDic(section)(keyname) = value
    End Sub


    ''' <summary>
    ''' Iniファイル読み込み
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function ReadIni(filePath As String, Optional isDataSet As Boolean = False) As Boolean

        ' Ini情報のセット
        SectionDic = GetSections(filePath)

        ' DataSet型に変換
        If isDataSet Then Return ToDataSet()

        Return True
    End Function

    ''' <summary>
    ''' DataSet型に変換する
    ''' </summary>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function ToDataSet() As Boolean
        SectionDataSet = New DataSet()

        ' XMLデータに変換する
        Dim xml As String = ConvertDicToXML()
        If xml = "" Then Return False

        ' DataSet型に変換する
        Using reader As New StringReader(xml)
            SectionDataSet.ReadXml(reader)
        End Using

        Return True
    End Function

    ''' <summary>
    ''' Iniファイル情報を取得する
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <returns></returns>
    Public Function GetSections(filePath As String) As OrderedDictionary(Of String, OrderedDictionary(Of String, String))
        Using reader = New StreamReader(filePath, Encording)
            Dim sections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))(StringComparer.Ordinal)
            Dim regexSection = New Regex(SECTION_PATTERN, RegexOptions.Singleline Or RegexOptions.CultureInvariant)
            Dim regexNameValue = New Regex(PARAMETER_PATTERN, RegexOptions.Singleline Or RegexOptions.CultureInvariant)
            Dim currentSection = String.Empty

            ' セクション名が明示されていない先頭部分のセクション名を""として扱う
            sections(String.Empty) = New OrderedDictionary(Of String, String)()

            While True
                Dim line = reader.ReadLine()

                If line Is Nothing Then Exit While

                ' 空行は読み飛ばす
                If IsBlank(line) Then Continue While

                ' コメント行は読み飛ばす
                If line.StartsWith(";", StringComparison.Ordinal) Then
                    Continue While
                ElseIf line.StartsWith("#", StringComparison.Ordinal) Then
                    Continue While
                End If

                Dim matchNameValue = regexNameValue.Match(line)

                If matchNameValue.Success Then
                    ' name=valueの行
                    sections(currentSection)(matchNameValue.Groups("name").Value.Trim()) = matchNameValue.Groups("value").Value.Trim()
                    Continue While
                End If

                Dim matchSection = regexSection.Match(line)

                If matchSection.Success Then
                    ' [section]の行
                    currentSection = matchSection.Groups("section").Value

                    If Not sections.ContainsKey(currentSection) Then
                        sections(currentSection) = New OrderedDictionary(Of String, String)()
                    End If

                    Continue While
                End If
            End While

            Return sections
        End Using
    End Function

    ''' <summary>
    ''' Iniファイルの書き込み
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <param name="sections">更新情報</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function WriteIni(filePath As String, sections As OrderedDictionary(Of String, OrderedDictionary(Of String, String))) As Boolean
        Dim result As Boolean = False

        ' Iniファイル情報を取得する
        Dim dic = GetSections(filePath)

        ' 並び替えた情報を格納
        Dim sortSections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))(StringComparer.Ordinal)

        For Each sec In dic
            For Each pair In sec.Value
                If sections.ContainsKey(sec.Key) AndAlso sections(sec.Key).ContainsKey(pair.Key) Then
                    If Not sortSections.ContainsKey(sec.Key) Then
                        sortSections(sec.Key) = New OrderedDictionary(Of String, String)()
                    End If

                    sortSections(sec.Key)(pair.Key) = sections(sec.Key)(pair.Key)
                End If
            Next
        Next

        ' 存在しなかったら追記
        For Each sec In sections
            For Each pair In sec.Value
                ' 既に登録済みなら何もしない
                If sortSections.ContainsKey(sec.Key) AndAlso sortSections(sec.Key).ContainsKey(pair.Key) Then
                    Continue For
                End If

                ' 未登録なら追加する
                If Not sortSections.ContainsKey(sec.Key) Then
                    sortSections(sec.Key) = New OrderedDictionary(Of String, String)()
                End If

                sortSections(sec.Key)(pair.Key) = pair.Value
            Next
        Next


        ' セクションの最終キーを格納
        Dim lastdic As New Dictionary(Of String, String)()
        For Each sec In dic
            If dic(sec.Key).Count > 0 Then
                Dim pair = dic(sec.Key).Last()
                lastdic.Add(sec.Key, pair.Key)
            End If
        Next

        result = Write(filePath, sortSections, lastdic)

        Return result
    End Function

    ''' <summary>
    ''' iniファイルの書き込み
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <param name="sectionName">セクション名</param>
    ''' <param name="keyName">キー名</param>
    ''' <param name="value">値</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function WriteIni(filePath As String, sectionName As String, keyName As String, value As String) As Boolean
        Dim sections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))()

        sections(sectionName) = New OrderedDictionary(Of String, String)()
        sections(sectionName)(keyName) = value

        Return WriteIni(filePath, sections)
    End Function

    ''' <summary>
    ''' Iniファイルの書き込み
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <param name="dsSections">セクション情報</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function WriteIni(filePath As String, dsSections As DataSet) As Boolean
        Dim sections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))()

        For Each dt As DataTable In dsSections.Tables
            ToDictionary(dt, sections)
        Next

        Return WriteIni(filePath, sections)
    End Function

    ''' <summary>
    ''' Iniファイルの書き込み
    ''' </summary>
    ''' <param name="filePath"></param>
    ''' <param name="dtSections"></param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function WriteIni(filePath As String, dtSections As DataTable) As Boolean
        If dtSections.Rows.Count = 0 Then Return False

        Dim sections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))()
        ToDictionary(dtSections, sections)

        Return WriteIni(filePath, sections)
    End Function

    ''' <summary>
    ''' Dictionary型に変換する
    ''' </summary>
    ''' <param name="dtSections">セクション情報</param>
    ''' <param name="sections">更新セクション情報</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Private Function ToDictionary(dtSections As DataTable, ByRef sections As OrderedDictionary(Of String, OrderedDictionary(Of String, String))) As Boolean
        For Each dr As DataRow In dtSections.Rows
            Dim groupNo As String = ""
            If dtSections.Columns.Contains(SECTION_GROUP_ATTR) Then
                groupNo = dr(SECTION_GROUP_ATTR).ToString()
                If groupNo = "0" Then groupNo = ""
            End If

            Dim sectionName As String = dtSections.TableName & SectionSeparator & groupNo
            If sectionName = RootName Then sectionName = ""
            If Not sections.ContainsKey(sectionName) Then
                sections(sectionName) = New OrderedDictionary(Of String, String)()
            End If

            Dim indexNo As String = ""
            If dtSections.Columns.Contains(PARAMETER_INDEX_ATTR) Then
                indexNo = dr(PARAMETER_INDEX_ATTR).ToString()
            End If

            For Each column As DataColumn In dtSections.Columns
                If column.ColumnName = SECTION_GROUP_ATTR OrElse column.ColumnName = PARAMETER_INDEX_ATTR OrElse column.ColumnName = RootName & "_Id" Then
                    Continue For
                End If

                Dim keyName As String = column.ColumnName & KeySeparator & indexNo
                Dim keyName2 As String = keyName
                If indexNo <> "" Then
                    Dim keyno = GetKeyAndNo(keyName, KeySeparator)
                    keyName2 = keyno.Item1
                End If
                sections(sectionName)(keyName) = dr(keyName2).ToString()
            Next
        Next

        Return True
    End Function

    ''' <summary>
    ''' iniファイルの書き込みメイン処理
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <param name="sortSections">ソート済更新情報</param>
    ''' <param name="lastdic">最終パラメーター情報</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Private Function Write(filePath As String, sortSections As OrderedDictionary(Of String, OrderedDictionary(Of String, String)), lastdic As Dictionary(Of String, String)) As Boolean
        Dim isSave As Boolean = False
        Dim isWrite As Boolean = False
        Dim sb As New StringBuilder()
        Dim isExistsSection As Boolean = False

        Using reader = New StreamReader(filePath, Encording)
            Dim sections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))(StringComparer.Ordinal)
            Dim regexSection = New Regex(SECTION_PATTERN, RegexOptions.Singleline Or RegexOptions.CultureInvariant)
            Dim regexNameValue = New Regex(PARAMETER_PATTERN, RegexOptions.Singleline Or RegexOptions.CultureInvariant)
            Dim currentSection = String.Empty

            ' セクション名が明示されていない先頭部分のセクション名を""として扱う
            sections(String.Empty) = New OrderedDictionary(Of String, String)()

            While True
                Dim line = reader.ReadLine()

                If line Is Nothing Then Exit While

                ' 空行は読み飛ばす
                Dim isContinue As Boolean = IsBlank(line)
                If Not isContinue Then
                    ' コメント行は読み飛ばす
                    If line.StartsWith(";", StringComparison.Ordinal) Then
                        isContinue = True
                    ElseIf line.StartsWith("#", StringComparison.Ordinal) Then
                        isContinue = True
                    End If

                    ' 全て終わった
                    If sortSections.Count = 0 Then isContinue = True
                End If

                If isContinue Then
                    sb.AppendLine(line)
                    Continue While
                End If

                ' 存在しなかったら追記
                Dim matchNameValue = regexNameValue.Match(line)
                If sortSections.ContainsKey("") AndAlso currentSection = "" Then
                    isExistsSection = True
                End If

                If isExistsSection AndAlso matchNameValue.Success Then
                    Dim newline As String = line
                    ' name=valueの行
                    Dim keyName As String = matchNameValue.Groups("name").Value.Trim()
                    If sortSections(currentSection).ContainsKey(keyName) Then
                        isWrite = True

                        Dim value As String = sortSections(currentSection)(keyName)
                        sortSections(currentSection).Remove(keyName)

                        Dim curvalue As String = matchNameValue.Groups("value").Value.Trim()
                        If curvalue <> "" Then
                            If curvalue <> value Then
                                ' 現在値があるなら置換する
                                If curvalue.Contains(" ") Then
                                    ' 現在値に空白が含まれていた場合、単純な置換(キーやコメントなども置換される可能性がある) 
                                    newline = line.Replace(curvalue, value)
                                Else
                                    ' 現在値に空白が含まれていない場合、値のみ置換 
                                    newline = Regex.Replace(line, "(=\s+|=)([^;|\s]+)(\s+;.*|)", "${1}" & value & "${3}")
                                End If
                                ' 現在値と違う値なら保存する
                                isSave = True
                            End If
                        Else
                            ' = の位置に半角スペースを1つ空けて値をセット
                            newline = Regex.Replace(line, "(=)(\s[^;].*)", "${1}" &
InsertSpace & value & "${2}")
                            If value <> "" AndAlso newline.IndexOf(value) = -1 Then
                                newline = Regex.Replace(line, "(=)", "${1}" &
InsertSpace & value)
                            End If
                            ' 現在値と違う値なら保存する
                            If newline = line Then isSave = True
                        End If
                    End If

                    ' 書き換え行のセット
                    sb.AppendLine(newline)

                    ' 新規パラメーターがあれば最終キー後に追記する
                    If lastdic(currentSection) = keyName Then
                        For Each s In sortSections(currentSection)
                            sb.AppendLine(String.Format("{0}" & InsertSpace & "=" & InsertSpace & "{1}", s.Key, s.Value))
                        Next
                        sortSections(currentSection).Clear()
                    End If

                    ' セクション内のパラメーターが存在しない
                    If sortSections(currentSection).Count = 0 Then
                        ' セクションを削除する
                        sortSections.Remove(currentSection)
                    End If
                    Continue While
                End If

                isExistsSection = False
                Dim matchSection = regexSection.Match(line)
                If matchSection.Success Then
                    Dim sectionName As String = matchSection.Groups("section").Value.Trim()
                    If sortSections.ContainsKey(sectionName) Then
                        currentSection = sectionName
                        ' 対象のセクション行が存在
                        isExistsSection = True
                    End If
                End If

                ' 行のセット
                sb.AppendLine(line)
            End While
        End Using

        ' 新規セクションとパラメーターを追記する
        For Each sec In sortSections
            ' 未登録のセクションを追加する(一行空行)
            If sb.Length <> 0 Then sb.AppendLine("")
            sb.AppendLine(String.Format("[{0}]", sec.Key))
            isWrite = True
            isSave = True
            For Each pair In sec.Value
                ' パラメーターを追加する
                sb.AppendLine(String.Format("{0}" & InsertSpace & "=" & InsertSpace & "{1}", pair.Key, pair.Value))
            Next
        Next

        ' 保存処理
        If isWrite AndAlso isSave Then
            File.WriteAllText(filePath, sb.ToString(), Encording)
        End If

        Return True
    End Function


    ''' <summary>
    ''' 末尾数値分割処理
    ''' </summary>
    ''' <param name="value">対象キー</param>
    ''' <param name="separator">区切り文字</param>
    ''' <returns>分割情報</returns>
    Private Function GetKeyAndNo(value As String, separator As String) As Tuple(Of String, Integer)
        If Regex.IsMatch(value, "[0-9]$") Then
            Dim pattern As String = "(?<Key>.*\D)(?<No>\d+$)"
            If separator <> "" Then
                pattern = String.Format("(?<Key>.*){0}(?<No>\d+$)", separator)
            End If

            Dim reg As New Regex(pattern)
            Dim mat As Match = reg.Match(value)
            Dim key As String = mat.Result("${Key}")
            Dim no As String = mat.Result("${No}")

            Return New Tuple(Of String, Integer)(key, Integer.Parse(no))
        Else
            Return Nothing
        End If
    End Function

    ''' <summary>
    ''' セクショングループ件数を取得する
    ''' </summary>
    ''' <returns>セクショングループ件数</returns>
    Private Function GetSectionGroupCount() As OrderedDictionary(Of String, Integer)
        Dim secCount As New OrderedDictionary(Of String, Integer)()

        For Each section In SectionDic
            If section.Key <> "" Then
                Dim key As String = section.Key
                Dim keyno = GetKeyAndNo(section.Key, SectionSeparator)
                Dim cnt As Integer = 1
                If keyno IsNot Nothing Then
                    key = keyno.Item1
                    If secCount.ContainsKey(key) Then
                        cnt = secCount(key)
                        cnt += 1
                    End If
                End If
                secCount(key) = cnt
            End If
        Next

        Return secCount
    End Function

    ''' <summary>
    ''' XMLデータに変換する
    ''' </summary>
    ''' <returns>XMLデータ</returns>
    Private Function ConvertDicToXML() As String
        If SectionDic.Count = 0 Then Return ""

        Dim sb As New StringBuilder()

        sb.AppendLine("<?xml version = '1.0' encoding = 'utf-8' ?>")
        sb.AppendLine([String].Format("<{0}>", RootName))

        ' 同一名カウントチェック
        Dim secCount As OrderedDictionary(Of String, Integer) = GetSectionGroupCount()

        ' XMLデータ生成
        For Each section In SectionDic
            Dim grpno As Integer = -1
            Dim key As String = section.Key
            Dim keyno = GetKeyAndNo(key, SectionSeparator)
            If keyno IsNot Nothing Then
                key = keyno.Item1
                grpno = keyno.Item2
            End If
            ' 複数存在するならグループ扱い
            If grpno = -1 AndAlso key <> "" AndAlso secCount(key) > 1 Then grpno = 0

            ' パラメーターが連番のみかチェック
            Dim isDataGrp As Boolean = True
            For Each pair In section.Value
                If Not Regex.IsMatch(pair.Key, "[0-9]$") Then
                    isDataGrp = False
                    Exit For
                End If
            Next

            Dim grp As String = If(grpno > -1, String.Format(" {0}='{1}'", SECTION_GROUP_ATTR, grpno), "")
            If key <> "" Then sb.Append(String.Format("<{0}{1}", key, grp))

            Dim no As Integer = 0
            Dim oldNo As Integer = -1
            Dim pkey As String = ""
            Dim isFirst As Boolean = False
            For Each pair In section.Value
                pkey = pair.Key
                If key <> "" Then
                    If isDataGrp Then
                        keyno = GetKeyAndNo(pkey, KeySeparator)
                        If keyno IsNot Nothing Then
                            pkey = keyno.Item1
                            no = keyno.Item2
                        End If
                        If no <> oldNo AndAlso no > 0 Then
                            If isFirst Then
                                sb.AppendLine(" />")
                                sb.Append(String.Format("<{0}{1}", key, grp))
                            End If
                            sb.Append(String.Format(" {0}='{1}'", PARAMETER_INDEX_ATTR, no))
                        End If
                        oldNo = no
                        isFirst = True
                    End If
                    sb.Append(String.Format(" {0}='{1}'", pkey, pair.Value))
                Else
                    ' セクションが無い場合
                    sb.AppendLine(String.Format("<{0}>{1}</{0}>", pkey, pair.Value))
                End If
            Next
            If key <> "" Then
                sb.AppendLine(" />")
            End If
        Next
        sb.AppendLine([String].Format("</{0}>", RootName))

        Return sb.ToString()
    End Function

    ''' <summary>
    ''' 空行判定
    ''' </summary>
    ''' <param name="line">対象文字列</param>
    ''' <returns>true:空行 / false:空行以外</returns>
    Private Function IsBlank(str As String) As Boolean
        ' 空行(全角スペース、タブ、半角スペースのみを対象にする)
        Dim re As New Regex("\s")
        Dim blankline As String = re.Replace(str, "")

        Return (blankline.Length = 0)
    End Function
End Class
```

## CSharp版
【2018/06/22追記】C#を追加しました。
【2018/08/25追記】VB版とC#版が違うのに気が付きました。C#版が古かった。

```csharp:IniManager.cs
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
public class IniManager
{
    // Iniファイル情報格納(OrderedDictionary型)
    public OrderedDictionary<string, OrderedDictionary<string, string>> SectionDic { get; set; }
    // Iniファイル情報格納(DataSet型)
    public DataSet SectionDataSet { get; set; }

    // エンコーディング
    public Encoding Encording { get; set; }

    // セクション番号区切り文字
    public string SectionSeparator { get; set; }
    // キー番号区切り文字
    public string KeySeparator { get; set; }
    // セクションと値の間の空白
    public string InsertSpace { get; set; }

    // XML用ルート名
    public string RootName { get; set; }

    // セクショングループ用属性値
    public const string SECTION_GROUP_ATTR = "Grp";
    // パラメーターグループ用属性値
    public const string PARAMETER_INDEX_ATTR = "Idx";
    // XML用ルート名
    public const string DEFAULT_ROOT_NAMER = "Root";

    // セクション用正規表現パターン
    const string SECTION_PATTERN = @"^\s*\[(?<section>[^\]]+)\].*$";
    // パラメーター用正規表現パターン
    const string PARAMETER_PATTERN = @"^\s*(?<name>[^=]+)=(?<value>.*?)(\s+;(?<comment>.*))?$";

    // コンストラクタ
    public IniManager()
    {
        Encording = Encoding.UTF8;
        SectionSeparator = "";
        KeySeparator = "";
        InsertSpace = " ";
        RootName = DEFAULT_ROOT_NAMER;
    }

    /// <summary>
    /// 設定値を取得する
    /// </summary>
    /// <param name="section">セクション名</param>
    /// <param name="keyname">キー名</param>
    /// <returns></returns>
    public string GetString(string section, string keyname)
    {
        if (!SectionDic.ContainsKey(section) ||
            !SectionDic[section].ContainsKey(keyname)) return "";

        return SectionDic[section][keyname];
    }

    /// <summary>
    /// 設定値を取得する
    /// </summary>
    /// <param name="section">セクション名</param>
    /// <returns></returns>
    public string GetString(string keyname)
    {
        return GetString("", keyname);
    }

    /// <summary>
    /// 設定情報を取得する
    /// </summary>
    /// <param name="tableName">テーブル名</param>
    /// <returns></returns>
    public DataTable GetTable(string tableName)
    {
        return SectionDataSet.Tables[tableName];
    }

    /// <summary>
    /// 設定値を更新する
    /// </summary>
    /// <param name="section">セクション名</param>
    /// <param name="keyname">キー名</param>
    /// <param name="value">設定値</param>
    public void SetString(string section, string keyname, string value)
    {
        SectionDic[section][keyname] = value;
    }


    /// <summary>
    /// Iniファイル読み込み
    /// </summary>
    /// <param name="filePath">ファイルパス</param>
    /// <returns>true : 正常 / false : 異常</returns>
    public bool ReadIni(string filePath, bool isDataSet = false)
    {

        // Ini情報のセット
        SectionDic = GetSections(filePath);

        // DataSet型に変換
        if (isDataSet) return ToDataSet();

        return true;
    }

    /// <summary>
    /// DataSet型に変換する
    /// </summary>
    /// <returns>true : 正常 / false : 異常</returns>
    public bool ToDataSet()
    {
        SectionDataSet = new DataSet();

        // XMLデータに変換する
        string xml = ConvertDicToXML();
        if (xml == "") return false;

        // DataSet型に変換する
        using (StringReader reader = new StringReader(xml))
        {
            SectionDataSet.ReadXml(reader);
        }

        return true;
    }

    /// <summary>
    /// Iniファイル情報を取得する
    /// </summary>
    /// <param name="filePath">ファイルパス</param>
    /// <returns></returns>
    public OrderedDictionary<string, OrderedDictionary<string, string>> GetSections(string filePath)
    {
        using (var reader = new StreamReader(filePath, Encording))
        {
            var sections = new OrderedDictionary<string, OrderedDictionary<string, string>>(StringComparer.Ordinal);
            var regexSection = new Regex(SECTION_PATTERN, RegexOptions.Singleline | RegexOptions.CultureInvariant);
            var regexNameValue = new Regex(PARAMETER_PATTERN, RegexOptions.Singleline | RegexOptions.CultureInvariant);
            var currentSection = string.Empty;

            // セクション名が明示されていない先頭部分のセクション名を""として扱う
            sections[string.Empty] = new OrderedDictionary<string, string>();

            for (;;)
            {
                var line = reader.ReadLine();

                if (line == null)
                    break;

                // 空行は読み飛ばす
                if (IsBlank(line))
                    continue;

                // コメント行は読み飛ばす
                if (line.StartsWith(";", StringComparison.Ordinal))
                    continue;
                else if (line.StartsWith("#", StringComparison.Ordinal))
                    continue;

                var matchNameValue = regexNameValue.Match(line);

                if (matchNameValue.Success)
                {
                    // name=valueの行
                    sections[currentSection][matchNameValue.Groups["name"].Value.Trim()] = matchNameValue.Groups["value"].Value.Trim();
                    continue;
                }

                var matchSection = regexSection.Match(line);

                if (matchSection.Success)
                {
                    // [section]の行
                    currentSection = matchSection.Groups["section"].Value;

                    if (!sections.ContainsKey(currentSection))
                        sections[currentSection] = new OrderedDictionary<string, string>();

                    continue;
                }
            }

            return sections;
        }
    }

    /// <summary>
    /// Iniファイルの書き込み
    /// </summary>
    /// <param name="filePath">ファイルパス</param>
    /// <param name="secyions">更新情報</param>
    /// <returns>true : 正常 / false : 異常</returns>
    public bool WriteIni(string filePath, OrderedDictionary<string, OrderedDictionary<string, string>> sections)
    {
        bool result = false;

        // Iniファイル情報を取得する
        var dic = GetSections(filePath);

        // 並び替えた情報を格納
        var sortSections = new OrderedDictionary<string, OrderedDictionary<string, string>>(StringComparer.Ordinal);

        foreach (var sec in dic)
        {
            foreach (var pair in sec.Value)
            {
                if (sections.ContainsKey(sec.Key) && sections[sec.Key].ContainsKey(pair.Key))
                {
                    if (!sortSections.ContainsKey(sec.Key))
                        sortSections[sec.Key] = new OrderedDictionary<string, string>();

                    sortSections[sec.Key][pair.Key] = sections[sec.Key][pair.Key];
                }
            }
        }

        // 存在しなかったら追記
        foreach (var sec in sections)
        {
            foreach (var pair in sec.Value)
            {
                // 既に登録済みなら何もしない
                if (sortSections.ContainsKey(sec.Key) && sortSections[sec.Key].ContainsKey(pair.Key))
                    continue;

                // 未登録なら追加する
                if (!sortSections.ContainsKey(sec.Key))
                    sortSections[sec.Key] = new OrderedDictionary<string, string>();

                sortSections[sec.Key][pair.Key] = pair.Value;
            }
        }


        // セクションの最終キーを格納
        Dictionary<string, string> lastdic = new Dictionary<string, string>();
        foreach (var sec in dic)
        {
            if(dic[sec.Key].Count > 0)
            {
                var pair = dic[sec.Key].Last();
                lastdic.Add(sec.Key, pair.Key);
            }
        }

        //result = WriteIni(section.Key, pair.Key, pair.Value, filePath);
        //if (!result) return false;

        result = Write(filePath, sortSections, lastdic);

        return result;
    }

    /// <summary>
    /// iniファイルの書き込み
    /// </summary>
    /// <param name="filePath">ファイルパス</param>
    /// <param name="sectionName">セクション名</param>
    /// <param name="keyName">キー名</param>
    /// <param name="value">値</param>
    /// <returns>true : 正常 / false : 異常</returns>
    public bool WriteIni(string filePath, string sectionName, string keyName, string value)
    {
        var sections = new OrderedDictionary<string, OrderedDictionary<string, string>>();

        sections[sectionName] = new OrderedDictionary<string, string>();
        sections[sectionName][keyName] = value;

        return WriteIni(filePath, sections);
    }

    /// <summary>
    /// Iniファイルの書き込み
    /// </summary>
    /// <param name="filePath">ファイルパス</param>
    /// <param name="dsSections">セクション情報</param>
    /// <returns>true : 正常 / false : 異常</returns>
    public bool WriteIni(string filePath, DataSet dsSections)
    {
        var sections = new OrderedDictionary<string, OrderedDictionary<string, string>>();

        foreach (DataTable dt in dsSections.Tables)
        {
            ToDictionary(dt, ref sections);
        }

        return WriteIni(filePath, sections);
    }

    /// <summary>
    /// Iniファイルの書き込み
    /// </summary>
    /// <param name="filePath"></param>
    /// <param name="dtSections"></param>
    /// <returns>true : 正常 / false : 異常</returns>
    public bool WriteIni(string filePath, DataTable dtSections)
    {
        if (dtSections.Rows.Count == 0) return false;

        var sections = new OrderedDictionary<string, OrderedDictionary<string, string>>();
        ToDictionary(dtSections, ref sections);

        return WriteIni(filePath, sections);
    }

    /// <summary>
    /// Dictionary型に変換する
    /// </summary>
    /// <param name="dtSections">セクション情報</param>
    /// <param name="sections">更新セクション情報</param>
    /// <returns>true : 正常 / false : 異常</returns>
    private bool ToDictionary(DataTable dtSections, ref OrderedDictionary<string, OrderedDictionary<string, string>> sections)
    {
        foreach (DataRow dr in dtSections.Rows)
        {
            string groupNo = "";
            if (dtSections.Columns.Contains(SECTION_GROUP_ATTR))
            {
                groupNo = dr[SECTION_GROUP_ATTR].ToString();
                if(groupNo == "0") groupNo = "";
            }

            string sectionName = dtSections.TableName + SectionSeparator + groupNo;
            if (sectionName == RootName) sectionName = "";
            if(!sections.ContainsKey(sectionName))
                sections[sectionName] = new OrderedDictionary<string, string>();

            string indexNo = "";
            if (dtSections.Columns.Contains(PARAMETER_INDEX_ATTR))
            {
                indexNo = dr[PARAMETER_INDEX_ATTR].ToString();
            }

            foreach (DataColumn column in dtSections.Columns)
            {
                if (column.ColumnName == SECTION_GROUP_ATTR ||
                    column.ColumnName == PARAMETER_INDEX_ATTR ||
                    column.ColumnName == RootName + "_Id") continue;

                string keyName = column.ColumnName + KeySeparator + indexNo;
                string keyName2 = keyName;
                if(indexNo != "")
                {
                    var keyno = GetKeyAndNo(keyName, KeySeparator);
                    keyName2 = keyno.Item1;
                }
                sections[sectionName][keyName] = dr[keyName2].ToString();
            }
        }

        return true;
    }

    /// <summary>
    /// iniファイルの書き込みメイン処理
    /// </summary>
    /// <param name="filePath">ファイルパス</param>
    /// <param name="sortSections">ソート済更新情報</param>
    /// <param name="lastdic">最終パラメーター情報</param>
    /// <returns>true : 正常 / false : 異常</returns>
    private bool Write(string filePath, OrderedDictionary<string, OrderedDictionary<string, string>> sortSections, Dictionary<string, string> lastdic)
    {
        bool isSave = false;
        bool isWrite = false;
        StringBuilder sb = new StringBuilder();
        bool isExistsSection = false;

        using (var reader = new StreamReader(filePath, Encording))
        {
            var sections = new OrderedDictionary<string, OrderedDictionary<string, string>>(StringComparer.Ordinal);
            var regexSection = new Regex(SECTION_PATTERN, RegexOptions.Singleline | RegexOptions.CultureInvariant);
            var regexNameValue = new Regex(PARAMETER_PATTERN, RegexOptions.Singleline | RegexOptions.CultureInvariant);
            var currentSection = string.Empty;

            // セクション名が明示されていない先頭部分のセクション名を""として扱う
            sections[string.Empty] = new OrderedDictionary<string, string>();

            for (;;)
            {
                var line = reader.ReadLine();

                if (line == null)
                    break;

                // 空行は読み飛ばす
                bool isContinue = IsBlank(line);
                if (!isContinue)
                {
                    // コメント行は読み飛ばす
                    if (line.StartsWith(";", StringComparison.Ordinal))
                        isContinue = true;
                    else if (line.StartsWith("#", StringComparison.Ordinal))
                        isContinue = true;

                    // 全て終わった
                    if (sortSections.Count == 0)
                        isContinue = true;
                }

                if (isContinue)
                {
                    sb.AppendLine(line);
                    continue;
                }

                // 存在しなかったら追記
                var matchNameValue = regexNameValue.Match(line);
                if (sortSections.ContainsKey("") && currentSection == "")
                {
                    isExistsSection = true;
                }

                if (isExistsSection && matchNameValue.Success)
                {
                    string newline = line;
                    // name=valueの行
                    string keyName = matchNameValue.Groups["name"].Value.Trim();
                    if (sortSections[currentSection].ContainsKey(keyName))
                    {
                        isWrite = true;

                        string value = sortSections[currentSection][keyName];
                        sortSections[currentSection].Remove(keyName);

                        string curvalue = matchNameValue.Groups["value"].Value.Trim();
                        if (curvalue != "")
                        {
                            if (curvalue != value)
                            {
                                // 現在値があるなら置換する
                                if(curvalue.Contains(" "))
                                {
                                    // 現在値に空白が含まれていた場合、単純な置換(キーやコメントなども置換される可能性がある)
                                    newline = line.Replace(curvalue, value);
                                }
                                else
                                {
                                    // 現在値に空白が含まれていない場合、値のみ置換
                                    newline = Regex.Replace(line, @"(=\s+|=)([^;|\s]+)(\s+;.*|)", "${1}" + value + "${3}");
                                }
                                // 現在値と違う値なら保存する
                                isSave = true;
                            }
                        }
                        else
                        {
                            // = の位置に半角スペースを1つ空けて値をセット
                            newline = Regex.Replace(line, @"(=)(\s[^;].*)", "${1}" + InsertSpace + value + "${2}");
                            if(value != "" && newline.IndexOf(value) == -1)
                            {
                                newline = Regex.Replace(line, @"(=)", "${1}" + InsertSpace + value);
                            }
                            // 現在値と違う値なら保存する
                            if (newline != line) isSave = true;
                        }
                    }

                    // 書き換え行のセット
                    sb.AppendLine(newline);

                    // 新規パラメーターがあれば最終キー後に追記する
                    if (lastdic[currentSection] == keyName)
                    {
                        foreach (var s in sortSections[currentSection])
                        {
                            sb.AppendLine(string.Format("{0}" + InsertSpace + "=" + InsertSpace + "{1}", s.Key, s.Value));
                        }
                        sortSections[currentSection].Clear();
                    }

                    // セクション内のパラメーターが存在しない
                    if (sortSections[currentSection].Count == 0)
                    {
                        // セクションを削除する
                        sortSections.Remove(currentSection);
                    }
                    continue;
                }

                isExistsSection = false;
                var matchSection = regexSection.Match(line);
                if (matchSection.Success)
                {
                    string sectionName = matchSection.Groups["section"].Value.Trim();
                    if (sortSections.ContainsKey(sectionName))
                    {
                        currentSection = sectionName;
                        // 対象のセクション行が存在
                        isExistsSection = true;
                    }
                }

                // 行のセット
                sb.AppendLine(line);
            }
        }

        // 新規セクションとパラメーターを追記する
        foreach (var sec in sortSections)
        {
            // 未登録のセクションを追加する(一行空行)
            if (sb.Length != 0) sb.AppendLine("");
            sb.AppendLine(string.Format("[{0}]", sec.Key));
            isWrite = true;
            isSave = true;
            foreach (var pair in sec.Value)
            {
                // パラメーターを追加する
                sb.AppendLine(string.Format("{0}" + InsertSpace + "=" + InsertSpace + "{1}", pair.Key, pair.Value));
            }
        }

        // 保存処理
        if (isWrite && isSave)
        {
            File.WriteAllText(filePath, sb.ToString(), Encording);
        }

        return true;
    }


    /// <summary>
    /// 末尾数値分割処理
    /// </summary>
    /// <param name="value">対象キー</param>
    /// <param name="separator">区切り文字</param>
    /// <returns>分割情報</returns>
    private Tuple<string, int> GetKeyAndNo(string value, string separator)
    {
        if (Regex.IsMatch(value, @"[0-9]$"))
        {
            string pattern = @"(?<Key>.*\D)(?<No>\d+$)";
            if (separator != "")
                pattern = string.Format(@"(?<Key>.*){0}(?<No>\d+$)", separator);

            Regex reg = new Regex(pattern);
            Match mat = reg.Match(value);
            string key = mat.Result("${Key}");
            string no = mat.Result("${No}");

            return new Tuple<string, int>(key, int.Parse(no));
        }
        else
            return null;
    }

    /// <summary>
    /// セクショングループ件数を取得する
    /// </summary>
    /// <returns>セクショングループ件数</returns>
    private OrderedDictionary<string, int> GetSectionGroupCount()
    {
        OrderedDictionary<string, int> secCount = new OrderedDictionary<string, int>();

        foreach (var section in SectionDic)
        {
            if (section.Key != "")
            {
                string key = section.Key;
                var keyno = GetKeyAndNo(section.Key, SectionSeparator);
                int cnt = 1;
                if (keyno != null)
                {
                    key = keyno.Item1;
                    if (secCount.ContainsKey(key))
                    {
                        cnt = secCount[key];
                        cnt++;
                    }
                }
                secCount[key] = cnt;
            }
        }

        return secCount;
    }

    /// <summary>
    /// XMLデータに変換する
    /// </summary>
    /// <returns>XMLデータ</returns>
    private string ConvertDicToXML()
    {
        if (SectionDic.Count == 0) return "";

        StringBuilder sb = new StringBuilder();

        sb.AppendLine("<?xml version = '1.0' encoding = 'utf-8' ?>");
        sb.AppendLine(String.Format("<{0}>", RootName));

        // 同一名カウントチェック
        OrderedDictionary<string, int> secCount = GetSectionGroupCount();

        // XMLデータ生成
        foreach (var section in SectionDic)
        {
            int grpno = -1;
            string key = section.Key;
            var keyno = GetKeyAndNo(key, SectionSeparator);
            if (keyno != null)
            {
                key = keyno.Item1;
                grpno = keyno.Item2;
            }
            // 複数存在するならグループ扱い
            if (grpno == -1 && key != "" && secCount[key] > 1) grpno = 0;

            // パラメーターが連番のみかチェック
            bool isDataGrp = true;
            foreach (var pair in section.Value)
            {
                if (!Regex.IsMatch(pair.Key, @"[0-9]$"))
                {
                    isDataGrp = false;
                    break;
                }
            }

            string grp = grpno > -1 ? string.Format(" {0}='{1}'", SECTION_GROUP_ATTR , grpno) : "";
            if (key != "") sb.Append(string.Format("<{0}{1}", key, grp));

            int no = 0;
            int oldNo = -1;
            string pkey = "";
            bool isFirst = false;
            foreach (var pair in section.Value)
            {
                pkey = pair.Key;
                if (key != "")
                {
                    if (isDataGrp)
                    {
                        keyno = GetKeyAndNo(pkey, KeySeparator);
                        if (keyno != null)
                        {
                            pkey = keyno.Item1;
                            no = keyno.Item2;
                        }
                        if (no != oldNo && no > 0)
                        {
                            if (isFirst)
                            {
                                sb.AppendLine(" />");
                                sb.Append(string.Format("<{0}{1}", key, grp));
                            }
                            sb.Append(string.Format(" {0}='{1}'", PARAMETER_INDEX_ATTR, no));
                        }
                        oldNo = no;
                        isFirst = true;
                    }
                    sb.Append(string.Format(" {0}='{1}'", pkey, pair.Value));
                }
                else
                {
                    // セクションが無い場合
                    sb.AppendLine(string.Format("<{0}>{1}</{0}>", pkey, pair.Value));
                }
            }
            if (key != "") sb.AppendLine(" />");
        }
        sb.AppendLine(String.Format("</{0}>", RootName));

        return sb.ToString();
    }

    /// <summary>
    /// 空行判定
    /// </summary>
    /// <param name="line">対象文字列</param>
    /// <returns>true:空行 / false:空行以外</returns>
    private bool IsBlank(string str)
    {
        // 空行(全角スペース、タブ、半角スペースのみを対象にする)
        Regex re = new Regex(@"\s");
        string blankline = re.Replace(str, "");

        return (blankline.Length == 0);
    }
}
```

## OrderedDictionary型について
IniManagerクラスの中で、次サイトの「ジェネリック版OrderedDictionary」を使用しています。読み込みだけならDictionary型で充分だったのですが、書き込みする上ではOrderedDictionaryのように順序の保証が必要でした。
[ジェネリック版OrderedDictionary - smdn](http://smdn.jp/programming/netfx/tips/generic_ordereddictionary/)

このジェネリック版OrderedDictionaryは、C#版しか公開されていないのですが、[SharpDevelop Ver 4.4](http://www.icsharpcode.net/OpenSource/SD/Download/Default.aspx)のC#からVB.NETへのコンバート機能を使用しました。ちなみに最新版のSharpDevelop Ver 5には変換機能がつかなくなりました。

正しく変換されなかったところは次のように修正しました。

```bat:OrderedDictionary.vb
#degine ENABLE_KEYS_VALUES
↓
#Const ENABLE_KEYS_VALUES = True

Public Overloads Function Remove(key As TKey) As Boolean Implements IDictionary(Of TKey, TValue).Remove, ICollection(Of KeyValuePair(Of TKey, TValue)).Remove
↓
Public Overloads Function Remove(key As TKey) As Boolean Implements IDictionary(Of TKey, TValue).Remove

Me(index).Value
↓
Me.Items(index).Value
```

# ライセンスっぽいこと
コード改変や配布は自由です。
このツールによる義務／責任を何ら負いません。

但し、[ジェネリック版OrderedDictionary - smdn](http://smdn.jp/programming/netfx/tips/generic_ordereddictionary/)はMITライセンスとなっています。

# 最後に
え、C#版が欲しいって、これはVisual Basic Advent Calendarなんですよ。
ウソです、もともとC#で作成したのを、この記事用にVisual Basic用に書き換えました。~~そのうち、GitHubに公開します。~~

# 改良と不具合修正
【2018/08/25追記】
C#版がVB版と比べて古かったのを修正。
初期値が存在しない場合に値変更が反映されない不具合を修正しました。
InsertSpaceプロパティを追加(デフォルトは半角スペース1つ)、値書込時にセクションと値の間の半角スペース1つ固定を可変にしました。これで、半角スペース無しが設定可能です。

【2018/09/07追記】
タブのみの行があった際に別セクション扱いになってしまう不具合があり、空行判定(半角スペース、全角スペース、タブ等)を正規表現の"\s"で空文字に置換して長さ 0 なら空行扱いとするように修正しました。