Files
thinkyu/教育訓練系統/Web/DOC/NPOI 2.1 Excel 報表.md
sryang 577060bc78 chore: 首次簽入 Thinkyu ASP.NET 專案
- 加入 Visual Studio / ASP.NET .gitignore
- 排除建置輸出、IDE 設定、NuGet packages、大型 MSI 安裝檔
2026-09-10 09:42:37 +08:00

7.1 KiB

NPOI 2.1 (.NET 3.5) 跨版本 Excel 列印跑版終極解決方案文件

1. 問題背景與根本原因

在使用 NPOI 2.1 配合 .NET Framework 3.5 的舊系統環境下,產出的 Excel 報表在不同版本(例如 Excel 2010/2016 與 Excel 2021/2024)直接列印時,會出現「橫向切到下一頁」或「右側多出一欄空白」的排版不一致問題。

核心原因分析:

  1. 字型渲染引擎演進(標楷體效應):新舊版 Excel 對「標楷體」的字元寬度計算像素不同(GDI vs DirectWrite)。相同的標楷體文字在新版中渲染出來的總寬度通常會多出幾個像素。如果文字因為多出的像素而折行,雖然目前系統列高預留得夠高、高度沒差,但依然會觸發 Excel 盲測邊界失準,導致橫向被切頁或多印空白欄。
  2. NPOI 2.1 底層 Bug:原生的 workbook.SetPrintArea 存在著名的 XML 節點建構 Bug,在新版 Excel 中會直接導致導出檔案被判定為「檔案已損毀,需要修復」,因此無法使用設定列印區域的方式來限制範圍。
  3. 多人連線檔案鎖定:如果直接操作、修改並儲存伺服器上的實體模板,在 Web 多人同時連線產出報表時,會引發「檔案被另一個程序佔用」的嚴重衝突。

2. 終極解決方案架構:記憶體隔離克隆法 (Thread-Safe)

不要建立全新的 Workbook 並逐一複製樣式,也絕不污染實體模板。改用以唯讀模式將模板讀入記憶體 ➔ 在記憶體中進行 Sheet 克隆與字串替換 ➔ 清理邊界 ➔ 存檔前刪除樣板 的閉環流程。

核心開發流程與程式碼實作 (C#)

using System;
using System.IO;
using NPOI.SS.UserModel;

public class ExcelReportGenerator
{
    public void GenerateReport()
    {
        string templatePath = @"C:\Templates\ReportTemplate.xlsx";
        string outputPath = @"C:\Outputs\FinalReport.xlsx";
        
        // 預期報表的最大總欄數(假設固定為 8 欄,索引 0 ~ 7)
        int maxValidColumnIndex = 7; 

        // 1. 使用 FileShare.Read 唯讀模式開啟實體檔案,保證多人同時連線不鎖檔
        using (FileStream fs = new FileStream(templatePath, FileMode.Open, FileAccess.Read, FileShare.Read))
        {
            // 2. 將模板內容完整載入記憶體
            byte[] templateBytes;
            using (MemoryStream ms = new MemoryStream())
            {
                byte[] buffer = new byte[1024 * 4];
                int read;
                while ((read = fs.Read(buffer, 0, buffer.Length)) > 0)
                {
                    ms.Write(buffer, 0, read);
                }
                templateBytes = ms.ToArray();
            }

            // 3. 在完全隔離的記憶體中建立獨立的 Workbook 物件
            using (MemoryStream workbookMs = new MemoryStream(templateBytes))
            {
                IWorkbook workbook = WorkbookFactory.Create(workbookMs);

                // 4. 利用 CloneSheet 完美克隆樣板,完整帶走字型、預設邊界與頁面配置
                int templateIndex = 0; // 假設原始樣板在第一個 Sheet
                ISheet newSheet = workbook.CloneSheet(templateIndex);
                workbook.SetSheetName(workbook.GetSheetIndex(newSheet), "正式報表");

                // 5. 執行您原有的 Find and Replace 邏輯
                // 註:標楷體即使因為版本差而折行,因高度安全故不會影響縱向分頁
                PerformFindAndReplace(newSheet);

                // ============================================================
                // 防線一:補上底層 PageSetup 縮放,強制鎖定橫向寬度(根治「切到下一頁」)
                // ============================================================
                newSheet.FitToPage = true; 
                newSheet.PrintSetup.Scale = 0; // 關鍵:在 NPOI 2.1 中,必須將 Scale 歸零,FitWidth 才會在所有 Excel 版本中生效
                newSheet.PrintSetup.FitWidth = 1;  // 強制所有欄位橫向壓縮在一頁內
                newSheet.PrintSetup.FitHeight = 0; // 高度設為 0,代表縱向不限制,隨資料自然往下延伸

                // ============================================================
                // 防線二:徹底拔除右側隱形/殘留儲存格(替代 SetPrintArea,根治「多一欄」)
                // ============================================================
                for (int rowNum = 0; rowNum <= newSheet.LastRowNum; rowNum++)
                {
                    IRow row = newSheet.GetRow(rowNum);
                    if (row == null) continue;

                    // 如果 NPOI 判定此行在 XML 中的最後一欄超過了實質資料的上限
                    if (row.LastCellNum > (maxValidColumnIndex + 1))
                    {
                        // 倒序迴圈,將指定欄位之後的所有隱形/空儲存格物件物理性地徹底拔除
                        for (int c = row.LastCellNum - 1; c > maxValidColumnIndex; c--)
                        {
                            ICell cell = row.GetCell(c);
                            if (cell != null)
                            {
                                row.RemoveCell(cell); 
                            }
                        }
                    }
                }

                // ============================================================
                // 防線三:安全刪除原始樣板工作表,避開打開空白與重複列印
                // ============================================================
                // 技巧:先將活動焦點(ActiveSheet)移至新報表,再進行刪除
                workbook.SetActiveSheet(workbook.GetSheetIndex(newSheet));
                workbook.RemoveSheetAt(templateIndex);

                // 6. 將專屬的報表匯出成獨立新檔(或直接 Response 給瀏覽器下載)
                using (FileStream outFs = new FileStream(outputPath, FileMode.Create, FileAccess.Write))
                {
                    workbook.Write(outFs);
                }
            }
        }
    }

    private void PerformFindAndReplace(ISheet sheet)
    {
        // 您的 Find and Replace 實作代碼
        // 提示:只更新 cell.SetCellValue(),絕對不要重新 CreateCell(),以完整保留標楷體格式與邊框
    }
}

3. 關鍵執行與相容性檢查清單

  1. 只換值、不重造:在 Find and Replace 時,請直接使用 cell.SetCellValue(newValue),不要重新 CreateCell,否則會把原模板上精心設計的標楷體字型、置中、邊框等 CellStyle 屬性完全洗掉。
  2. Scale = 0 是必備開關:在 NPOI 2.1 底層 XML 結構中,如果沒有明確指定 PrintSetup.Scale = 0,部分 Office 版本在讀取 FitWidth = 1 時會發生衝突進而失效。
  3. 活動焦點移轉:在呼叫 workbook.RemoveSheetAt(0) 刪除空白樣板前,必須先呼叫 workbook.SetActiveSheet() 鎖定新工作表,否則使用者開啟 Excel 時畫面可能因焦點遺失而顯示全白(需手動點選下方分頁才看得到內容)。