筆記目錄

Skip to content

使用 .NET 產生帶有浮水印的 Excel

前言

我有一個開發中的套件 SpreadsheetExporter 可以使用 NPOI 或 EPPlus 產生帶有浮水印的 Excel,但是由於浮水印功能會使用到 System.Drawing.Common,但是隨著 .NET 6 對於 System.Drawing.Common 的支援度開始下降。本來想改為僅提供有支援 System.Drawing.Common 的 Framework 才提供這個功能,但後來我放棄了這個想法,直接刪除相關功能。為了避免後續需要時沒有參考,我決定把方法作為筆記。

產生帶有浮水印的 Excel

Excel 並沒有內建的浮水印功能,但是可以透過設定滿版的透明背景圖片來模擬出浮水印的效果。

Excel 有三種檢視模式,分別為「標準模式」、「分頁模式」和「整頁模式」,再加上列印,有四種狀況,目前「分頁模式」,我還沒有找到有效的方法,其他可用以下方式設定:

  • 在 Background 設定 Image 可以在「標準模式」和「整頁模式」顯示背景圖片。
  • 在 Header 設定 Image 可以在「整頁模式」和「列印」顯示背景圖片。

產生滿版圖片

Excel 的版面設定,可以設定頁面方向和頁面大小,這會決定要產生的圖片大小。

可以藉由下面的程式碼取得有紀錄長寬的 PaperSize 物件集合,Excel 預設的紙張大小皆可在此找到對應的設定。

csharp
PrinterSettings settings = new PrinterSettings() {
    PrinterName = "Microsoft XPS Document Writer"
};
foreach (System.Drawing.Printing.PaperSize printerPaperSize in settings.PaperSizes) {
    // printerPaperSize.RawKind 流水號
    // printerPaperSize.PaperName A4 之類的 PaperName
    // printerPaperSize.Width 寬
    // printerPaperSize.Height 高
}

以下為各個 PaperSize 的資料:

RawKindPaperNameWidthHeight
1Letter8501100
2Letter Small8501100
3Tabloid11001700
4Ledger17001100
5Legal8501400
6Statement550850
7Executive7251050
8A311691654
9A48271169
10A4 Small8271169
11A5583827
12B4 (JIS)10121433
13B5 (JIS)7171012
14Folio8501300
15Quarto8461083
1610×1410001400
1711×1711001700
18Note8501100
19Envelope #9387887
20Envelope #10412950
21Envelope #114501037
22Envelope #124751100
23Envelope #145001150
24C size sheet17002200
25D size sheet22003400
26E size sheet34004400
27Envelope DL433866
28Envelope C5638902
29Envelope C312761803
30Envelope C49021276
31Envelope C6449638
32Envelope C65449902
33Envelope B49841390
34Envelope B5693984
35Envelope B6693492
36Envelope433906
37Envelope Monarch387750
386 3/4 Envelope362650
39US Std Fanfold14871100
40German Std Fanfold8501200
41German Legal Fanfold8501300
42B4 (ISO)9841390
43Japanese Postcard394583
449×119001100
4510×1110001100
4615×1115001100
47Envelope Invite866866
50Letter Extra9501200
51Legal Extra9501500
53A4 Extra9271269
54Letter Transverse8501100
55A4 Transverse8271169
56Letter Extra Transverse9501200
57Super A8941402
58Super B12011917
59Letter Plus8501269
60A4 Plus8271299
61A5 Transverse583827
62B5 (JIS) Transverse7171012
63A3 Extra12681752
64A5 Extra685925
65B5 (ISO) Extra7911087
66A216542339
67A3 Transverse11691654
68A3 Extra Transverse12681752

知道 PaperSize 大小,就可用以下程式碼修正浮水印圖片背景的空白部分,需注意如果 PaperSize 為橫向,傳入widthheight 要對調。

csharp
public Image ResizeImageBackgroundToFullPage(Image watermark, int width, int height){
    if (watermark.Width > width || watermark.Height > height) {
        using (Image image = ZoomOutImage(width, height)) {
            return ResizeImageBackgroundToFullPageInternal(width, height, image);
        }
    }

    return ResizeImageBackgroundToFullPageInternal(width, height, watermark);
}

