首页 文章详情

wkhtmltopdf 转 PDF 技术实践

DotNet NB | 1234 2021-10-18 05:37 0 0 0
UniSMS (合一短信)

背景

近期根据需求反馈,需要实现动态生成PDF报告的功能。以往我们生成PDF一般会使用第三方收费组件如Spire、Aspose、ITextSharp、Select.HtmlToPdf等这些组件。但经过实际测试,大部分组件只能部分满足我们要求,无法提供很好的报告生成能力。
我们对组件要求包括:
  • 开源、免费、跨平台;

  • 具备自定义页眉、页脚功能;

  • 可自定义生成目录功能;

  • 可自定义生成书签功能;

  • 可自定义页面内容功能(纸张大小、边距、页码、编码、字体、样式等);

  • 对内容表格、统计图支持较好;

  • 报告模板化;

考虑到报告会存在线上浏览器查看需求、报告本身图表的复杂度、纯动态生成开发工作量,我们决定将报告模版设计为HTML格式,通过wkhtmltopdf组件将HTML模版转化为PDF。

一、关于wkhtmltopdf

wkhtmltopdf and wkhtmltoimage are open source (LGPLv3) command line tools to render HTML into PDF and various image formats using the Qt WebKit rendering engine. These run entirely "headless" and do not require display or display service.There is also a C library, if you're into that kind of thing.

以上是wkhtmltopdf 官网上的简介。就是使用QT Webkit渲染引擎将HTML渲染为PDF和各种图像格式的命令行工具。由于采用“headless”模式运行,使得将浏览器引擎的功能都带入命令行。QT Webkit 拥有清晰源码结构、高效的渲染速度、丰富的API。
wkhtmltopdf组件支持可执行文件命令行、链接库代码调用两种使用方式,所以即支持分离进程式调用:java采用Runtime.exec()或ProcessBuilder、.net 采用Process.Start() 甚至WinExec()函数,外部函数式调用:java采用JNI或JNA方式、.net采用P/invoke 方式,这里需要关心的是.net core 在linux下使用P/invoke 调用动态链接库,如果出现调用失败情况,可能是so文件缺少依赖文件:
# 可通过ldd命令进行查看ldd libwkhtmltox.so 
# 通过stirng查找是否确实缺少依赖strings /usr/lib64/libstdc++.so.6 |grep GLIBCXX
# 如果确实缺少依赖,可以通过find命令查找依赖。find / -name libstdc++.so.6*
# 复制文件cp /usr/local/lib64/libstdc++.so.6.0.20 /usr/lib64

wkhtmltopdf组件使用较为简单,分为以下几步:

  • 下载对应平台编译后文件或基于源码自己构建,或者使用对应语言的二次包装框架接入自己系统。

  • 使用外部或自己构建对应的HTML文档。

  • 通过命令行或函数式调用方式,将对应HTML文档转为PDF文件。命令及参数在此不赘述。

二、关于wkhtmlToPdfDotNet

基于wkhtmltopdf组件包装框架挺多的,例如基于java java-wkhtmltopdf-wrapper,基于.net WkHtmlToPdf-DotNet、DinkToPdf都可以使用。

以.net为例WkHtmlToPdfDotNet是基于DinkToPdf 的fork版本,主要区别是前者在后者基础上预包含wkhtmltopdf编译文件,增加同步转换器使得原生单线程的转换器在多线程应用程序和Web服务器中适用,以及包含docker测试项目及配置文件。

WkhtmlToPdfDotNet基于.NETStandard v2.0开发,主要分为文档转换设置与转换器,文档转换设置提供转换文档所需的若干参数设置,由转换器发起转换动作,触发事件,最终完成转换。

  • HtmlToPdfDocument 文档转换设置

包括全局设置GlobalSettings、对象设置Objects:

GlobalSettings 主要是对文档转换全局属性进行设置,其中DPI、ImageDPI、ImageQuality设置将影响转换后PDF质量、大小、转换时间。 

