Image

最近做的一項需求是要把 HTML 轉換成 PDF,過程中決定使用 DinkToPdf 來處理這一段。

考慮到現在拿到的一些文件模板都是 HTML 檔了,感覺以後會蠻常碰到這個場景,決定來筆記一篇,給未來的我複製貼上。

環境準備

首先,當然是要先到 Nuget 安裝這篇的主角:DinkToPdf

Image

由於 DinkToPdf 只負責包裝給 C# 這段,實際上要產出 PDF 還得用到 wkhtmltopdf 這個工具,因此還需要先弄到 wkhtmltopdf 的檔案。

不過作者也知道大家不是很想另外跑去找,所以 wkhtmltopdf 的組件檔案可以直接從 DinkToPdf 的 Repo 拿。但要記得要按照作業系統下載對應的 wkhtmltopdf 檔案:

  • Windows => libwkhtmltox.dll
  • Linux => libwkhtmltox.so
  • macOS => libwkhtmltox.dylib

但全部都載也不會有人阻止你就是了。

最後把 libwkhtmltox 放到專案的根目錄底下,DinkToPdf 就呼叫得到囉!
(問就是魔法,不服的自己去啃 P/Invoke


libwkhtmltox 準備好之後,因為我的示範環境是 .Net 8 的 API,所以還需要註冊 DinkToPdf 的 IConverter 到 DI 框架上。

這邊額外說明一下,IConverter 其實有兩個版本的實作:

  • BasicConverter:最直接的簡易版本,會直接去呼叫 libwkhtmltox
  • SynchronizedConverter:有搞執行緒安全的版本,會讓任務排隊依序處理

但通常我們就直接參考官方給的範例無腦註冊 SynchronizedConverter 就行了:

services.AddSingleton<IConverter>(_ => new SynchronizedConverter(new PdfTools()));

這邊要特別提醒一點:DinkToPdf 的 IConverter 一定要註冊成單例!

即使今天不是使用 DI 框架來注入的話,也一定要把呼叫的 Converter 包裝成單例,否則會導致多個 Converter 去操作 libwkhtmltox 而發生錯誤。

如果在使用 DinkToPdf 的時候,發現產生的 PDF 會有「一次正常、一次跑版、一次正常、一次跑版」之類的靈異現象,通常就是沒有把 Converter 包成單例所造成的組件呼叫錯誤。


如果懶得找檔案搞單例什麼的,也可以像我一樣,直接找人家包好的工具爽爽用:

這邊我選擇直接開 Nuget 安裝 HtmlToPdfConverter,它已經把 wkhtmltopdf 組件包含在套件裡了,還封裝了一層。我們只需要在 DI 註冊時加上:

services.AddHtmlToPdfConverter();

它就會自己呼叫 RuntimeInformation.IsOSPlatform 來載入對應的 libwkhtmltox 組件檔案了。(有興趣的朋友也可以參考 HtmlToPdfConverter Repo 的寫法

不管是選擇自己放組件,還是直接挖人家包好的。總之材料準備好之後,就可以開工啦!

實作紀錄

首先,假設我們有某組 HTML 內容:

private string GetHtmlContent()
{
    // 為了簡單示範,直接寫死一組字串
    var html =
    """
    <!DOCTYPE html>
    <html lang="zh-Hant">
    <head>
        <meta charset="UTF-8">
        <title>測試 DinkToPdf</title>
        <style>
            body {
                font-family: '標楷體', sans-serif;
                padding: 20px;
            }
            h1 {
                color: #333;
            }
        </style>
    </head>
    <body>
        <h1>Hello world</h1>
        <p>我來,我見,我 PDF</p>
    </body>
    </html>
    """;

    // 我們也有可能是從檔案讀的嘛,留這段方便我複製貼上
    // var path = @"C:\temp\test.html";
    // var html = File.ReadAllText(path, Encoding.UTF8);

    return html;
}

接著就是 DinkToPdf 上場的時候,首先我們需要宣告一組 HtmlToPdfDocument,並設置一些文件相關的特性。

我們這種最簡單的例子,直接設定 A4 直向給他就可以了:

var doc = new HtmlToPdfDocument()
{
    GlobalSettings = new GlobalSettings()
    {
        PaperSize = PaperKind.A4,
        Orientation = Orientation.Portrait,
    }
};

補充:可調整的項目請參考 GlobalSettings.cs 的欄位,像是一些彩色/黑白啦、文件大小跟方向等等都可以從這裡調整。

有了基本的文件之後,就可以直接把我們前面的 HTML 整坨塞進去:

doc.Objects.Add(new ObjectSettings()
{
	HtmlContent = html,
	WebSettings = { DefaultEncoding = "utf-8" },
	HeaderSettings = { FontName = "標楷體" }
});

都 OK 之後就可以把 IConverter 叫出來轉檔案囉:

// 為了方便示範直接 new 一個 SynchronizedConverter
// 實際使用時請從 DI 之類的地方取得單例物件重複用
var converter = new SynchronizedConverter(new PdfTools());
var pdfBytes = converter.Convert(doc);

最後就可以根據狀況,看是要存成檔案,還是要把 bytes[] 傳遞到下一站:

// 把前面產生的 PDF 儲存成檔案
// 如果要繼續對 PDF 進行加工的話(例如後續用 PDFSharp 接手)
// 也可以考慮傳遞上面的 pdfBytes 就好
var outputPath = @"C:\temp\output.pdf";
File.WriteAllBytes(outputPath, pdfBytes);
Console.WriteLine($"已產生 PDF:{outputPath}");

這樣就搞定啦,馬上打開來看看: Image

後話

如果像我一樣拿到 HTML 的樣板,最後要產製 PDF 的話,DinkToPdf 算是個不錯的小工具,尤其程式撰寫的部份蠻簡單的,相關設定丟一丟就可以直接轉換檔案了。

比起程式碼部份,反而環境設定的坑還比較多,如果沒乖乖丟組件檔案,又或是註冊的時候沒有好好做成單例,就會發生各種怪怪的事情(果然還是留一篇筆記給未來的我抄比較安全)

原本有考慮 PdfSharp 最常搭配的 HtmlRenderer.PdfSharp,但它已經停止維護;而看起來像是移植版本的 HtmlRendererCore.PdfSharp 光用 Linqpad 裝看看就跳出一排東西,實在不敢直接用:

Image

最後繞了半圈,還是把 libwkhtmltox.dll 丟一丟、DinkToPdf 叫一叫最簡單方便,畢竟早點下班才是正義,阿彌陀佛。