使用 Open XML SDK 在 Word 插入圖片
16 | 15,804 |
客戶提了需求,套表應用想在文件範本的特定位置插入圖片。花了點時間研究如何用 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
請問要如何將插入的圖片新增底線呢,謝謝