private Image ZoomOutImage(int pageWidth, int pageHeight) {
    decimal scale = Math.Max((decimal)watermark.Width / pageWidth, (decimal)watermark.Height / pageHeight);
    return new Bitmap(watermark, (int)(watermark.Width / scale), (int)(watermark.Height / scale));
}

private Image ResizeImageBackgroundToFullPageInternal(int pageWidth, int pageHeight, Image image) {
    Image bitmap = new Bitmap(pageWidth, pageHeight);
    using Graphics graphics = Graphics.FromImage(bitmap);
    graphics.Clear(Color.White);
    graphics.DrawImage(image, (pageWidth - image.Width) / 2, (pageHeight - image.Height) / 2);
    graphics.CompositingQuality = CompositingQuality.HighQuality;
    graphics.SmoothingMode = SmoothingMode.HighQuality;
    graphics.Save();

    return bitmap;
}

如果需要用程式產生文字圖片,可用以下程式碼:

csharp
public Image DrawText(string text, Font font, Color textColor, Color backColor, int width, int height) {
    // 建立一個指定寬度和高度的 Bitmap 物件作為圖像容器 //創立一個指定寬度和高度的位圖圖象
    Image img = new Bitmap(width, height);
    using (Graphics drawing = Graphics.FromImage(img)) {

        // 計算文字在圖像容器中的座標
        SizeF textSize = drawing.MeasureString(text, font, 0, StringFormat.GenericTypographic);
        float x = (width - textSize.Width) / 2;
        float y = (height - textSize.Height) / 2;

        drawing.TranslateTransform(x + (textSize.Width / 2), y + (textSize.Height / 2));
        // 將圖形逆時針旋轉 45 度
        drawing.RotateTransform(-45);
        drawing.TranslateTransform(-(x + (textSize.Width / 2)), -(y + (textSize.Height / 2)));

        // 在圖像容器上清除一個矩形,以背景顏色填充
        drawing.Clear(backColor);

        // 建立一個實心筆刷,用於繪製文字
        Brush textBrush = new SolidBrush(textColor);
        drawing.DrawString(text, font, textBrush, x, y);

        drawing.Save();
        return img;
    }
}

TIP

有關圖片旋轉的邏輯,可以參考「C# 使用 GDI+ 實現新增中心旋轉(任意角度)的文字」。

使用 EPPlus 產生帶有浮水印的 Excel

下面是使用 EPPlus 產生帶有浮水印的 Excel 的範例程式碼,其中 watermark 型別為 Image

csharp
sheet.HeaderFooter.OddHeader.InsertPicture(watermark, PictureAlignment.Centered);
sheet.BackgroundImage.Image = watermark;

WARNING

此作法可能不適用於 EPPlus 6,因為 System.Drawing.Common 在 .NET 6 以後的支援問題,EPPlus 6 刪除 System.Drawing.Common 的依賴關係,但我並無關注詳細內容,所以不確定調整方式。

使用 NPOI 產生帶有浮水印的 Excel (XLSX)

以我的認知,目前 NPOI 的 API 並不能直接設定 Background Image 和 Header Image,但可以使用 NPOI 提供的較底層的 API 來進行處理。

若要產生帶有浮水印的 Excel,需先了解設定 Background 和 Header 的 Image 會產生怎樣的 XML 結構。以下為相關 XML 節錄:

Sheet 的 XML 相關內容,其中 rId1rId2 定義在 \xl\worksheets_rels\{Sheet Name}.xml.rels

xml
<!-- Header Image -->
<headerFooter><oddHeader><![CDATA[&C&G]]></oddHeader></headerFooter><legacyDrawingHF r:id="rId2"/>
<!-- Background Image -->
<picture r:id="rId1"></picture>

{Sheet Name}.xml.rels

xml
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image1.png" />
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml" /></Relationships>

vmlDrawing1.vml 會用 o:relid="rId1" 關聯圖片 rId1 的定義位置在 \xl\drawings\_rels\vmlDrawing1.vml.rels 裡。

xml
<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">
  <o:shapelayout v:ext="edit">
    <o:idmap v:ext="edit" data="1" />
  </o:shapelayout>
  <v:shapetype id="_x0000_t202" coordsize="21600,21600" o:spt="202" path="m,l,21600r21600,l21600,xe">
    <v:stroke joinstyle="miter" />
    <v:path gradientshapeok="t" o:connecttype="rect" />
  </v:shapetype>
  <v:shape id="CH" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:876.75pt;height:620.25pt;z-index:1">
    <v:imagedata o:relid="rId1" o:title="" />
    <o:lock v:ext="edit" rotation="t" />
  </v:shape>
