客戶提了需求,套表應用想在文件範本的特定位置插入圖片。花了點時間研究如何用 OpenXML SDK 實現,以下是我的筆記。

Word docx 其實是一個 ZIP 檔,文件主體是一份 XML。如果你有興趣研究,可以將 docx 更名成 zip 解壓縮(或在 docx 按右鍵選單直接用 7-Zip 解開),其中 word 資料夾有一個 document.xml,打開它會發現 Word 文件是由一堆 <w:p> 包 <w:r> 組成,其中 <w:p> 對應到 Open XML 中的 Paragraph,<w:r> 則對應到 Run

例如以下的 Test.docx:

解壓縮 Test.docx 後檢視 word\document.xml,可看到 Paragraph 文字內容被拆得很細,像「圖片插入位置 –> CAT」幾個字就被拆成 6 個 Run,字型顏色大小不同要拆成不同的 Run 無可厚非,但連中文、英文、符號也被獨立切開,甚至「圖片插入」與「位置」被分成兩個 Run。如何拆成 Run 對文件編輯者沒有任何影響,Word 的確可以全權做主,但使用 Open XML SDK 讀取時就得留心其中差異。

Open XML SDK 官方文件有一個完整的插入圖片範例: 如何: 將圖片插入文書處理文件 (開啟 XML SDK),照方煎藥就能在文件末端插入圖片。不過,我遇到的需求還需要調整圖檔大小及插入位置,便試著改寫較有彈性的版本。

