Word 套版在本站是個老話題了,我最早是啟動 Word 程式用 Office Automation 處理(過程 Word 很容易當掉沒反應,不太好搞),Word 2007 docx 普及後我開始改用更可靠有效率的 OpenXML SDK,但當時只是不成熟未經實戰的構想,應讀者們敲碗多時,我還是分享了有缺陷的 PoC 版本

看看舊文日期,居然已是十年前的事。

thumbnail

近一年來因專案需求對 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,你說的痛苦我懂,我也是好幾年後來才找到「醒目提示」這個最佳解。

Post a comment