Objects可包含多个ObjectSettings,每个ObjectSettings可针对不同模版或页面进行配置。每个ObjectSettings 包含基本配置、WebSettings、HeaderSettings、FooterSettings、LoadSettings。基本配置中支持两种方式设置内容:HtmlContent HTML字符串形式的内容、Page HTML Url的内容。LoadSettings中JSDelay属性设置延迟等待时间,当页面Js加载时间较长可设置此值。PagesCount 用于计算文档页数。

以下为文档转换设置全量参数,根据实际情况进行设置:

//Define document to convert:var doc = new HtmlToPdfDocument(){    //全局配置    GlobalSettings = new GlobalSettings()    {      Orientation = Orientation.Portrait,//输出文档的方向必须是“横向”或“纵向”,默认纵向      ColorMode = ColorMode.Color,//输出文档彩色还是灰色,默认彩色      UseCompression = true,//是否创建文档时使用无损压缩,默认true      DPI = 96,//DPI,默认96      PageOffset = 0,//页面页脚起始数字,默认0      Copies = 1,//打印多少份,默认1      Outline = true,//是否生成大纲,默认true      OutlineDepth = 4,//大纲深度,默认4级      Out = "",//输出文件路径,默认""      DocumentTitle = "供应商履约报告",//pdf文档标题,默认""      ImageDPI = 600,//图片DPI,默认600      ImageQuality = 94,//图片质量,默认94      CookieJar = "",//用于加载或存储cookies的路径,默认""      PaperSize = PaperKind.A4,//页面纸张大小      Margins = new MarginSettings() //纸张边距      {        Bottom = 1,        Left = 1,        Right = 1,        Top = 1,      },    },    Objects =    {      new ObjectSettings()      {        UseExternalLinks = true,//HTML文档中的外部链接是否应转换为外部pdf链接,默认true        UseLocalLinks = true,//HTML文档中的内部链接是否应转换为pdf引用,默认true        ProduceForms = false,//是否将HTML表单转换为PDF表单,默认false        IncludeInOutline = false,//本文件的章节是否应包括在大纲和目录中,默认false        PagesCount = true,//是否在页眉页脚的计数器中计算此文档页数,默认false        //HtmlContent = "",//HTML内容,非url模式使用        Page = url,//HTML url
//Web设置 WebSettings = new WebSettings() { Background = true,//是否显示背景,默认true LoadImages = true,//是否加载图片,默认true EnableJavascript = true,//是否启动脚本,默认true EnableIntelligentShrinking = true,//是否启动智能收缩在一个页面上容纳更多内容,默认true MinimumFontSize = -1,//允许最小字体大小,默认-1 PrintMediaType = false,//是否应使用打印介质类型而不是屏幕介质类型打印内容,默认false DefaultEncoding = "utf-8",//默认未指定内容编码,则采用哪种编码,默认"" UserStyleSheet = "",//用户指定的样式表的Url或路径,默认"" enablePlugins = false,//是否启动插件,不推荐使用,默认false }, //页眉 HeaderSettings = new HeaderSettings() { FontSize = 9, //页眉字体大小,默认12 FontName = "Ariel",//页眉字体,默认Ariel Left = "测试报告",//页眉左侧显示内容,默认"" Center = "",//页眉中间显示内容,默认"" Right = "第 [page] 页,共[toPage] 页", //页眉右侧显示内容,默认"" Line = true, //是否启用页面线,默认false Spacing = 5, //在标题与内容之间放置的空间量,即间隔,默认0 HtmUrl = "https://auth.yzw.cn/",//页面链接,默认"", }, //页脚 FooterSettings = new FooterSettings() { FontSize = 9, FontName = "Ariel", Left = "测试报告", Center = "", Right = "第 [page] 页,共[toPage] 页 ", Line = false, Spacing = 5, HtmUrl = "" },
//加载设置 LoadSettings = new LoadSettings() { Username = "",//登录网站时要使用的用户名,默认"" Password = "",//登录网站时要使用的密码,默认"" JSDelay = 200,//延迟等待时间,即当页面Js加载时间较长,可设置此值,默认200毫秒 ZoomFactor = 1,//页面呈现放大倍数,默认正常1 BlockLocalFileAccess = false,//访问本地文件设置,默认false StopSlowScript = true,//停止缓慢运行js,默认true, DebugJavascript = false,//将javascript警告和错误转发到警告回调,默认false LoadErrorHandling = ContentErrorHandling.Abort,//指定应该如何处理无法加载的object,默认Abort Proxy = "",//加载对象时用到的代理字符串 //CustomHeaders = null,//请求页面时候用到的Header RepeatCustomHeaders = false,//指定是否将CustomHeaers发送到请求页面的所有页面上,包括子页面 //Cookies = null, //用到的cookies }, } }};
  • 封面与尾页

