客戶提了需求,套表應用想在文件範本的特定位置插入圖片。花了點時間研究如何用 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。
publicclass ImageData
{
publicstring FileName = string.Empty;
publicbyte[] 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;
}
thrownew ApplicationException($"Unsupported image type: {ext}");
}
}
publicint SourceWidth;
publicint SourceHeight;
publicdecimal Width;
publicdecimal Height;
publiclong WidthInEMU => Convert.ToInt64(Width * CM_TO_EMU);
publiclong HeightInEMU => Convert.ToInt64(Height * CM_TO_EMU);
privateconstdecimal INCH_TO_CM = 2.54M;
privateconstdecimal CM_TO_EMU = 360000M;
publicstring 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)
{
}
}
publicclass DocxImgHelper
{
publicstatic 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"
});
returnnew Run(element);
}
}
有了公用函式,要插入圖片就簡單了,試試將圖片加在文件末端:
staticvoid 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)
staticvoid 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!