我先將圖片內容及設定抽取成獨立類別 ImageData,偵測 附檔名決定 OpenXML ImagePartType(我只打算支援 JPG、PNG、GIF、BMP),由圖檔寬度高度 Pixel 數除以 DPI(預設300) 換算出以公分為單位的預設寬度與高度,但圖片寬高允許自由調整。Open XML 使用 EMU 作為長度單位,使用時公分要乘上 360000 轉成 EMU 以符合 OpenXML 要求。我選擇不直接插入圖片,而是透過一個 GenerateImageRun() 公用函式將圖片轉成 Run,開發者再視需要決定該插入到文件末端、特定位置或置換現有 Run。

    public class ImageData
    {
        public string FileName = string.Empty;
        public byte[] BinaryData;
        public Stream DataStream => new MemoryStream(BinaryData);
        public ImagePartType ImageType
        {
            get
            {
                var ext = Path.GetExtension(FileName).TrimStart('.').ToLower();
                switch (ext)
                {
                    case "jpg":
                        return ImagePartType.Jpeg;
                    case "png":
                        return ImagePartType.Png;
                    case "":
                        return ImagePartType.Gif;
                    case "bmp":
                        return ImagePartType.Bmp;
                }
                throw new ApplicationException($"Unsupported image type: {ext}");
            }
        }
        public int SourceWidth;
        public int SourceHeight;
        public decimal Width;
        public decimal Height;
        public long WidthInEMU => Convert.ToInt64(Width * CM_TO_EMU);
        public long HeightInEMU => Convert.ToInt64(Height * CM_TO_EMU);
        private const decimal INCH_TO_CM = 2.54M;
        private const decimal CM_TO_EMU = 360000M;       
        public string ImageName;
        public ImageData(string fileName, byte[] data, int dpi = 300)
        {
            FileName = fileName;
            BinaryData = data;
            Bitmap img = new Bitmap(new MemoryStream(data));
            SourceWidth = img.Width;
            SourceHeight = img.Height;
            Width = ((decimal)SourceWidth) / dpi * INCH_TO_CM;
            Height = ((decimal)SourceHeight) / dpi * INCH_TO_CM;
            ImageName = $"IMG_{Guid.NewGuid().ToString().Substring(0, 8)}";
        }
        public ImageData(string fileName, int dpi = 300) : 
            this(fileName, File.ReadAllBytes(fileName), dpi)
        {
        }
    }
    public class DocxImgHelper
    {
        public static Run GenerateImageRun(WordprocessingDocument wordDoc, ImageData img)
        {
            MainDocumentPart mainPart = wordDoc.MainDocumentPart;
 
            ImagePart imagePart = mainPart.AddImagePart(ImagePartType.Jpeg);
            var relationshipId = mainPart.GetIdOfPart(imagePart);
            imagePart.FeedData(img.DataStream);
 
            // Define the reference of the image.
            var element =
                 new Drawing(
                     new DW.Inline(
                         //Size of image, unit = EMU(English Metric Unit)
                         //1 cm = 360000 EMUs
                         new DW.Extent() { Cx = img.WidthInEMU, Cy = img.HeightInEMU },
                         new DW.EffectExtent()
                         {
                             LeftEdge = 0L,
                             TopEdge = 0L,
                             RightEdge = 0L,
                             BottomEdge = 0L
                         },
                         new DW.DocProperties()
                         {
                             Id = (UInt32Value)1U,
                             Name = img.ImageName
                         },
                         new DW.NonVisualGraphicFrameDrawingProperties(
                             new A.GraphicFrameLocks() { NoChangeAspect = true }),
                         new A.Graphic(
                             new A.GraphicData(
                                 new PIC.Picture(
                                     new PIC.NonVisualPictureProperties(
                                         new PIC.NonVisualDrawingProperties()
                                         {
                                             Id = (UInt32Value)0U,
                                             Name = img.FileName
                                         },
                                         new PIC.NonVisualPictureDrawingProperties()),
                                     new PIC.BlipFill(
                                         new A.Blip(
                                             new A.BlipExtensionList(
                                                 new A.BlipExtension()
                                                 {
                                                     Uri =
                                                        "{28A0092B-C50C-407E-A947-70E740481C1C}"
                                                 })
                                         )
                                         {
                                             Embed = relationshipId,
                                             CompressionState =
                                             A.BlipCompressionValues.Print
                                         },
                                         new A.Stretch(
                                             new A.FillRectangle())),
                                     new PIC.ShapeProperties(
                                         new A.Transform2D(
                                             new A.Offset() { X = 0L, Y = 0L },
                                             new A.Extents() { 
                                                    Cx = img.WidthInEMU, Cy = img.HeightInEMU }),
                                         new A.PresetGeometry(
                                             new A.AdjustValueList()
                                         )
                                         { Preset = A.ShapeTypeValues.Rectangle }))
                             )
                             { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" })
                     )
                     {
                         DistanceFromTop = (UInt32Value)0U,
                         DistanceFromBottom = (UInt32Value)0U,
                         DistanceFromLeft = (UInt32Value)0U,
                         DistanceFromRight = (UInt32Value)0U,
                         EditId = "50D07946"
                     });
            return new Run(element);
        }
    }

有了公用函式,要插入圖片就簡單了,試試將圖片加在文件末端:

static void Main(string[] args)
{
    var workFileName = $"Test-{DateTime.Now:HHmmss}.docx";
    File.Copy("Test.docx", workFileName);
    using (WordprocessingDocument document =
        WordprocessingDocument.Open(workFileName, true))
    {
        var cat2Img = new ImageData("Cat2.png");
        var imgRun = DocxImgHelper.GenerateImageRun(document, cat2Img);
        document.MainDocumentPart.Document.Body.AppendChild(new Paragraph(imgRun));
    }
}

測試成功! 測試圖片尺寸為 300x300,因預設 300 DPI,300 Pixel 等於 1 吋,故變成 2.54cm x 2.54cm 的圖片,置於文件最後一段 Paragraph。

接著再來測試置換現有內容。用 Document.Body.Descendants() 取回 document.xml 所有 XML 節點,如果我們確定 CAT 文字被包在單一 <w:r> 中(小訣竅: 使用純英文並套用不同字型可確保該段文字自成一個 Run),用 LINQ .Single(o => o.Local == "r" && o.InnerText == "CAT") 可找到 CAT 所在的 Run,接著將其 InnerXml 換成圖片的 InnerXml,CAT 文字就變成圖檔囉~ (本例順便示範改變圖片寬高為 1cm x 1cm)

static void Main(string[] args)
{
    var workFileName = $"Test-{DateTime.Now:HHmmss}.docx";
    File.Copy("Test.docx", workFileName);
    using (WordprocessingDocument document =
        WordprocessingDocument.Open(workFileName, true))
    {
        var cat1Img = new ImageData("Cat1.gif")
        {
            Width = 1,
            Height = 1
        };
        var imgRun = DocxImgHelper.GenerateImageRun(document, cat1Img);
                
        //找到 CAT 所在的 Run
        var runCAT = document.MainDocumentPart.Document.Body.Descendants()
            .Single(o => o.LocalName == "r" && o.InnerText == "CAT");
 
        //將 InnerXML 置換成圖片 Run 的 InnerXML
        runCAT.InnerXml = imgRun.InnerXml;
    }
}

測試成功,YA!


Comments

# by Lee

請問一下 throw new ApplicationException($"Unsupported image type: {ext}"); 的 $ 有沒有辦法用其他寫法替代 這用法 我都會顯示必須要提升c# 6以上的版本才可以

# by Jeffrey

to Lee, $"Unsupported image type: {ext}" 的學名叫 Interpolated Strings(字串插值),相當於 string.Format("Unsupported image type: {0}", ext)。延伸閱讀: http://blog.darkthread.net/post-2016-11-22-c-interpolated-string.aspx 是 C#6 用過就回不去了的好東西之一,呵~

# by DENNIS

Dear 大大 我看完您的文章後 可以跟您要完整的CODE跑跑看嗎 拜託您了 chiaminghuang@gmail.com

# by Jeffrey

to DENNIS, 這算是較進階議題,你會使用Visual Studio建立Console Application專案嗎? 文章程式範例已包含完整邏輯,加入專案應該就可以執行了。

# by xiaoxia

请问一下 我需要把图片插入到 <w:r> (一个指定的文本)之前(大多数都是之后) 我应该如何操作呢?

# by Jeffrey

to xiaoxia, 用 InsertBefore() 應該可以辦到 https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.openxmlelement.insertbefore?view=openxml-2.8.1

# by LYM

想請問有插入文字方塊的教學嗎 是否跟圖片是一樣的 差別只在一個是圖片一個是文字??

# by Jeffrey

to LYM,像這樣嗎?https://www.e-iceblue.com/Tutorials/Spire.Doc/Spire.Doc-Program-Guide/NET-Word-Textbox-Insert-Textbox-in-Word-with-C-and-VB.NET.html

# by LYM

那例子感覺不是用WordprocessingDocument做出來的 不過已經找到方法搞出來了 謝謝

# by andy

請問黑大的Notepad++ 有使用XML的外掛嗎?我用7.8.3版 卻無法像您的畫面那樣會自動換行 安裝啟用內建的XML Treeview後卻閃退了 https://photos.app.goo.gl/CsksddgFgrDN8kEGA

# by Jeffrey

to andy, 我是用 7.8.1 32bit,處理 XML 是用 XML Tools 這個外掛,沒遇到什麼問題。https://i.imgur.com/zxydkAL.png

# by andy

謝謝黑大,我改用XML Tools沒問題了。新年快樂

# by Nathan

你好, 請問有方法可以找到文件的第一個Shape並插入文字嗎? 網上找了半天但實在找到到方法, 謝謝!

# by Jeffrey

to Nathan, 請參考新文章 https://blog.darkthread.net/blog/insert-text-to-shape-w-openxml/

# by Ronaldlin

非常實用,感謝您的分享。 解析度的部分,本來想要改成一般的word插入圖片時自動100%內容寬度,不過一直找不到可以參照的寬度屬性在哪,目前只能先給個數字了事了。

# by Yuan

請問要如何將插入的圖片新增底線呢,謝謝

Post a comment