实际开发过程中,一份完整的文档内容格式需包含页眉与页脚,而增加页眉与页脚需设置WkHtmlToPdfDotNet中全局配置GlobalSettings的Margins属性。但正因为这种全局特性,将导致转换的文档每一页都包含边距,而实际上封面与尾页很可能是不需要边距的,所以我们将封面与尾页作为单独HTML页面进行转换处理,最终通过iTextSharp.LGPLv2.Core PdfImportedPage、PdfCopy合并PDF流。

 

  • 页眉与页脚

HeaderSettings、FooterSettings中HtmUrl属性支持以HTML Url方式自定义设置页眉与页脚,即设置的Url地址必须对应网页地址,例如图片地址则无效。需要注意的是文档在渲染过程中,包含页眉与页脚的内容页均会访问Url地址,意味着内容页数等于访问HtmUrl次数,所以HtmUrl页面设计建议元素尽量精简且不包含脚本,若包含图片、样式等请确保进行压缩,因为内存消耗将随着页数增加递增。

在页眉与页脚中引用文档页数以格式化字符串[page]作为当前页数,以[toPage]作为总页数,需全局设置用启用PagesCount。另外启用页眉页脚需指定Spacing空间量,保证全局设置GlobalSettings中Margins设置足够,否则无法显示。

页眉效果:

页脚效果:

HeaderSettings = new HeaderSettings(){    FontSize = 9,    Line = true,    Spacing = 5,    HtmUrl = logoUrl},FooterSettings = new FooterSettings(){    FontSize = 9,    FontName = "Ariel",    Center = "报告页脚",    Right = "第 [page] 页,共[toPage] 页",    Line = false,    Spacing = 5,}
  • 目录

WkhtmlToPdfDotNet 目录功能需基于ObjectSettings进行命令扩展,设置isTableOfContent属性开启目录功能,设置toc.captionText目录标题。

[Serializable]public class CustomTableOfContentsSettings : ObjectSettings{    [WkHtml("isTableOfContent")]    public bool IsTableOfContent { get; set; }
[WkHtml("toc.captionText")] public string CaptionText { get; set; }
public CustomTableOfContentsSettings() => this.IsTableOfContent = true;}

目录效果:

wkhtmltopdf生成目录无法指定页眉页脚与自定义样式。所以如果无法满足需求可以采用基iTextSharp.LGPLv2.Core 生成自定义目录。具体步骤如下:

  • 通过wkhtmltopdf将文档正文内容转PDF流。

  • 通过iTextSharp PdfReader读取Pdf流中包含的目录信息。

  • 构建自定义目录存储结构,通过Razor方式渲染出HTML内容。

  • 通过wkhtmltopdf将目录HTML内容转成Pdf流。

  • 通过iTextSharp 合并PDF流。

