用 80 行 C# 程式實現 Word 套版功能
32 |
Word 套版在本站是個老話題了,我最早是啟動 Word 程式用 Office Automation 處理(過程 Word 很容易當掉沒反應,不太好搞),Word 2007 docx 普及後我開始改用更可靠有效率的 OpenXML SDK,但當時只是不成熟未經實戰的構想,應讀者們敲碗多時,我還是分享了有缺陷的 PoC 版本。
看看舊文日期,居然已是十年前的事。
近一年來因專案需求對 Word OpenXML SDK 操作有較深入了解,學到不少實務技巧,回頭看當年缺陷版遇到的難題,如今已不再令我發愁,今天這篇分享,也算技能成長的見證吧,哈!
為防有人不知道 Word 套版是什麼,還是簡單解釋一下 - Word 套版概念源自我們想用程式從資料庫或其他來源抓資料動態產生 Word 檔,實務上對輸出文件格式常有許多規格、排版要求,全部靠程式處理包你寫到流淚,更別提每次改一個字調個顏色就要改程式多讓人崩潰。
因此,套版是較有效率的方法。請使用者自己先用 Word 依規格、喜好做出範本檔,需要動態放入文字的地方再以 [$ParserTag$] 之類的符號註記,如以下的例子:
接著我們用一小段程式便能將動態內容套入範本產生文件:
static void Example01_WordTmplRendering()
{
var docxBytes = WordRender.GenerateDocx(File.ReadAllBytes("AnnounceTemplate.docx"),
new Dictionary<string, string>()
{
["Title"] = "澄清黑暗執行緒部落格併購傳聞",
["SeqNo"] = "2021-FAKE-001",
["PubDate"] = "2021-04-01",
["Source"] = "亞太地區公關部",
["Content"] = @"
坊間媒體盛傳「史塔克工業將以美金 18 億元併購黑暗執行緒部落格」一事,
本站在此澄清並無此事。\n\n
史塔克公司執行長日前確實曾派遣代表來訪,雙方就技術合作一事交換意見,
相談甚歡,惟本站暫無出售計劃,且傳聞金額亦不符合本站預估價值(謎之聲:180 元都嫌貴),
純屬不實資訊。\n\n
本站將秉持初衷,持續發揚野人獻曝、敝帚自珍精神,歡迎舊雨新知繼續支持。"
});
File.WriteAllBytes(
Path.Combine(ResultFolder, $"套表測試-{DateTime.Now:HHmmss}.docx"),
docxBytes);
}
之後若文件排版或固定文字需要修改,請使用者提供修改過的範本檔更新即可,程式完全不用動,是不是很美妙?
十年前 PoC 版本有個問題 - 由於它並不解析 OpenXML 元素,而是直接將 XML 中的 [$ParserTag$] 換成對映文字,但實務上你看到的 [$ParserTag$] 在 XML 中幾乎不會連在一起,多半長成這樣:
要克服這個問題倒也不難,解析 OpenXML Word 文件的 Paragraph / Run / Text 結構,逐一比對相鄰 Run 的文字內容一定可還原出 [$ParserTag$] 文字內容,再進行置換即可。而這裡有個技巧,為提升在茫茫元素大海找出目標的效率並減少誤判,我會請使用者在製作範本檔時為 [$ParserTag$] 加上「醒目提示」(如下圖)。 醒目提示的顏色不限,可配合當下文字顏色,反正它們在產生文件時會被移除:
另外針對長段文字需強制換行的需求,我增加了一個「將字串內容 "\n" (反斜線加 n 兩個字元) 轉成強制換行」(相當 Word 按 Shift-Enter 的效果)的規格。
核心程式不到 80 行,實測了一陣子目前沒遇到什麼問題,就提供有需要的同學們參考吧!
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace Examples
{
public static class WordRender
{
static void ReplaceParserTag(this OpenXmlElement elem, Dictionary<string, string> data)
{
var pool = new List<Run>();
var matchText = string.Empty;
var hiliteRuns = elem.Descendants<Run>() //找出鮮明提示
.Where(o => o.RunProperties?.Elements<Highlight>().Any() ?? false).ToList();
foreach (var run in hiliteRuns)
{
var t = run.InnerText;
if (t.StartsWith("["))
{
pool = new List<Run> { run };
matchText = t;
}
else
{
matchText += t;
pool.Add(run);
}
if (t.EndsWith("]"))
{
var m = Regex.Match(matchText, @"\[\$(?<n>\w+)\$\]");
if (m.Success && data.ContainsKey(m.Groups["n"].Value))
{
var firstRun = pool.First();
firstRun.RemoveAllChildren<Text>();
firstRun.RunProperties.RemoveAllChildren<Highlight>();
var newText = data[m.Groups["n"].Value];
var firstLine = true;
foreach (var line in Regex.Split(newText, @"\\n"))
{
if (firstLine) firstLine = false;
else firstRun.Append(new Break());
firstRun.Append(new Text(line));
}
pool.Skip(1).ToList().ForEach(o => o.Remove());
}
}
}
}
public static byte[] GenerateDocx(byte[] template, Dictionary<string, string> data)
{
using (var ms = new MemoryStream())
{
ms.Write(template, 0, template.Length);
using (var docx = WordprocessingDocument.Open(ms, true))
{
docx.MainDocumentPart.HeaderParts.ToList().ForEach(hdr =>
{
hdr.Header.ReplaceParserTag(data);
});
docx.MainDocumentPart.FooterParts.ToList().ForEach(ftr =>
{
ftr.Footer.ReplaceParserTag(data);
});
docx.MainDocumentPart.Document.Body.ReplaceParserTag(data);
docx.Save();
}
return ms.ToArray();
}
}
}
}
A simple example to rendering Word document from template docx with OpenXML SDK.
Comments
# by Albert
這個好用。趕快收起來。謝謝黑大的分享!!
# by ByTIM
公告日期:四月一日 (誤...我搞錯重點了!
# by Spider
請問黑大, 如果是列表形式的報表, 也能這樣套嗎? 一直在找Crystal Report的替代方案, 謝謝!
# by Jeffrey
to Spider,產生表格功能我也完成 PoC 了,過陣子會分享。
# by Spider
黑大, 非常感激!
# by 彰
不知哪裡有誤~~~ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Drawing; using DocumentFormat.OpenXml.Packaging; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Web; using System.Web.UI; using System.Web.UI.WebControls; namespace word { public partial class index : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { } protected void Unnamed1_Click(object sender, EventArgs e) { // string path = @"Document/123.docx"; string path = Server.MapPath("Document/123.docx"); // 路徑 var docxBytes = Examples.WordRender.GenerateDocx(File.ReadAllBytes(path), new Dictionary<string, string>() { ["abc"] = "澄清黑暗執行緒部落格併購傳聞", ["gg"]="333", ["hh"] = "333" }); string PathS = Server.MapPath("Document"); // 路徑 File.WriteAllBytes( System.IO.Path.Combine(PathS, $"套表測試-{DateTime.Now:HHmmss}.docx"), docxBytes); } } namespace Examples { public static class WordRender { static void ReplaceParserTag(this OpenXmlElement elem, Dictionary<string, string> data) { var pool = new List<Run>(); var matchText = string.Empty; var hiliteRuns = elem.Descendants<Run>() //找出鮮明提示 .Where(o => o.RunProperties?.Elements<Highlight>().Any() ?? false).ToList(); foreach (var run in hiliteRuns) { var t = run.InnerText; if (t.StartsWith("[")) { pool = new List<Run> { run }; matchText = t; } else { matchText += t; pool.Add(run); } if (t.EndsWith("]")) { var m = Regex.Match(matchText, @"\[\$(?<n>\w+)\$\]"); if (m.Success && data.ContainsKey(m.Groups["n"].Value)) { var firstRun = pool.First(); firstRun.RemoveAllChildren<Text>(); firstRun.RunProperties.RemoveAllChildren<Highlight>(); var newText = data[m.Groups["n"].Value]; var firstLine = true; foreach (var line in Regex.Split(newText, @"\\n")) { if (firstLine) firstLine = false; else firstRun.Append(new Break()); firstRun.Append(new Text(line)); } pool.Skip(1).ToList().ForEach(o => o.Remove()); } } } } public static byte[] GenerateDocx(byte[] template, Dictionary<string, string> data) { using (var ms = new MemoryStream()) { ms.Write(template, 0, template.Length); using (var docx = WordprocessingDocument.Open(ms, true)) { docx.MainDocumentPart.HeaderParts.ToList().ForEach(hdr => { hdr.Header.ReplaceParserTag(data); }); docx.MainDocumentPart.FooterParts.ToList().ForEach(ftr => { ftr.Footer.ReplaceParserTag(data); }); docx.MainDocumentPart.Document.Body.ReplaceParserTag(data); docx.Save(); } return ms.ToArray(); } } } } }
# by Jeffrey
to 彰,能把有問題的專案放上 Github 方便大家重現問題幫忙抓蟲嗎?
# by kevin
感謝您無私地分享~ 在本次公司專案上剛好有用到. ^____^
# by Jasonyen
ResultFolder 定義在哪, 找不到
# by Jeffrey
to Jasonyen, 加個 string ResultFolder = "你要輸出的路徑"; 就行了,這部分依執行電腦決定,故沒附在範例程式中。
# by Adam
http://game.nowforyou.com/ko5c/test/word/index.htm Thanks
# by Jeffrey
to Adam,漂亮!! 程式碼部分會開源讓大家觀摩嗎?
# by Adam
感恩,請mail to cmas.tw@gmail.com
# by Decimal458
請問如果我今天是讀取.odt檔,有什麼方法嗎? 我目前的想法是 1. 複製一份然後重新命名成.zip (test.odt => test.odt.zip) 2. 讀取及解析zip中的Content.xml 3. 取代標籤文字([$example$] => 黑暗執行緒) 4. 儲存後將副檔名的.zip刪除 不知有沒有更好的方法?
# by Decimal458
另外在 "foreach (var line in Regex.Split(newText, @"\\n"))" 中的 \\n 是不是要改成 \n ? 因為按照原先的寫法,我丟 "test1\ntest2\ntest3"無法換行
# by Jeffrey
to Decimal458,因應長段文字需強制換行的需求,我增加了一個「將字串內容 "\n" (反斜線加 n 兩個字元) 轉成強制換行」(相當 Word 按 Shift-Enter 的效果)的規格,如要強制換行,要寫成 "test1\\ntest2\\ntest3""。 odt 我沒研究過,但它跟 docx 一樣是 XML,我應該也會採取跟你一樣的解法。
# by CHONGMAN
請問各位大大, 有誰人可以幫我轉成VB.NET CODE, 我用不到呀!
# by CHONGMAN
各位大大門, 這行CODE我看不明白, Compile errors var hiliteRuns = elem.Descendants<Run>() //找出鮮明提示 .Where(o => o.RunProperties?.Elements<Highlight>().Any() ?? false).ToList();
# by Jeffrey
to CHONGMAN,?? 是所謂 Null-Coalescing Operator https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/null-coalescing-operator 。VB.NET 沒有,要改用 If(..., ...) https://stackoverflow.com/a/403453/288936
# by CHONGMAN
to Jeffrey 大大, 我都是唔明白, 段CODE都是COMPILE不到,有冇COMPILE 好的DLL提供, 我直接加來用, 其他大大有都可以提供下已COMILE 好的DLL。謝謝!
# by James
請問如果有個ListData要套印word,要怎麼改呢? 用版大的code只能輸出一個人的資料,多人的不知道要怎麼接續下去?
# by KENT
請問為什麼輸入這三行就會先跑錯誤出來? 要先設定什麼嗎? using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing;
# by KENT
不好意思,上述問題已解決!
# by KENT
請問Word模板工能 有辦法讓他也貼上圖片嗎?
# by BE
您好,想請問一下如果是要生成或開啟odt檔案是否要搭配其他第三方套件了呢?
# by Jeffrey
to BE,沒研究過,但有查到一篇 https://cheyi.idv.tw/wp/2019/06/22/word2odt/
# by Jeffrey
to KENT, 可行,但得花點心思整合進去 https://blog.darkthread.net/blog/insert-image-to-docx/
# by Bin
Hi, Jeffrey 感謝你的分享,我有遇到一個問題,套表後,某些欄位會變成 MERGEFIELD "欄位名稱" #.##0的格式,請問這是甚麼問題呢?
# by Paul
您好, 請問您有使用過任何API產生word檔案, 將前端HtmlEditor的content寫入.docx嗎? 同一行裡有文字, 也有圖片, 這個case該怎麼處理呢?
# by Jeffrey
to Paul, 插入圖片的做法可參考這篇: https://blog.darkthread.net/blog/insert-image-to-docx/
# by wisen
非常感謝 套版問題現在工作也有遇到 需要動態產生一個大報告 弄到頭昏眼花....尤其是直接在word範本裡Key想要替換的標籤例如[T: test1 :T] 轉換成xml後會直接被切割成好幾行 頭很痛
# by Jeffrey
to wisen,你說的痛苦我懂,我也是好幾年後來才找到「醒目提示」這個最佳解。