需求如下:

有多份要遞交客戶的文件,由於格式與內容經常要微調,故規劃以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以外的其他系統使用,算是不錯的解決方案。


Comments

# by 小傑

對不起, 請教一下 如果有頁首、頁尾的話, 要怎麼像內文一樣做替換. 謝謝!!

# by Jeffrey

to 小傑, 可透過Sections(m).Footers(n) 或 .Headers(n).Range存取到頁尾頁首,其中n=1-3,代表左中右三區,詳情可參考MSDN: http://msdn.microsoft.com/en-us/library/office/aa221968(v=office.11).aspx

# by KK

請教,若是環境無OFFICE 元件或軟體 ,想利用XML SDK 來做出 WORD ,再轉PDF, 有何解決之道..???

# by Jeffrey

to KK, 建議使用3rd Party元件較省事,Word轉PDF的元件選擇挺多,但多半需付費購買。

# by KaiYai

Jeffrey 您好: 目前做了一個Api提供User下載處理完的Word檔案(檔案142KB、文字不計其數、欄位4X個),在本機測試會發生以下2種須改進的情況 1.每次執行光Word就把CPU吃掉30% 2.無法同時執行多個,會一個處理完才做下一個(有是先將檔案複製一份出來修改) 想請教大大是否有遇過類似情況,謝謝

# by Jeffrey

to KaiYai, 需要一些資訊以判斷狀況: 1) API每次處理時會重建Word Application還是建立一次之後共用? 2) CPU為幾核? (4核CPU 30%與單核CPU 30%的意義不同) 3) 無法執行多個是指無法同時建多個Word Application? 我實際應用的寫法是建立1至4個Word Applcation,接著將要處理的範本跟欄位資料放入Queue裡,每個Word Appliation由一條Thread負責去Queue取回待處理的工作。換言之,任何時候只會有一條Thread在操作其專屬的Word Appliction,如此同時可以消化1-4個套表工作,依之前測試,12頁滿滿是文字的Word共13個欄位每次套表約1.2-2.5秒完成(CPU i7),以上數據供你參考。

# by KaiYai

Jeffrey 您好: 感謝您的回覆,目前狀況為 1.因API使用率不高,所以未建置Service去背景執行,而是每次執行時會重建Word Application,然後用相同的Word Application去產生出Word檔案 2.測試環境CPU為I5-4570 4核心,記憶體8G 3.無法執行多個指的是,UserA執行API,尚未完成Word產出時B也執行API,就會發生需要等A完成後才會開始產出B的Word 4.Word套用欄位數約45~50個,還有5個需要塞圖片,執行時間約15秒 可能是因為欄位數太多了,要掃很多次,所以花的時間比較久,目前找到較快的方式是把Range tmpRange = doc.Content;改成使用Selection tmpSelection = wordApp.Selection;,時間有降到約8秒 但是仍然未解決同時跑多個的問題

# by Jeffrey

to KaiYi, 依直覺你遇到的"慢"有一大部分源自"每次都重新建立Word Appication",依我的理解這部分會耗用蠻多的資源,建議,建立Word Application後,用Stopwatch單獨計時置換部分所耗時間,看看佔總時間的比例,如果其佔用的比例在60%以下,或許該考慮建立一個Word Application Pool,用Queue的方式處理進來的Request。至於無法同時執行的問題,應與資源鎖定有關,好奇問一下,API是跑在ASP.NET裡嗎? 如果是,有可能是Session鎖定造成。

# by pohsun

Jeffrey大大,近日正遇到資料需要套印後以pdf輸出,您的大作幫上超級大忙!太感謝了! 但現在有個最後的問題,就是依範例完成後可以輸出第一頁,但第二頁之後的還在苦惱如何產出!(每頁不同處為其文件序號) 是否可以幫忙指點迷津! 另有先用原件產出成doc後由您的方法存成pdf,一次轉換670頁,大約需要1min(看著工作管理員發個呆就完成了),也在思考若是多入同時使用時,應該如果架構這功能才是! 謝謝您!

# by Jeffrey

to pohsun, 每頁文件序號不同的需求,我覺得用套表解決效率不佳,應該可透過VBA程式操作Word用第一頁內容複製新頁,再改掉文件序號。你可以手動複製、修改,錄製成巨集再參考裡面的VBA做法。

# by Johnny

什麼是Word Application Pool要是什麼?有沒有範例?

# by Jeffrey

to Johnny, 開多條Thread跑多個物件Instance,每個Instance有自已專屬的Word,要執行的工作丟進JobQueue,各Instance再去Queue取出Job,做完把結果丟回去。詳細概念可參考這篇:http://columns.chicken-house.net/2008/10/18/%E7%94%9F%E7%94%A2%E8%80%85-vs-%E6%B6%88%E8%B2%BB%E8%80%85-blockqueue-%E5%AF%A6%E4%BD%9C/

# by 北極大西瓜

最近看到一個類似的 Word套表與轉存PDF 套件 NuGet 搜尋 FreeSpire.Doc 好像也不錯用 推薦給黑大

# by Jeffrey

to 北極大西瓜,感謝情報,排入工作清單。

# by SS

請問DocX要怎麼將文字取代成圖片呢?上網爬文了許多,但都沒看到相關文章。

# by Jeffrey

to SS, 希望這篇會有幫助 https://blog.darkthread.net/blog/insert-image-to-docx/

# by Tony KE

請問要怎麼樣用程式產生一個帶有功能變數的docs檔案? 我看到的很多例子都是去使用..沒有產生

# by Jeffrey

to Tony KE, 可參考這個範例(關鍵字 SimpleField) https://stackoverflow.com/q/47748525/288936

# by Leo

請問,如果要替換的欄位key是無值,要在哪一段落寫過濾或替換的語法? 因目前key值欄位,有資料的部分都會成功寫入,但無資料的欄位卻會把key一併顯示,例如[$$ + key + $$]

# by Jeffrey

to Leo, 無值的欄位寫成 fields.Add("key_wo_value", ""); 可以避開問題嗎?

# by Leo

To Jeffery, 謝謝你的建議,已經成功解決了。非常感謝你

# by Leo

To Jeffrey 謝謝你的建議,已經成功解決了。非常感謝你 (上一則回覆,你的名字打錯 @@",重發一篇)

Post a comment