//生成自定义目录,bodyBuffer 即文档正文PDF流var bodyReader = new PdfReader(bodyBuffer);bodyReader.ConsolidateNamedDestinations();var bookmarks = SimpleBookmark.GetBookmark(bodyReader);if (bookmarks == null || bookmarks.Count == 0) throw new Exception("目录获取失败");
//构建自定义目录结构var tocModel = new TableOfContentsModel() { OutlineModels = new List<OutlineModel>(), RequestUrl = requestUrl };for (int i = 0; i < bookmarks.Count; i++){ var bookmark = bookmarks[i] as Hashtable; if (bookmark.ContainsKey("Title") && bookmark.ContainsKey("Page")) { var rootItem = new OutlineModel(bookmark["Title"].ToString().Trim(), bookmark["Page"].ToString().Trim().Split(" ")[0]); if (bookmark.ContainsKey("Kids")) { ArrayList child = bookmark["Kids"] as ArrayList; for (int j = 0; j < child.Count; j++) { var childBookmark = child[j] as Hashtable; if (childBookmark.ContainsKey("Title") && childBookmark.ContainsKey("Page")) { string title = childBookmark["Title"].ToString().Trim(); string page = int.Parse(childBookmark["Page"].ToString().Trim().Split(" ")[0]).ToString();
var childItem = new OutlineModel(title, page); rootItem.Children.Add(childItem); } } } tocModel.OutlineModels.Add(rootItem); }}
//通过Razor方式渲染出HTML内容,_converter 构造注入的转换器var tocHtmlStr = await _render.RenderToStringAsync("Views/TableOfContents.cshtml", tocModel);var tocBuffer = _converter.Convert(tocDocument);

自定义样式目录:

  • 书签

书签使用需在全局设置中启用属性Outline,可通过OutlineDepth属性设置书签深度。在wkhtmltopdf以HTML渲染PDF过程中,书签以HTML标签 <H1>~ <H6>作为大纲标题。

<!DOCTYPE html><html lang="en">    <head>    <title>测试报告</title>    <head>    <body>     <h1>一、一级标题</h1>    <section>    <h2>1.1 二级标题</h2>    </section>    </body></html>

书签效果:

 


如果单独生成封面与尾页或独立生成目录采用PDF流合并,那么合并时需注意还原书签。

        reader = new PdfReader(fileBuffers[i]);        reader.ConsolidateNamedDestinations();        pageNumber = reader.NumberOfPages;        tempBookmarks = SimpleBookmark.GetBookmark(reader);        if (i == 0)        {          document = new Document(PageSize.A4, 0, 0, 0, 0);          pdfCpy = new PdfCopy(document, stream);          pdfCpy.SetMargins(0, 0, 0, 0);          document.Open();
SimpleBookmark.ShiftPageNumbers(tempBookmarks, pageOffset, null); if (tempBookmarks != null) bookmarks.AddRange(tempBookmarks);
pageOffset += pageNumber; pageTotal = pageNumber; } else { SimpleBookmark.ShiftPageNumbers(tempBookmarks, pageOffset, null); if (tempBookmarks != null) bookmarks.AddRange(tempBookmarks); pageOffset += pageNumber; pageTotal += pageNumber; }
for (int j = 1; j <= pageNumber; j++) { page = pdfCpy.GetImportedPage(reader, j); pdfCpy.AddPage(page); } reader.Close(); } pdfCpy.Outlines = bookmarks; pdfCpy.Close(); document.Close();
byte[] buffer = stream.ToArray(); stream.Close(); return buffer; } finally { if (document != null) { document.Close(); } }}
  • BasicConverter/SynchronizedConverter 转换器

转换器接受ITools构造注入,PdfTools 支持自定义创建、销毁、获取GlobalSettin、ObjectSetting配置信息,以及设置错误、转换结束、进度变更、警告等回调。实际回调建议绑定转换器事件而非使用PdfTools设置句柄或函数指针。

由于wkhtmltopdf采用转换机制使得转换器本身是以单线程方式工作并且不支持多线程,故在BasicConverter基础上,封装SynchronizedConverter通过BlockingCollection<Task>集合方式进行内部排队处理转换任务。 

