再探文件套表 - Word套表與轉存PDF
22 |
需求如下:
有多份要遞交客戶的文件,由於格式與內容經常要微調,故規劃以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);
}
}
}
}
程式碼不複雜,只有幾個小地方要補充:
- 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以外的其他系統使用,算是不錯的解決方案。
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 謝謝你的建議,已經成功解決了。非常感謝你 (上一則回覆,你的名字打錯 @@",重發一篇)