需求如下:
有多份要遞交客戶的文件,由於格式與內容經常要微調,故規劃以Word檔形式由使用者自行編排修改。執行時由程式套版查詢資料庫後置換其中欄位,並以PDF格式輸出。
Word套版這事兒已是老生常談,但這回的特殊需求是必須轉成PDF格式。原本盤算用OpenXML SDK處理套版,再用第三方元件將Word轉成PDF,研究後發現Word內建的轉存PDF功能出奇的簡單,而Word本身的搜尋取代功能拿來處理套版也綽綽有餘,拍板定案 -- 就用Office Automation吧!
套版加轉PDF的程式碼如下:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Office.Interop.Word;
namespace WordToPdfService
{
publicclass PdfConverter : IDisposable
{
private Application wordApp = null;
public PdfConverter()
{
wordApp = new Application();
wordApp.Visible = false;
}
publicbyte[] GetPdf(string templateFile, Dictionary<string, string> fields)
{
object filePath = templateFile;
//檔案先寫入系統暫存目錄
object outFile =
Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".pdf");
Document doc = null;
try
{
object readOnly = true;
doc = wordApp.Documents.Open(FileName: ref filePath, ReadOnly: ref readOnly);
doc.Activate();
Stopwatch sw = new Stopwatch();
sw.Start();
//REF: http://bit.ly/Z9G5zg
Range tmpRange = doc.Content;
tmpRange.Find.Replacement.Highlight = 0; //去除醒目提示(Highlight)
tmpRange.Find.Wrap = WdFindWrap.wdFindContinue;
object replaceAll = WdReplace.wdReplaceAll;
foreach (string key in fields.Keys)
{
tmpRange.Find.Text = "[$$" + key + "$$]";
tmpRange.Find.Replacement.Text = fields[key];
tmpRange.Find.Execute(Replace: ref replaceAll);
}
sw.Stop();
Debug.WriteLine("Replaced in {0:N0}ms", sw.ElapsedMilliseconds);
//釋放Range COM+
Marshal.FinalReleaseComObject(tmpRange);
tmpRange = null;
//存成PDF檔案
object fileFormat = WdSaveFormat.wdFormatPDF;
doc.SaveAs2(FileName: ref outFile, FileFormat: ref fileFormat);
//關閉Word檔
object dontSave = WdSaveOptions.wdDoNotSaveChanges;
((_Document)doc).Close(ref dontSave);
}
finally
{
//確保Document COM+釋放
if (doc != null)
Marshal.FinalReleaseComObject(doc);
doc = null;
}
//讀取PDF檔,並將暫存檔刪除
byte[] buff = File.ReadAllBytes(outFile.ToString());
File.Delete(outFile.ToString());
return buff;
}
publicvoid Dispose()
{
//確實關閉Word Application
try
{
object dontSave = WdSaveOptions.wdDoNotSaveChanges;
((_Application)wordApp).Quit(ref dontSave);
}
finally
{
Marshal.FinalReleaseComObject(wordApp);
}
}
}
}
程式碼不複雜,只有幾個小地方要補充:
- Word活在Unmanaged世界,故使用完畢要確實用Marshal.FinalReleaseComObject釋放資源,並明確結束應用程式(Excel也有相同議題),否則.NET程式結束時,將無法自動清除佔用的Unmanaged資源。我寫了一個PdfConverter類別並實作IDisposable,在其中建立一個Word Applicatoin物件,並在IDispose()時確實結束它。如此,當外界透過using方式使用PdfConverter,可有效降低程式結束後殘留Word應用程式的風險。
- Word方法接受的參數都是傳址物件,故即便是true/false,也要先object flag = true,再以ref flag方式傳入,不能直接傳true/false。而.NET 4.0的具名參數在此大顯神威,讓我們在呼叫Word方法時只需傳入指定的參數項目,不用填入一堆missing。
- 要置換的欄位以Dictionary<string, string>方式傳入,程式一一取其Key,組成[$$KeyName$$]後搜尋文件中出現的地方並置換成Value值(但保留其字型、大小、顏色等設定),達到套表的目的。
- 實務上維護套表範本時,多期望在動態置換欄位處加上標示,以便能在檢視文件時能"一望即知"(看到這詞我就想趕一下羚羊)哪些地方的內容是動態的。套版程式允許為欄位加上Word的醒目提示(Highlight),在置換文件時會一併將醒目提示清除。
接著用個實例做測試,範本文件如下: (謎之聲: 奴才知道主子很想中樂透,但容奴才說兩句: 這張怎麼看都像詐騙信!)
建立PdfConverter物件,指定範本路徑,再傳入Dictionary<string, string>欄位資料,就能生出PDF檔囉!
Dictionary<string, string> fields = new Dictionary<string, string>();
fields.Add("Seq", "32767");
fields.Add("LetterDate", DateTime.Today.ToString("yyyy年M月d日"));
fields.Add("Name", "黑暗執行緒");
fields.Add("Date", new DateTime(2012,12,21).ToString("yyyy年M月d日"));
fields.Add("Amount", int.MaxValue.ToString("N0"));
fields.Add("TelNo", "0800092000");
fields.Add("AgentName", "林志玲");
fields.Add("AgentTitle", "副理");
//使用using確保Word資源被釋放
using (var cvtr = new PdfConverter())
{
var buff =
cvtr.GetPdf(Path.Combine(
System.AppDomain.CurrentDomain.BaseDirectory,
"templates\\notice.docx"), fields);
File.WriteAllBytes("d:\\Temp\\" + Guid.NewGuid() + ".pdf", buff);
}
Console.WriteLine("Done");
Console.ReadLine();
產生結果如下: (謎之聲: 很好,這下子確定是詐騙無誤了!)
【後記】
以前述範本為例實測,套表約0.1秒,存PDF約0.9秒,但整個過程(含啟動Word Application及結束)卻要4秒。因此 ---不建議把前述範例整個搬進網頁執行,每個Web Request自己開啟一份Word Application在太過奢華,資源利用不符經濟效益且效能欠佳;在Web Application中設法建立共用機制,啟動多份Word Appliation消化套版轉檔需求是一種解法,但會有執行身分(ASP.NET多半會用權限較低的帳號執行)及程序生命週期的問題要傷腦筋。
而我想到的另一種做法是改採Console Application或Windows Service方式執行,開啟指定數量的PdfConverter(意味著只會開啟指定數量的Word Application,理論上與CPU核心數目相同時可達到最大產能)組成Pool,提供介面接收轉換需求,由Pool中的PdfConverter分擔處理,應該可以達到較佳的運作效率。如此可視為獨立的服務程式,可任意指定執行身分,管理監控方便,還能提供套表轉檔服務給Web以外的其他系統使用,算是不錯的解決方案。