Word套表與轉存PDF 轉貼

  • 2087
  • 0

轉載自黑暗執行緒:https://blog.darkthread.net/blog/word-to-pdf/     個人學習紀錄

需求如下:

有多份要遞交客戶的文件,由於格式與內容經常要微調,故規劃以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
{
    public class PdfConverter : IDisposable
    {
        private Application wordApp = null;
 
        public PdfConverter()
        {
            wordApp = new Application();
            wordApp.Visible = false;
        }
 
        public byte[] 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;
        }
 
        public void Dispose()
        {
            //確實關閉Word Application
            try
            {
                object dontSave = WdSaveOptions.wdDoNotSaveChanges;
                ((_Application)wordApp).Quit(ref dontSave);
            }
            finally
            {
                Marshal.FinalReleaseComObject(wordApp);
            }
        }
    }
}

程式碼不複雜,只有幾個小地方要補充:

  1. Word活在Unmanaged世界,故使用完畢要確實用Marshal.FinalReleaseComObject釋放資源,並明確結束應用程式(Excel也有相同議題),否則.NET程式結束時,將無法自動清除佔用的Unmanaged資源。我寫了一個PdfConverter類別並實作IDisposable,在其中建立一個Word Applicatoin物件,並在IDispose()時確實結束它。如此,當外界透過using方式使用PdfConverter,可有效降低程式結束後殘留Word應用程式的風險。
  2. Word方法接受的參數都是傳址物件,故即便是true/false,也要先object flag = true,再以ref flag方式傳入,不能直接傳true/false。而.NET 4.0的具名參數在此大顯神威,讓我們在呼叫Word方法時只需傳入指定的參數項目,不用填入一堆missing。
  3. 要置換的欄位以Dictionary<string, string>方式傳入,程式一一取其Key,組成[$$KeyName$$]後搜尋文件中出現的地方並置換成Value值(但保留其字型、大小、顏色等設定),達到套表的目的。
  4. 實務上維護套表範本時,多期望在動態置換欄位處加上標示,以便能在檢視文件時能"一望即知"(看到這詞我就想趕一下羚羊)哪些地方的內容是動態的。套版程式允許為欄位加上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以外的其他系統使用,算是不錯的解決方案。