2024: Blazor Excelファイル読み書きテスト

# BlazorのウェブアプリでExcelファイルの読み書きをテスト
今日は .NET 用の下記の4つの Excel ライブラリをテストします：

- [ClosedXML](https://github.com/ClosedXML/ClosedXML)　※ 2024-03-05：**ClosedXML** は **OpenXML 3.X** ではまだ適切に動作しないため、**OpenXML 2.X** にダウングレードする必要があります
- [EPPlus](https://epplussoftware.com/)　※ 注意：有料ライセンスも必要かもしれません
- [NPOI](https://github.com/nissl-lab/npoi)
- [OpenXML](https://github.com/dotnet/Open-XML-SDK)　※ 一番使いにくいけど基本のライブラリ
## テストプロジェクトの作成
### テストのは？
テストプロジェクトでは、20,000 行のファイルを 5 つ読み取り、それらを 1 つのファイルに結合し、実行時間を計測します。
### テストファイルの準備
1. まず、次の`generate_split_files.py`の **Python 3** スクリプトを使用して、テスト中に結合する 5 つのファイルを作成しましょう：
    ```python
    import argparse
    import openpyxl
    import uuid
    
    parser = argparse.ArgumentParser()
    parser.add_argument("name",
                        help="the split files base name", default='to_join')
    parser.add_argument("-v", "--verbose", help="increase output verbosity",
                        action="store_true")
    parser.add_argument("-n", "--number", help="the number of files to generate")
    parser.add_argument("-s", "--size", help="the size of each file")
    parser.add_argument("-c", "--columns", default=3, help="the number of column in each file")
    
    args = parser.parse_args()
    
    total_number = 5
    if args.number:
        total_number = args.number
    
    size = 20000
    if args.size:
        size = args.size
        
    columns = args.columns
    
    colName = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    
    for file_number in range(1, total_number+1):
        filepath = f"./input/{args.name}_{file_number}.xlsx"
        print(f"saving '{filepath}'. ({file_number}/{total_number})")
        wb = openpyxl.Workbook()
        ws = wb["Sheet"]
        ws.title = "リスト"
        rowIndex = 1
        for colIndex, val in enumerate(colName[:columns], start=1):
            ws.cell(row=rowIndex, column=colIndex).value = val
        rowIndex += 1
        for row in range(size):
            for col in range(columns):
                ws.cell(row=rowIndex, column=col+1).value = str(uuid.uuid4())
            rowIndex += 1
        print(f"'{filepath}' saved. ({file_number}/{total_number})")
        wb.save(filepath)
    ```
2. 次の引数を指定してスクリプトを実行し、テスト ファイルを生成します
    ```shell
    python generate_split_files.py split_file
    ```
### Blazor プロジェクトの新規作成
1. Visual Studio 2022を開いて
2. プロジェクトの新規作成を押下
![blazor_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531993/781e371f-f56f-4ec4-be62-1ae0739e04cd.png)
3. Blazorのウエブアプリテンプレートを選択
4. 「次」を押下
![blazor_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531993/b88d1bf4-8c27-fd11-6675-e451865cbaf5.png)
5. 「ExcelReadWrite」のプロジェクト名を入力
6. 「次」を押下
![erw_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531993/3953e1ac-2438-3a7e-6d1b-c57560735950.png)
7. 「作成」を押下
![blazor_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531993/b5cb8505-46ad-161f-eab8-270d8bfd0f50.png)

### デフォールトのページを削除 
1. `Components\Pages`フォルダーの中にある`Counter.razor`と`Weather.razor`ファイルを選択
![erw_8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531993/fdc035ca-dc86-3ec2-4127-fd2795fc10be.png)
2. 右クリックすると表示されるコンテキストメニューで「削除」をクリックします
3. `Components\Layout\NavMenu.razor`の中で削除されたページへのリンクも削除します：
    ```html
    <div class="top-row ps-3 navbar navbar-dark">
        <div class="container-fluid">
            <a class="navbar-brand" href="">ExcelReadWrite</a>
        </div>
    </div>
    
    <input type="checkbox" title="Navigation menu" class="navbar-toggler" />
    
    <div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
        <nav class="flex-column">
            <div class="nav-item px-3">
                <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                    <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
                </NavLink>
            </div>
        </nav>
    </div>
    ```
### Excel 用のラブラリーのインストール
1. ソリューションを右クリック
2. 右クリックすると表示されるコンテキストメニューで Manage NuGet Packages for Solution... を選択
![erw_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531993/51a57e81-3a0e-8724-00fb-5f21a3803425.png)
3. NuGet - Solution ペインの Browse タブを選択
4. 検索ボックスで NPOI を入力
5. 左側にあるパッケージ一覧で NPOI を選択
6. 左側に表示されるパッケージ詳細で「プロジェクト」のチェックボックスを選択
7. 現れた「インストール」ボタンを押下
![erw_6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531993/f1a86e16-67a7-fda8-9d6c-8063c9201679.png)
8. 表示された変更プレビューのダイアログの「適用する」ボタンを押下
9. CloseXML、EPPlus、OpenXML ライブラリに対して上記の手順 4 ～ 8 を繰り返します：
![erw_7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531993/bfcd8b50-1939-433a-2de8-ff3f98ff0562.png)
## Excelファイルの読み書きのテストページを作成
Excelファイルの読み書きのテストページは`Components\Pages\Home.razor`で定義されています。
### インポートとディレクティブ
1. Razorページの設定：インポートとディレクティブ
    ```csharp
    @page "/"
    
    @rendermode InteractiveServer
    
    @using System.Data
    @using System.Diagnostics
    @using System.IO
    @using ClosedXML.Excel
    @using NPOI.SS.UserModel
    @using NPOI.XSSF.UserModel
    @using OfficeOpenXml
    @using DocumentFormat.OpenXml
    @using DocumentFormat.OpenXml.Packaging
    @using DocumentFormat.OpenXml.Spreadsheet
    ```
### UI
1. RazorページのHTML：
    ```html
    <PageTitle>Excel Read Write Test</PageTitle>
    
    <h3>Files</h3>
    
    
    <h4>Uploads</h4>
    <InputFile OnChange="UploadFiles" multiple />
    
    <ul>
        @foreach (var file in uploadedFiles)
        {
            <li>@file</li>
        }
    </ul>
    
    
    <h4>Processing</h4>
    <button @onclick="ProcessWithClosedXML">Process With ClosedXML</button>
    <button @onclick="ProcessWithEPPlus">Process With EPPlus</button>
    <button @onclick="ProcessWithNPOI">Process With NPOI</button>
    <button @onclick="ProcessWithOpenXML">Process With OpenXML</button>
    <button @onclick="ProcessWithOpenXMLSAX">Process With OpenXML (SAX)</button>
    
    <ul>
        @foreach (var file in processedFiles)
        {
            <li>@file</li>
        }
    </ul>
    <p>processed in : @processingTime ms</p>
    ```
### コード
1. Razorページの基本的なコード：
    ```csharp
    @code {
       private List<string> uploadedFiles = new List<string>();
       private List<string> processedFiles = new List<string>();
       private string inputFolderPath = $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\\Documents\\ExcelReadWrite\\Input";
       private string outputFolderPath = $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\\Documents\\ExcelReadWrite\\Output";
       private int processingTime = 0;
    
       protected override void OnInitialized()
       {
           uploadedFiles = new List<string>(Directory.GetFiles(inputFolderPath));
       }
    
        private async Task UploadFiles(InputFileChangeEventArgs e)
        {
            var selectedFileList = e.GetMultipleFiles(e.FileCount);
            foreach (var inputFile in selectedFileList)
            {
                string uploadedFilePath = $"{inputFolderPath}\\{inputFile.Name}";
                System.Diagnostics.Debug.Print(inputFile.Name);
                this.uploadedFiles.Add(uploadedFilePath);
                using (var inputStream = inputFile.OpenReadStream(inputFile.Size))
                {
                    // Save the file to the server's file system
                    using (var fileStream = new FileStream(uploadedFilePath, FileMode.OpenOrCreate, FileAccess.Write))
                    {
                        await inputStream.CopyToAsync(fileStream);
                    }
                }
            }
        }
        private async Task DeleteFile(string filePath)
        {
            if (File.Exists(filePath))
            {
                try
                {
                    File.Delete(filePath);
                }
                catch (System.Exception error)
                {
                    System.Diagnostics.Debug.Print(error.ToString());
                }
            }
            processedFiles.Clear();
            processingTime = 0;
            await InvokeAsync(StateHasChanged);
        }
        private async Task ProcessWithClosedXML(MouseEventArgs e)
        {
            System.Diagnostics.Debug.Print("ProcessWithClosedXML");
        }
        private async Task ProcessWithEPPlus(MouseEventArgs e)
        {
            System.Diagnostics.Debug.Print("ProcessWithEPPlus");
        }
        private async Task ProcessWithNPOI(MouseEventArgs e)
        {
            System.Diagnostics.Debug.Print("ProcessWithNPOI");
        }
        private async Task ProcessWithOpenXML(MouseEventArgs e)
        {
            System.Diagnostics.Debug.Print("ProcessWithOpenXML");
        }
        private async Task ProcessWithOpenXMLSAX(MouseEventArgs e)
        {
            System.Diagnostics.Debug.Print("ProcessWithOpenXMLSAX");
        }
    }
    ```
## ClosedXML ライブラリ
※ 2024-03-05：[**ClosedXML** は **OpenXML 3.X** ではまだ適切に動作しないため、**OpenXML 2.X** にダウングレードする必要があります](https://github.com/ClosedXML/ClosedXML/issues/2220)。
1. **ClosedXML** でのスタブに処理実装を追加
    ```csharp
    private async Task ProcessWithClosedXML(MouseEventArgs e)
    {
         System.Diagnostics.Debug.Print($"ProcessWithClosedXML start");
    
         if (e is null)
         {
             throw new ArgumentNullException(nameof(e));
         }
    
         try
         {
            string resultFilePath = $"{outputFolderPath}\\Results_ClosedXML.xlsx";
            // 結合されたファイルを削除
            DeleteFile(resultFilePath);
            // 読み取りのファイルリスト
            string[] fileToProcessList = Directory.GetFiles(inputFolderPath);
            // 実行時間の計測を開始
            var stopwatch = Stopwatch.StartNew();
            // 出力ワークブックを開く
            using (XLWorkbook OutputWorkbook = new XLWorkbook())
            {
                // 最初の入力ファイルを開く
                using (XLWorkbook firstInputWorkbook = new XLWorkbook(fileToProcessList.First()))
                {
                    // 最初の入力した Excel シートをロード
                    IXLWorksheet firstInputSheet = firstInputWorkbook.Worksheet("リスト");
                    var firstInputEndRow = firstInputSheet.LastRowUsed().RowNumber();
                    // ヘッダー行を含むシート行を一度でコピー
                    IXLWorksheet outputSheet = firstInputSheet.CopyTo(OutputWorkbook);
                    // 次の入力ファイルの行をコピーできるように出力ファイルの開始行番号を更新
                    int outputRowIndex = 1 + firstInputEndRow;
                    // 入力ファイルが処理終了後に、処理済のファイルのリストに表示
                    this.processedFiles.Add(fileToProcessList.First());
                    await InvokeAsync(StateHasChanged);

                    // 残りの各入力ファイルを処理
                    foreach (var inputFile in fileToProcessList.Skip(1))
                    {
                        // 入力ファイルを開く
                        IXLWorksheet inputSheet;
                        using (XLWorkbook inputWorkbook = new XLWorkbook(inputFile))
                        {
                            // 入力した Excel シートをロード
                            inputSheet = inputWorkbook.Worksheet("リスト");
                            var inputEndColumn = inputSheet.LastColumnUsed().ColumnLetter();
                            var inputEndRow = inputSheet.LastRowUsed().RowNumber();

                            // ヘッダー行以降のシート行を一度でコピー
                            IXLCell outputCell = outputSheet.Cell(outputRowIndex, 1);
                            outputCell.CopyFrom(inputSheet.Range("A2", $"{inputEndColumn}{inputEndRow}"));
                            // 次の入力ファイルの行をコピーできるように出力ファイルの開始行番号を更新
                            outputRowIndex += inputEndRow - 1;
                        }
                        // 入力ファイルが処理終了後に、処理済のファイルのリストに表示
                        this.processedFiles.Add(inputFile);
                        await InvokeAsync(StateHasChanged);
                    }
                }
                // 出力ファイルを保存
                OutputWorkbook.SaveAs(resultFilePath);
            }
            // 実行時間の計測を終了
            stopwatch.Stop();
            // 実行時間を表示
            TimeSpan elapsedTime = stopwatch.Elapsed;
            processingTime = elapsedTime.Milliseconds;

            System.Diagnostics.Debug.Print($"ProcessWithClosedXML end ({elapsedTime}ms)");
        }
        catch (System.Exception error)
        {
         System.Diagnostics.Debug.Print(error.Message);
         System.Diagnostics.Debug.Print(error.Source);
         System.Diagnostics.Debug.Print(error.StackTrace);
         System.Diagnostics.Debug.Print(error.HelpLink);
        
        }
    }
    ```
## EPPlus ライブラリ
1. **EPPlus** の[ライセンス](https://epplussoftware.com/developers/licenseexception)の設定を `appsettings.json` ファイルに挿入：
    ```json
    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "AllowedHosts": "*",
      "EPPlus": {
        "ExcelPackage": {
          "LicenseContext": "NonCommercial" // 使用されるライセンス コンテキスト (商用/非商用)
        }
      }
    }
    ```

2. **EPPlus** でのスタブに処理実装を追加
    ```csharp
    private async Task ProcessWithEPPlus(MouseEventArgs e)
    {
        System.Diagnostics.Debug.Print($"ProcessWithEPPlus start");

        if (e is null)
        {
            throw new ArgumentNullException(nameof(e));
        }

        try
        {
            string resultFilePath = $"{outputFolderPath}\\Results_EPPlus.xlsx";
            // 結合されたファイルを削除
            DeleteFile(resultFilePath);
            // 読み取りのファイルリスト
            string[] fileToProcessList = Directory.GetFiles(inputFolderPath);
            // 実行時間の計測を開始
            var stopwatch = Stopwatch.StartNew();
            // 出力ファイルを開く
            using (var outputExcelPack = new ExcelPackage(resultFilePath))
            {
                // 出力Excelシートを作成
                ExcelWorksheet outputSheet = outputExcelPack.Workbook.Worksheets.Add("リスト");

                List<string> headers = new List<string>();
                int outputRowIndex = 1;
                // 各入力ファイルを処理
                foreach (var inputFile in fileToProcessList)
                {
                    // 入力ファイルを開く
                    ExcelWorksheet inputSheet;
                    using (var inputExcelPack = new ExcelPackage())
                    {
                        // 入力した Excel シートをロード
                        using (var inputFileStream = File.OpenRead(inputFile))
                        {
                            inputExcelPack.Load(inputFileStream);
                        }
                        inputSheet = inputExcelPack.Workbook.Worksheets["リスト"];
                        var inputEndColumn = inputSheet.Dimension.End.Column;
                        var inputEndRow = inputSheet.Dimension.End.Row;
                        // ヘッダー情報がない場合は、最初の入力ファイルを開いたので、最初の行からヘッダー情報を取得
                        if (headers.Count <= 0)
                        {
                            // 最初の行の各列を処理
                            foreach (var firstRowCell in inputSheet.Cells[outputRowIndex, 1, outputRowIndex, inputEndColumn])
                            {
                                string headerName = "N/A";
                                // 列のテキストをヘッダーとして取得
                                if (!string.IsNullOrEmpty(firstRowCell.Text))
                                {
                                    string firstColumn = string.Format("Column {0}", firstRowCell.Start.Column);
                                    headerName = firstColumn ?? "N/A";
                                }
                                headers.Add(headerName);
                            }
                            // ヘッダー行をコピー
                            inputSheet.Cells[outputRowIndex, 1, inputEndRow, inputEndColumn].Copy(outputSheet.Cells[outputRowIndex, 1, inputEndRow, inputEndColumn]);
                            // 次の入力ファイルの行をコピーできるように出力ファイルの開始行番号を更新
                            outputRowIndex += inputEndRow;
                        }
                        else
                        {
                            var startInputRow = 2; // 最初のシート以降のシートのヘッダーをスキップ

                            // ヘッダーの後のシート行を一度にコピー
                            inputSheet.Cells[startInputRow, 1, inputEndRow, inputEndColumn].Copy(outputSheet.Cells[outputRowIndex, 1, outputRowIndex + inputEndRow, inputEndColumn]);
                            // 次の入力ファイルの行をコピーできるように出力ファイルの開始行番号を更新
                            outputRowIndex += inputEndRow - 1;
                        }
                        // 入力ファイルが処理終了後に、処理済のファイルのリストに表示
                        this.processedFiles.Add(inputFile);
                        await InvokeAsync(StateHasChanged);
                    }
                }
                // 出力ファイルを保存
                outputExcelPack.Save();
                outputExcelPack.Dispose();
            }
            // 実行時間の計測を終了
            stopwatch.Stop();
            // 実行時間を表示
            TimeSpan elapsedTime = stopwatch.Elapsed;
            processingTime = elapsedTime.Milliseconds;

            System.Diagnostics.Debug.Print($"ProcessWithEPPlus end ({elapsedTime}ms)");
        }
        catch (System.Exception error)
        {
            System.Diagnostics.Debug.Print(error.Message);
            System.Diagnostics.Debug.Print(error.Source);
            System.Diagnostics.Debug.Print(error.StackTrace);
            System.Diagnostics.Debug.Print(error.HelpLink);

        }
    }
    ```
## NPOI ライブラリ
1. **NPOI** でのスタブに処理実装を追加
    ```csharp
        private async Task ProcessWithNPOI(MouseEventArgs e)
    {
        System.Diagnostics.Debug.Print($"ProcessWithNPOI start");

        if (e is null)
        {
            throw new ArgumentNullException(nameof(e));
        }

        try {
            string resultFilePath = $"{outputFolderPath}\\Results_NPOI.xlsx";
            // 結合されたファイルを削除
            DeleteFile(resultFilePath);
            // 読み取りのファイルリスト
            string[] fileToProcessList = Directory.GetFiles(inputFolderPath);
            // 実行時間の計測を開始
            var stopwatch = Stopwatch.StartNew();
            // 出力ファイルを開く
            using (var fs = new FileStream(resultFilePath, FileMode.Create, FileAccess.Write))
            {
                // 出力Excelシートを作成
                IWorkbook outputWorkbook = new XSSFWorkbook();
                ISheet outputSheet = outputWorkbook.CreateSheet("リスト");
    
                int cellCount = 3;
                List<string> headers = new List<string>();
                int outputRowIndex = 1;
                // 各入力ファイルを処理
                foreach (var inputFile in fileToProcessList)
                {
                    // 入力ファイルを開く
                    ISheet inputSheet;
                    using (var inputFileStream = new FileStream(inputFile, FileMode.Open, FileAccess.Read))
                    {
                        // 入力した Excel シートをロード
                        inputFileStream.Position = 0;
                        XSSFWorkbook inputWorkbook = new XSSFWorkbook(inputFileStream);
                        inputSheet = inputWorkbook.GetSheet("リスト");
    
                        // ヘッダー情報がない場合は、最初の入力ファイルを開いたので、最初の行からヘッダー情報を取得
                        if (headers.Count <= 0)
                        {
                            // 最初の行の各列を処理
                            IRow inputHeaderRow = inputSheet.GetRow(0);
    
                            cellCount = inputHeaderRow.LastCellNum;
                            // 出力ファイルのヘッダー行を作成
                            IRow outputHeaderRow = outputSheet.CreateRow(0);
                            int columnIndex = 0;
                            // 最初の行の各列を処理
                            for (int j = 0; j < cellCount; j++)
                            {
                                string headerName = "N/A";
                                // 列のテキストをヘッダーとして取得
                                ICell inputCell = inputHeaderRow.GetCell(j);
                                if (inputCell != null && !string.IsNullOrWhiteSpace(inputCell.ToString()))
                                {
                                    headerName = inputCell.ToString() ?? "N/A";
                                }
                                // ヘッダー情報に追加
                                headers.Add(headerName);
                                // 出力ファイルのヘッダー行にコピー
                                outputHeaderRow.CreateCell(columnIndex).SetCellValue(headerName);
                                columnIndex++;
                            }
                        }
                        // ヘッダー行以降のシート行を一行づつでコピー
                        for (int i = (inputSheet.FirstRowNum + 1); i <= inputSheet.LastRowNum; i++)
                        {
                            // 入力ファイルの行を取得
                            IRow inputRow = inputSheet.GetRow(i);
                            // 行が存在し、データが含まれていることを確認
                            if (inputRow == null) continue;
                            if (inputRow.Cells.All(d => d.CellType == NPOI.SS.UserModel.CellType.Blank)) continue;
                            if (inputRow.FirstCellNum < 0)
                            {
                                continue;
                            }
                            // 出力ファイルの行を作成
                            IRow outputRow = outputSheet.CreateRow(outputRowIndex);
                            int outputCellIndex = 0;
                            // 行内の各セルを処理
                            for (int j = inputRow.FirstCellNum; j < cellCount; j++)
                            {
                                string outputCellContent = "N/A";
                                // セルが存在し、データが含まれていることを確認
                                if (inputRow.GetCell(j) != null)
                                {
                                    if (!string.IsNullOrEmpty(inputRow.GetCell(j).ToString()) && !string.IsNullOrWhiteSpace(inputRow.GetCell(j).ToString()))
                                    {
                                        // セルのテキストを取得
                                        ICell inputCell = inputRow.GetCell(j);
                                        outputCellContent = inputCell.StringCellValue ?? "N/A";
                                    }
                                }
                                // セルを作成し、取得されたテキストを挿入
                                outputRow.CreateCell(outputCellIndex).SetCellValue(outputCellContent);
                                outputCellIndex++;
                            }
                            outputRowIndex++;
                        }
                        // 入力ファイルが処理終了後に、処理済のファイルのリストに表示
                        this.processedFiles.Add(inputFile);
                        await InvokeAsync(StateHasChanged);
                    }
                }
                // 出力ファイルを保存
                outputWorkbook.Write(fs);
                outputWorkbook.Close();
            }
            // 実行時間の計測を終了
            stopwatch.Stop();
            // 実行時間を表示
            TimeSpan elapsedTime = stopwatch.Elapsed;
            processingTime = elapsedTime.Milliseconds;
    
            System.Diagnostics.Debug.Print($"ProcessWithNPOI end ({elapsedTime}ms)");
        }
        catch (System.Exception error)
        {
            System.Diagnostics.Debug.Print(error.Message);
            System.Diagnostics.Debug.Print(error.Source);
            System.Diagnostics.Debug.Print(error.StackTrace);
            System.Diagnostics.Debug.Print(error.HelpLink);

        }
    }
    ```
## OpenXML ライブラリ
### OpenXML
1. 普通の **OpenXML** でのスタブに処理実装を追加
    ```csharp
    private async Task ProcessWithOpenXML(MouseEventArgs e)
    {
        System.Diagnostics.Debug.Print($"ProcessWithOpenXML start");

        if (e is null)
        {
            throw new ArgumentNullException(nameof(e));
        }

        try {
            string resultFilePath = $"{outputFolderPath}\\Results_OpenXML.xlsx";
            // 結合されたファイルを削除
            DeleteFile(resultFilePath);
            // 読み取りのファイルリスト
            string[] fileToProcessList = Directory.GetFiles(inputFolderPath);
            // 実行時間の計測を開始
            var stopwatch = Stopwatch.StartNew();
            // 出力ファイルを開く
            using (SpreadsheetDocument document = SpreadsheetDocument.Create(resultFilePath, DocumentFormat.OpenXml.SpreadsheetDocumentType.Workbook))
            {
                // 出力Excelシートを作成
                WorkbookPart outputWorkbookPart = document.AddWorkbookPart();
                outputWorkbookPart.Workbook = new Workbook();

                WorksheetPart outputWorksheetPart = outputWorkbookPart.AddNewPart<WorksheetPart>();
                var outputSheetData = new SheetData();
                outputWorksheetPart.Worksheet = new Worksheet(outputSheetData);

                Sheets outputWorkbookSheets = outputWorkbookPart.Workbook.AppendChild(new Sheets());
                Sheet sheet = new Sheet() { Id = outputWorkbookPart.GetIdOfPart(outputWorksheetPart), SheetId = 1, Name = "リスト" };

                outputWorkbookSheets.Append(sheet);
                int cellCount = 3;
                List<string> headers = new List<string>();
                int outputRowIndex = 1;
                // 各入力ファイルを処理
                foreach (var inputFile in fileToProcessList)
                {
                    // 入力ファイルを開く
                    using (SpreadsheetDocument inputSpreadsheetDocument = SpreadsheetDocument.Open(inputFile, false))
                    {
                        // 入力した Excel シートをロード
                        WorkbookPart inputWorkbookPart = inputSpreadsheetDocument.WorkbookPart;
                        string inputSheetId = inputWorkbookPart.Workbook.Descendants<Sheet>().FirstOrDefault(s => s.Name.Equals("リスト"))?.Id;
                        Worksheet inputWorksheet = ((WorksheetPart)inputWorkbookPart.GetPartById(inputSheetId)).Worksheet;

                        SheetData inputSheetData = (SheetData)inputWorksheet.GetFirstChild<SheetData>();

                        bool isHeaderRow = true;
                        // 各行の処理
                        foreach (Row inputRow in inputSheetData)
                        {
                            // ヘッダー行の場合は
                            if (isHeaderRow == true)
                            {
                                isHeaderRow = false;
                                // ヘッダー情報がない場合は、最初の入力ファイルを開いたので、最初の行からヘッダー情報を取得
                                if (headers.Count <= 0)
                                {
                                    // 出力ファイルのヘッダー行を作成
                                    Row outputHeaderRow = new Row();
                                    // ヘッダー行の各セルの処理
                                    foreach (Cell inputCell in inputRow)
                                    {
                                        string headerName = "N/A";
                                        // セルにデータが含まれていることを確認し、テキストを取得
                                        if (inputCell != null && !string.IsNullOrWhiteSpace(inputCell.InnerText))
                                        {
                                            if (inputCell.DataType == CellValues.InlineString)
                                            {
                                                headerName = inputCell.InnerText;
                                            }
                                            else
                                            {
                                                int id = -1;
                                                // 共有文字列テーブルからヘッダーのテキストを取得
                                                if (Int32.TryParse(inputCell.InnerText, out id))
                                                {
                                                    SharedStringItem cellText = inputWorkbookPart.SharedStringTablePart.SharedStringTable.Elements<SharedStringItem>().ElementAt(id);
                                                    headerName = cellText.InnerText ?? "N/A";
                                                }
                                            }
                                        }
                                        headers.Add(headerName);
                                        // セルを作成し、取得されたテキストを挿入
                                        Cell outputCell = new Cell();
                                        outputCell.DataType = CellValues.String;
                                        outputCell.CellValue = new DocumentFormat.OpenXml.Spreadsheet.CellValue(headerName);
                                        // 出力セルを出力ヘッダー行に追加
                                        outputHeaderRow.AppendChild(outputCell);
                                    }
                                    // 出力ヘッダー行を出力シートに追加
                                    outputSheetData.AppendChild(outputHeaderRow);
                                }
                            }
                            // ヘッダー行以降の行の場合は
                            else
                            {
                                // 出力ファイルの行を作成
                                Row outputHeaderRow = new Row();
                                // 行内の各セルを処理
                                foreach (Cell inputCell in inputRow)
                                {
                                    // セルを作成
                                    Cell outputCell = new Cell();
                                    // 入力セルにテキストが含まれている場合は
                                    if (inputCell.DataType == CellValues.SharedString || inputCell.DataType == CellValues.String || inputCell.DataType == CellValues.InlineString)
                                    {
                                        outputCell.DataType = CellValues.String;
                                        string inputCellText = "";
                                        // セルにデータが含まれていることを確認し、テキストを取得
                                        if (!string.IsNullOrWhiteSpace(inputCell.InnerText))
                                        {
                                            if (inputCell.DataType == CellValues.InlineString)
                                            { // インライン文字列の場合は直接にセルの中でテキストが保存されます
                                                inputCellText = inputCell.InnerText;
                                            }
                                            else
                                            { // 共有文字列の場合は
                                                int id = -1;
                                                // 共有文字列テーブルからセルのテキストを取得
                                                if (Int32.TryParse(inputCell.InnerText, out id))
                                                {
                                                    SharedStringItem cellText = inputWorkbookPart.SharedStringTablePart.SharedStringTable.Elements<SharedStringItem>().ElementAt(id);
                                                    inputCellText = cellText.InnerText ?? "";
                                                }
                                            }
                                        }
                                        // 取得されたテキストを挿入
                                        outputCell.CellValue = new DocumentFormat.OpenXml.Spreadsheet.CellValue(inputCellText);
                                    }
                                    // 入力セルに数値が含まれている場合は
                                    else if (inputCell.DataType == CellValues.Number)
                                    {
                                        outputCell.DataType = CellValues.Number;
                                        outputCell.CellValue = inputCell.CellValue;
                                    }
                                    // 入力セルに他のものが含まれている場合は例外をスロー
                                    else
                                    {
                                        throw new ArrayTypeMismatchException(inputCell.DataType.ToString());
                                    }
                                    // 出力セルを出力行に追加
                                    outputHeaderRow.AppendChild(outputCell);
                                }
                                // 出力行を出力シートに追加
                                outputSheetData.AppendChild(outputHeaderRow);
                            }
                        }
                    }
                    // 入力ファイルが処理終了後に、処理済のファイルのリストに表示
                    this.processedFiles.Add(inputFile);
                    await InvokeAsync(StateHasChanged);
                    // 出力ファイルを保存
                    outputWorkbookPart.Workbook.Save();
                }
            }
            // 実行時間の計測を終了
            stopwatch.Stop();
            // 実行時間を表示
            TimeSpan elapsedTime = stopwatch.Elapsed;
            processingTime = elapsedTime.Milliseconds;

            System.Diagnostics.Debug.Print($"ProcessWithOpenXML end ({elapsedTime}ms)");
        }
        catch (System.Exception error)
        {
            System.Diagnostics.Debug.Print(error.Message);
            System.Diagnostics.Debug.Print(error.Source);
            System.Diagnostics.Debug.Print(error.StackTrace);
            System.Diagnostics.Debug.Print(error.HelpLink);

        }
    }
    ```
### OpenXML(SAX)
1. SAX の **OpenXML** でのスタブに処理実装を追加
    ```csharp
    private async Task ProcessWithOpenXMLSAX(MouseEventArgs e)
    {
        System.Diagnostics.Debug.Print($"ProcessWithOpenXML(SAX) start");
    
        if (e is null)
        {
            throw new ArgumentNullException(nameof(e));
        }
    
        try {
            string resultFilePath = $"{outputFolderPath}\\Results_OpenXML_SAX.xlsx";
            // 結合されたファイルを削除
            DeleteFile(resultFilePath);
            // 読み取りのファイルリスト
            string[] fileToProcessList = Directory.GetFiles(inputFolderPath);
            // 実行時間の計測を開始
            var stopwatch = Stopwatch.StartNew();
            // 出力ファイルを開く
            using (SpreadsheetDocument document = SpreadsheetDocument.Create(resultFilePath, DocumentFormat.OpenXml.SpreadsheetDocumentType.Workbook))
            {
                // 出力Excelシートを作成
                WorkbookPart outputWorkbookPart = document.AddWorkbookPart();
                outputWorkbookPart.Workbook = new Workbook();
                Sheets outputWorkbookSheets = outputWorkbookPart.Workbook.AppendChild(new Sheets());
                WorksheetPart outputWorksheetPart = outputWorkbookPart.AddNewPart<WorksheetPart>();
                Sheet sheet = new Sheet() { Id = outputWorkbookPart.GetIdOfPart(outputWorksheetPart), SheetId = 1, Name = "リスト" };
                outputWorkbookSheets.Append(sheet);
    
                // OpenXmlWriter を使用して出力ファイルに要素ごとに書き込みます
                OpenXmlWriter outputWriter = OpenXmlWriter.Create(outputWorksheetPart);
                // シート要素の開始タグを書きます
                outputWriter.WriteStartElement(new Worksheet());
                // SheetData要素の開始タグを書きます
                var outputSheetData = new SheetData();
                outputWriter.WriteStartElement(outputSheetData);
    
                // 出力ファイルの行を作成
                Row outputRow = new Row();
    
                bool hasCopiedHeaders = false;
                // 入力ファイルを開く
                foreach (var inputFile in fileToProcessList)
                {
                    bool isHeaderRow = true;
                    // 入力ファイルを開く
                    using (SpreadsheetDocument inputSpreadsheetDocument = SpreadsheetDocument.Open(inputFile, false))
                    {
                        // 入力した Excel シートをロード
                        WorkbookPart inputWorkbookPart = inputSpreadsheetDocument.WorkbookPart;
                        string inputSheetId = inputWorkbookPart.Workbook.Descendants<Sheet>().FirstOrDefault(s => s.Name.Equals("リスト"))?.Id;
                        WorksheetPart inputWorksheetPart = ((WorksheetPart)inputWorkbookPart.GetPartById(inputSheetId)).Worksheet.WorksheetPart;
    
                        // OpenXmlReader を使用してファイルを少しずつ読み取ります
                        OpenXmlReader inputReader = OpenXmlReader.Create(inputWorksheetPart);
                        while (inputReader.Read())
                        { // ファイル要素を 1 つずつ取得
                            if (hasCopiedHeaders == false || isHeaderRow == false)
                            { // ヘッダーをまだコピーしていない場合、またはコピーしていてもヘッダー/最初の行ではない場合は、見つかったものを出力ファイルにコピーします。
                                string inputCellText = "";
                                if (inputReader.ElementType == typeof(Row))
                                { // 行の場合
                                    if (inputReader.IsEndElement == false)
                                    { // 行開始タグの場合
                                      // 行開始タグを出力ファイルに書き込みます
                                        outputWriter.WriteStartElement(outputRow);
                                    }
                                    else
                                    { // 行終了タグの場合
                                      // 行終了タグを出力ファイルに書き込みます
                                        outputWriter.WriteEndElement();
                                    }
                                }
                                else if (inputReader.ElementType == typeof(InlineString))
                                { // インライン文字列の場合
    
                                      // テキストを取得
                                    inputCellText = inputReader.LoadCurrentElement().InnerText;
                                    // セルを作成し、取得されたテキストを挿入
                                    Cell outputCell = new Cell();
                                    outputCell.DataType = CellValues.String;
                                    DocumentFormat.OpenXml.Spreadsheet.CellValue outputCellValue = new DocumentFormat.OpenXml.Spreadsheet.CellValue(inputCellText);
                                    outputCell.AppendChild(outputCellValue);
                                    // セルを出力ファイルに書き込みます
                                    outputWriter.WriteElement(outputCell);
                                }
                                else if (inputReader.ElementType == typeof(Text))
                                { // テキストの場合
    
                                    // テキストを取得
                                    inputCellText = inputReader.LoadCurrentElement().InnerText;
                                    // セルを作成し、取得されたテキストを挿入
                                    Cell outputCell = new Cell();
                                    outputCell.DataType = CellValues.String;
                                    DocumentFormat.OpenXml.Spreadsheet.CellValue outputCellValue = new DocumentFormat.OpenXml.Spreadsheet.CellValue(inputCellText);
                                    outputCell.AppendChild(outputCellValue);
                                    // セルを出力ファイルに書き込みます
                                    outputWriter.WriteElement(outputCell);
                                }
                                else if (inputReader.ElementType == typeof(Cell))
                                { // セルの場合は
                                    Cell inputCell = (Cell)inputReader.LoadCurrentElement();
                                    // セルにデータが含まれていることを確認し、テキストを取得
                                    if (!string.IsNullOrWhiteSpace(inputCell.InnerText))
                                    {
                                        if (inputCell.DataType == CellValues.InlineString)
                                        { // インライン文字列の場合は直接にセルの中でテキストが保存されます
                                            inputCellText = inputCell.InnerText;
                                        }
                                        else
                                        { // 共有文字列の場合は
                                            int id = -1;
                                            // 共有文字列テーブルからセルのテキストを取得
                                            if (Int32.TryParse(inputCell.InnerText, out id))
                                            {
                                                SharedStringItem cellText = inputWorkbookPart.SharedStringTablePart.SharedStringTable.Elements<SharedStringItem>().ElementAt(id);
                                                inputCellText = cellText.InnerText ?? "";
                                            }
                                        }
                                    }
                                    // セルを作成し、取得されたテキストを挿入
                                    Cell outputCell = new Cell();
                                    DocumentFormat.OpenXml.Spreadsheet.CellValue outputCellValue = new DocumentFormat.OpenXml.Spreadsheet.CellValue(inputCellText);
                                    outputCell.AppendChild(outputCellValue);
                                    outputCell.DataType = CellValues.String;
                                    // セルを出力ファイルに書き込みます
                                    outputWriter.WriteElement(outputCell);
                                }
                                else if (inputReader.ElementType == typeof(NumberItem))
                                { // 数値の場合は
                                    // TODO: 実装
                                }
                            }
                            if (inputReader.ElementType == typeof(Row) && inputReader.IsEndElement == true)
                            { // 入力ファイルのヘッダー/最初の行を処理したら、「isHeaderRow」フラグを false に設定します。
                                isHeaderRow = false;
                                if (hasCopiedHeaders==false)
                                { // その上、最初の入力ファイルのヘッダー/最初の行である場合は、「hasCopiedHeaders」フラグを false に設定します。
                                    hasCopiedHeaders = true;
                                }
                            }
                        }
                        // 入力ファイルを閉じる
                        inputReader.Close();
                    }
                    // 入力ファイルが処理終了後に、処理済のファイルのリストに表示
                    this.processedFiles.Add(inputFile);
                    await InvokeAsync(StateHasChanged);
                }
                // SheetData要素の終了タグを書きます
                outputWriter.WriteEndElement();
                // シート要素の終了タグを書きます
                outputWriter.WriteEndElement();
    
                // 出力ファイルを閉じる
                outputWriter.Close();
            }
            // 実行時間の計測を終了
            stopwatch.Stop();
            // 実行時間を表示
            TimeSpan elapsedTime = stopwatch.Elapsed;
            processingTime = elapsedTime.Milliseconds;
    
            System.Diagnostics.Debug.Print($"ProcessWithOpenXML(SAX) end ({elapsedTime}ms)");
        }
        catch (System.Exception error)
        {
            System.Diagnostics.Debug.Print(error.Message);
            System.Diagnostics.Debug.Print(error.Source);
            System.Diagnostics.Debug.Print(error.StackTrace);
            System.Diagnostics.Debug.Print(error.HelpLink);
    
        }
    }
    ```
#### ボーナスステージ：行数と列数
1. ワークシートの寸法は SheetDimension 要素に文字列として保存されてます。それらを使用して列数と行数を計算します：

    ```csharp
    if (inputReader.ElementType == typeof(SheetDimension))
    {
        // 値があることを確認：
        if (inputReader.Attributes.Count == 1)
        {
            // ワークシートの寸法を取得します。 現在のサンプルの値文字列は"A1:C20001"であるはずです：
            var inputSheetDimensionString = inputReader.Attributes[0].Value;
            // 最初のセルと最後のセルを分けて：
            string[] dimensions = inputSheetDimensionString.Split(":");
            // 最後のセル番号のみを使用します：
            string lastCell = dimensions[1];
            // 行番号の最初の文字のインデックスを探ります：
            int rowIndex = 0;
            while(Char.IsLetter(lastCell[rowIndex]))
            {
                rowIndex++;
            }
            // まず、大文字のアルファベットベースの列番号を整数に変換します：
            int columnCount = lastCell.Substring(0, rowIndex).ToCharArray().Aggregate(0, (acc, x) => (acc * 26) + "ABCDEFGHIJKLMNOPQRSTUVWXYZ".IndexOf(x));
            // 次に、行番号を解析します：
            int rowCount = int.Parse(lastCell.Substring(rowIndex))+1;
        }
    }
    ```
    
## 比較表
比較すると、実行時間は実行ごとに大きく異なるようです...
|   | Closed<br>XML | EPPlus | NPOI | Open<br>XML | Open<br>XML<br>(SAX) |
|---|---|---|---|---|---|
| OpenXML | 2.X | 3.X | 3.X | 3.X | 3.X |
| 商用ライセンス | 無料 | 有料 | 無料 | 無料 | 無料 |
| 使い安さ | 〇 | 〇 | 〇 | △ | ✕ |
| 巨大ファイル<br>対応 | ✕ | ✕ | ✕ | ✕ | 〇 |
| ファイル<br>サイズ | 8,177 KB | 7,980 KB | 8,096 KB | 7,040 KB | 7,040 KB |
| OpenXML 2.X | 928ms | 478 ms | 262 ms | N/A | N/A |
| OpenXML 3.X ① | - | 72 ms | 682 ms | 24 ms ＊ | 733 ms ＊ |
| OpenXML 3.X ② | - | 29 ms | 612 ms | 947 ms ＊ | 304 ms ＊ |
| OpenXML 3.X ③ | - | 266 ms | 795 ms | 263 ms ＊ | 267 ms ＊ |
| OpenXML 3.X ④ | - | 320 ms | 156 ms | 828 ms ＊ | 407 ms ＊ |
| OpenXML 3.X ⑤ | - | 748 ms | 588 ms | 695 ms ＊ | 932 ms ＊ |
| OpenXML 3.X ⑥ | - | 735 ms | 375 ms | 943 ms ＊ | 807 ms ＊ |
| OpenXML 3.X ⑦ | - | 625 ms | 541 ms | 999 ms ＊ | 623 ms ＊ |
| OpenXML 3.X ⑧ | - | 472 ms | 656 ms | 240 ms ＊ | 721 ms ＊ |
| OpenXML 3.X ⑨ | - | 91 ms | 235 ms | 67 ms ＊ | 465 ms ＊ |
| OpenXML 3.X ⑩ | - | 650 ms | 441 ms | 246 ms ＊ | 162 ms ＊ |
| OpenXML 3.X ⑩ | - | 344 ms | 78 ms | 108 ms ＊ | 359 ms ＊ |
| 3.X → 中央値 | - | 438 ms | 565 ms | 479 ms ＊ | 436 ms ＊ |

＊ いつも遅く感じる

※ OpenXML 2.0で一回しかテストしてませんので数字の信頼度が低いです。

## 参考
使用した他の参考ページ：
- [EPPlus](https://www.thecodebuzz.com/read-write-excel-in-dotnet-core-epplus/)
- [NPOI](https://www.thecodebuzz.com/read-and-write-excel-file-in-net-core-using-npoi/)
- [OpenXML](https://www.thecodebuzz.com/read-excel-file-in-dotnet-core-2-1/)
- [OpenXML Sax](https://learn.microsoft.com/en-us/office/open-xml/spreadsheet/how-to-parse-and-read-a-large-spreadsheet?tabs=cs-2%2Ccs-3%2Ccs-4%2Ccs)