# 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#) ```csharp 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 時畫面可能因焦點遺失而顯示全白(需手動點選下方分頁才看得到內容)。