</xml>

vmlDrawing1.vml.rels 內容如下:

xml
<?xml version="1.0" encoding="utf-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image1.png" />
</Relationships>

原本知道這些資訊就可以用來拼湊設定浮水印的程式碼,但是這時會遇到一個問題,內建的 XSSFVMLDrawing 是用來建立 Comment 的,因此產生的結構和所需的不同,需要自行定義。

csharp
private class VmlRelation : POIXMLRelation {
    private static readonly Lazy<VmlRelation> instance = new(() => {
        return new VmlRelation(
                "application/vnd.openxmlformats-officedocument.vmlDrawing",
                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing",
                "/xl/drawings/vmlDrawing#.vml",
                typeof(VmlDrawing)
        );
    });

    private VmlRelation(string type, string rel, string defaultName, Type cls) : base(type, rel, defaultName, cls) { }

    public static VmlRelation Instance => instance.Value;
}

private class VmlDrawing : POIXMLDocumentPart {
    public string PictureRelId { get; set; }

    public Image Image { get; set; }

    protected override void Commit() {
        PackagePart part = GetPackagePart();
        Stream @out = part.GetOutputStream();
        Write(@out);
        @out.Close();
    }

    private void Write(Stream stream) {
        // Pixel => Points
        float width = Image.Width * 72 / Image.HorizontalResolution;
        float height = Image.Height * 72 / Image.VerticalResolution;

        using StreamWriter sw = new(stream);
        XmlDocument doc = new();
        doc.LoadXml($@"
<xml xmlns:v=""urn:schemas-microsoft-com:vml"" xmlns:o=""urn:schemas-microsoft-com:office:office"" xmlns:x=""urn:schemas-microsoft-com:office:excel"">
<o:shapelayout v:ext=""edit"">
<o:idmap v:ext=""edit"" data=""1"" />
</o:shapelayout>
<v:shapetype id=""_x0000_t202"" coordsize=""21600,21600"" o:spt=""202"" path=""m,l,21600r21600,l21600,xe"">
<v:stroke joinstyle=""miter"" />
<v:path gradientshapeok=""t"" o:connecttype=""rect"" />
</v:shapetype>
<v:shape id=""CH"" type=""#_x0000_t75"" style=""position:absolute;margin-left:0;margin-top:0;width:{width}pt;height:{height}pt;z-index:1"">
<v:imagedata o:relid=""{PictureRelId}"" o:title="""" />
<o:lock v:ext=""edit"" rotation=""t"" />
</v:shape>
</xml>");

        doc.Save(stream);
    }
}

最後我們可以使用以下程式碼設定浮水印。

csharp
MemoryStream imageMs = new MemoryStream();
watermark.Save(imageMs, System.Drawing.Imaging.ImageFormat.Png);

int pictureIdx = workbook.AddPicture(imageMs.ToArray(), PictureType.PNG);
POIXMLDocumentPart docPart = workbook.GetAllPictures()[pictureIdx] as POIXMLDocumentPart;

POIXMLDocumentPart.RelationPart backgroundRelPart = sheet.AddRelation(null, XSSFRelation.IMAGES, docPart);

sheet.GetCTWorksheet().picture = new CT_SheetBackgroundPicture {
    id = backgroundRelPart.Relationship.Id
};

int drawingNumber = (sheet.Workbook as XSSFWorkbook)
    .GetPackagePart()
    .Package
    .GetPartsByContentType(XSSFRelation.VML_DRAWINGS.ContentType).Count + 1;
VmlDrawing drawing = (VmlDrawing)sheet.CreateRelationship(VmlRelation.Instance, XSSFFactory.GetInstance(), drawingNumber);

POIXMLDocumentPart.RelationPart headerRelPart = drawing.AddRelation(null, XSSFRelation.IMAGES, docPart);

drawing.Image = watermark;
drawing.PictureRelId = headerRelPart.Relationship.Id;

sheet.Header.Center = HeaderFooter.PICTURE_FIELD.sequence;
sheet.GetCTWorksheet().legacyDrawingHF = new CT_LegacyDrawing {
    id = sheet.GetRelationId(drawing)
};

異動歷程

  • 2023-02-24 初版文件建立。