//Create converter:var converter = = new BasicConverter(new PdfTools());var convertSync = new SynchronizedConverter(new PdfTools());```
```c#//Convert:byte[] pdfBuffer = converter.Convert(doc);```
```c#//Dependency injection:public void ConfigureServices(IServiceCollection services){ services.AddSingleton(typeof(IConverter), new SynchronizedConverter(new PdfTools()));}

三、关于wkhtmltopdf HTML

wkhtmltopdf`本身是采用Webkit引擎进行的HTML渲染,所以在设计HTML页面时同样会面临浏览器兼容的若干问题。页面本身也需尽可能压缩。

以下列出wkhtmltopdf组件设计HTML页面若干建议:

  1. 当元素css属性position的值为absolute,不设置元素高度时,wkhtmltopdf会将该元素的高度设置为0px。

  2. 建议给定外层整体宽度。

  3. 同行div使用display:inline-block。

  4. div内容需要垂直居中时,建议通过在内部添加占位div方式填充。

  5. 不建议使用React、Vue等三方框架、建议使用基本样式设计页面。

  6. 如果单元格很宽导致换行, 可能会和重复显示的表头重叠。

未设置样式前


/*解决表头重复和文字重叠的问题*/thead {    display: table-row-group;}

设置样式后


  1. 网页本身是不分页的,但由于PDF本身分页导致元素被分割。也可以根据对应HTML元素进行设置,此处应用所有元素。

未设置样式前

/*解决元素被分页切断问题*/* {    page-break-inside: avoid;    page-break-after: avoid;    page-break-before: avoid;}

设置样式后


  1. 单元格内容较宽,导致表格总宽度超过页面限制,内容被截断。

/*解决截断问题*/table {    word-wrap: break-word;}table td{    word-break: break-all;}
  1. 使用例如echarts图表时,需给定宽高否则无法显示。在无法确定JSDelay设置时间满足加载动画的延迟时间的情况下,建议关闭动画。 

/*解决echarts图表无法显示问题*/.chart {    width: 1400px;    height: 680px;}/*echarts关闭动画*/option = {animation: false}


四、总结

  • wkhtmltopdf基于webkit 渲染转换PDF机制无法达到原生转PDF文档的性能。

  • wkhtmltopdf底层不支持多线程并发转换,需实现以多进程处理任务分发模式提高处理效率。

  • 不要将wkhtmltopdf与任何不受信任的HTML一起使用,否则可能导致完全接管其运行的服务器。

  • 若文档中包含中文或文档转换设置中指定字体,请确保服务器包含中文或对应中文的字体包,例如:fonts-wqy-microhei,否则将出现乱码。

文档资料

  • wkhtmltopdf:https://wkhtmltopdf.org/

  • wkhtmltopdf项目开源地址:https://github.com/wkhtmltopdf/wkhtmltopdf

  • WkHtmlToPdf-DotNet开源地址:https://github.com/HakanL/WkHtmlToPdf-DotNet


作 者:陈胜
审 稿:吴友强(技巅)
编 :周旭龙(爱迪生)

推荐阅读:
Kubernetes全栈架构师(Kubeadm高可用安装k8s集群)--学习笔记
.NET 云原生架构师训练营(模块一 架构师与云原生)--学习笔记
.NET Core开发实战(第1课:课程介绍)--学习笔记

点击下方卡片关注DotNet NB

一起交流学习

▲ 点击上方卡片关注DotNet NB,一起交流学习

请在公众号后台


回复 【路线图】获取.NET 2021开发者路线图
回复 【原创内容】获取公众号原创内容
回复 【峰会视频】获取.NET Conf开发者大会视频
回复 【个人简介】获取作者个人简介
回复 【年终总结】获取作者年终总结
回复 加群加入DotNet NB 交流学习群

长按识别下方二维码,或点击阅读原文。和我一起,交流学习,分享心得。


good-icon 0
favorite-icon 0
收藏
回复数量: 0
    暂无评论~~
    Ctrl+Enter