使用wkhtmltopdf和freemaker生成pdf

背景

在CMS项目中,网页的正文是用富文本编辑器来维护的,经过调研发现,将文章生成word版本时,需要使用doc4j来生成,核心代码如下:

### 对html进行标准化处理并增加字符集设置
Document document = org.jsoup.Jsoup.parse(htmlContent);
document.head().prepend("<meta charset=\"utf-8\"/>");
String normalizedHtmlContent = document.html();

### 将标准化后的html内容插入word文件
WordprocessingMLPackage aPackage = WordprocessingMLPackage.load(outputFile);
MainDocumentPart mainDocumentPart = aPackage.getMainDocumentPart();
mainDocumentPart.addAltChunk(AltChunkType.Html, normalizedHtmlContent.getBytes(Charsets.UTF_8));
aPackage.save(outputFile);

本来想将word转换成pdf, 经过失败了。不知道其他朋友有没有遇到过类似的需求场景,如果有的话,希望你能给大家分享一下。

最终转换思路,决定使用html来生成pdf, 经过一番调研,发现wkhtmltopdf还不错,能够较好地满足需求。

尝试在命令行环境实现

生成的pdf需要页眉页脚,其中页脚中的页码如果是奇数放在右边,如果是偶数放在左边,就像这样的效果:


页码为奇数放在页脚右边

页码为偶数放在页脚的左边

通过阅读wkhtmltopdf文档发现,wkhtmltopdf在生成页脚时,使用GET请求获取页脚中页面的数据,同时会带上page等参数。考虑到要根据page来动态控制页脚中的页码,我决定使用freemarker来实现。因此需要在项目中引入freemaker并做好相关配置:

# 添加freemarker依赖
<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>
# 在application.properties中配置相关参数
############################## freemarker START ##############################
spring.freemarker.enabled=true
spring.freemarker.template-loader-path=classpath:/templates/
spring.freemarker.suffix=.html
spring.freemarker.cache=false
spring.freemarker.charset=UTF-8
spring.freemarker.content-type=text/html
spring.freemarker.check-template-location=true
spring.freemarker.settings.tag_syntax=square_bracket
spring.freemarker.settings.template_update_delay=5
spring.freemarker.settings.defaultEncoding=UTF-8
spring.freemarker.settings.url_escaping_charset=UTF-8
spring.freemarker.settings.locale=zh_CN
spring.freemarker.settings.boolean_format=true,false
spring.freemarker.settings.datetime_format=yyyy-MM-dd HH:mm:ss
spring.freemarker.settings.date_format=yyyy-MM-dd
spring.freemarker.settings.time_format=HH:mm:ss
spring.freemarker.settings.number_format=0.######
spring.freemarker.settings.whitespace_stripping=true
spring.freemarker.settings.template_exception_handler=com.xxx.ow.staticpage.exception.FreemarkerExceptionHandler
############################## freemarker END ##############################
  • 添加Controller代码
/**
 * 规章详情
 *
 * @author : jamesfu
 * @date : 21/2/2022 14:22
 */
@Controller
@RequestMapping("/api/page/ruleArticle")
@Slf4j
public class RuleArticleController {
    /**
     * pdf页眉
     *
     * @return 页面模板
     */
    @GetMapping("pdfHeader")
    public ModelAndView pdfHeader() {
        return new ModelAndView("custom/article/规章详情-PDF-Header");
    }

    /**
     * pdf页脚
     *
     * @return 页面模板
     */
    @GetMapping("pdfFooter")
    public ModelAndView pdfFooter(HttpServletRequest request, Model model) {
        log.info("request query string is {}", request.getQueryString());
        String strPage = request.getParameter("page");
        log.info("page is {}", strPage);
        if (strPage != null) {
            int intPage = Integer.parseInt(strPage);
            model.addAttribute("page", intPage);
        } else {
            model.addAttribute("page", 0);
        }
        return new ModelAndView("custom/article/规章详情-PDF-Footer");
    }

/**
     * pdf页面
     *
     * @return 页面模板
     */
    @GetMapping("pdfBody")
    public ModelAndView pdfBody(Model model) {
        model.addAttribute("title","住房和城乡建设部关于修改《建设工程勘察质量管理办法》的决定");
        model.addAttribute("notes","(2021年4月1日中华人民共和国住房和城乡建设部令第53号公布 自公布之日起施行)");
        model.addAttribute("content","住房和城乡建设部决定对《建设工程勘察质量管理办法》(建设部令第115号,根据建设部令第163号修改)作如下修改:");
        return new ModelAndView("custom/article/规章详情-PDF-Body");
    }
}
  • 添加freemarker pdfHeader模板
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8"/>
  <title>规章详情页眉</title>
  <style>
    html, body {
      margin: 0;
    }

    p {
      margin: 0;
      padding: 0;
    }

    .gz-detail {
      margin: auto 85px;
    }

    .gz-page-header {
      position: relative;
      padding-top: 69px;
      padding-bottom: 16px;
    }

    .gz-page-header .logo {
      width: 36px;
      display: inline-block;
      vertical-align: middle;
    }

    .gz-page-header .page-title {
      font-size: 24px;
      color: #005392;
      display: inline-block;
      vertical-align: middle;
      font-family: 'FangSong','仿宋', 'SimSun', '宋体';
      padding-left: 2px;
      font-weight: bold;
    }

    .gz-page-header .gz-detail-border-bottom {
      position: absolute;
      bottom: -5px;
      width: 100%;
      height: 3px;
      background: #015293;
      margin-bottom: 5px;
    }
  </style>
</head>
<body>
<div class="gz-detail">
  <div class="gz-page-header">
    <img class="logo" src="/assets/image/logo/icon_logo.png">
    <span class="page-title">住房和城乡建设部规章</span>
    <div class="gz-detail-border-bottom"></div>
  </div>
</div>
</body>
</html>
  • 添加freemarker pdfFooter模板
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8"/>
  <title>规章详情页脚</title>
  <style>
    html, body {
      margin: 0;
    }

    p {
      margin: 0;
      padding: 0;
    }
    .gz-detail {
      margin: auto 85px;
    }

    .gz-page-footer {
      border-top: 3px solid #015293;
      padding-top: 22px;
      padding-bottom: 22px;
      font-size: 24px;
      text-align: right;
      color: #015293;
      /*padding-right: 63px;*/
      padding-right: 0;
      font-weight: bold;
      font-family: 'FangSong','仿宋', 'SimSun', '宋体';
    }

    .left-page {
      padding: 7px 0;
      text-align: left;
      font-size: 24px;
      font-family: 'FangSong','仿宋', 'SimSun', '宋体';
    }

    .right-page {
      padding: 7px 0;
      text-align: right;
      font-size: 24px;
      font-family: 'FangSong','仿宋', 'SimSun', '宋体';
    }
  </style>
</head>
<body>
<div class="gz-detail">
  [#if page % 2 == 0]
  <div class="left-page">- ${page} -</div>
  [#else]
  <div class="right-page">- ${page} -</div>
  [/#if]
  <div class="gz-page-footer">住房和城乡建设部发布</div>
</div>
</body>
</html>
  • 添加freemarker pdfBody模板
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8"/>
  <title>规章详情正文</title>
  <style>
    html, body {
      margin: 0;
    }

    p {
      margin: 0;
      padding: 0;
    }

    .gz-detail {
      margin: auto 85px;
    }

    .gz-page-content {
      padding-top: 85px;
      position: relative;
    }

    .gz-page-content .content-title {
      text-align: center;
    }

    .gz-page-content .content-title .main-title,
    .gz-page-content .content-title .main-title * {
      font-size: 36px;
      font-family: 'SimSun', '宋体';
      color: #333333;
    }

    .gz-page-content .content-title .notes {
      font-size: 24px;
      padding: 14px 0 9px;
      margin-bottom: 30px;
      line-height: 150%;
      font-family: 'FangSong','仿宋', 'SimSun', '宋体';
      color: #333333;
    }

    .gz-page-content .editorContent-box {
      border: 0;
    }

    .gz-page-content .expire-box {
      position: absolute;
      right: 40px;
      top: 70px;
      text-align: center;
    }

    .gz-page-content .expire-box img {
      width: 189px;
      height: 76px;
    }

    .gz-page-content .expire-box .expire-text {
      padding-top: 5px;
      font-size: 14px;
      color: #757575;
    }

    .content-inner {
      width: 100%;
      height: auto;
      padding: 0 5px 40px;
      margin-top: 20px;
      overflow: hidden;
      font-size: 16px;
      line-height: 30px;
      box-sizing: border-box;
    }

    .content-inner div,
    .content-inner h1,
    .content-inner h2,
    .content-inner h3,
    .content-inner h4,
    .content-inner h5,
    .content-inner h6,
    .content-inner hr,
    .content-inner p,
    .content-inner blockquote,
    .content-inner dl,
    .content-inner dt,
    .content-inner dd,
    .content-inner ul,
    .content-inner ol,
    .content-inner pre,
    .content-inner span,
    .content-inner a,
    .content-inner i,
    .content-inner strong,
    .content-inner b,
    .content-inner form,
    .content-inner fieldset,
    .content-inner legend,
    .content-inner button,
    .content-inner input,
    .content-inner select,
    .content-inner textarea,
    .content-inner th,
    .content-inner td {
      font-size: inherit;
    }

    .content-inner a {
      color: #006AD2;
    }

    .content-inner img {
      max-width: 100%;
    }

    .content-inner p {
      line-height: 30px;
      margin-top: 15px;
    }

    .content-inner table,
    .content-inner img {
      margin: 0 auto;
    }

    .content-inner ol {
      list-style: decimal;
    }

    .content-inner ol li {
      list-style: inherit;
    }

    .content-inner ul {
      list-style: disc;
    }

    .content-inner ul li {
      list-style: inherit;
    }
  </style>
</head>
<body>
<div class="gz-detail">
  <div class="gz-page-content">
    <div class="content-title">
      <p class="main-title">${title}</p>
      <p class="notes">${notes}</p>
    </div>
    <div class="content-inner">
      ${content}
    </div>
  </div>
</div>
</body>
</html>
  • 使用wkhtmltopdf在命令行进行一次测试
/usr/local/bin/wkhtmltopdf --page-size A4 --header-spacing 3 --footer-spacing 6  
--header-html 'http://127.0.0.1:18080/api/page/ruleArticle/pdfHeader' 
--footer-html 'http://127.0.0.1:18080/api/page/ruleArticle/pdfFooter'
'http://127.0.0.1:18080/api/page/ruleArticle/pdfBody' 
/Users/jamesfu/pdf/wengao022101.pdf

实现效果还是不错的。


wkhtmltopdf实现效果

集成到后端服务中

  • 引用maven包java-wkhtmltopdf-wrapper
<!--wkhtmltopdf包装器:https://github.com/jhonnymertz/java-wkhtmltopdf-wrapper-->
        <dependency>
            <groupId>com.github.jhonnymertz</groupId>
            <artifactId>java-wkhtmltopdf-wrapper</artifactId>
            <version>1.1.13-RELEASE</version>
        </dependency>
  • 使用java-wkhtmltopdf-wrapper生成文件,然后上传到文件服务器
 @Resource
    private IFileUploadService fileUploadService;

    @Resource
    private SiteConfig siteConfig;

    @Resource
    private BusinessConfig businessConfig;

    @Resource
    private Configuration configuration;

    /**
     * 生成规章pdf文件
     *
     * @return pdf文件上传结果
     */
    @Override
    public FileUploadResult generatePdf(Map<String, Object> data) throws TemplateException, IOException {
        //构造模板路径和输出文件路径
        String articleTitle = data.get("title").toString();
        String resultFileName = articleTitle + ".pdf";

        String outputDir = siteConfig.getStaticTempDir() + File.separator + "ruleArticle";
        String outputFilePath = outputDir + File.separator + resultFileName;
        FileUtil.mkdir(outputDir);
        File outputFile = FileUtil.file(outputFilePath);

        String headerHtml = String.format("http://127.0.0.1:%s/api/page/ruleArticle/pdfHeader", businessConfig.getServerPort());
        String footerHtml = String.format("http://127.0.0.1:%s/api/page/ruleArticle/pdfFooter", businessConfig.getServerPort());
        String bodyHtml = generateBodyHtml(data);

        try (FileOutputStream fos = new FileOutputStream(outputFile)) {
            String wkhtmltopdfCommand = businessConfig.getWkhtmltopdfCommand();
            WrapperConfig wc = new WrapperConfig(wkhtmltopdfCommand);
            Pdf pdf = new Pdf(wc);
            pdf.addParam(new Param("--enable-local-file-access"));
            pdf.addParam(new Param("--page-size", "A4"));
            pdf.addParam(new Param("--header-spacing", "3"));
            pdf.addParam(new Param("--footer-spacing", "6"));
            pdf.addParam(new Param("--header-html", headerHtml));
            pdf.addParam(new Param("--footer-html", footerHtml));
            pdf.addPageFromString(bodyHtml);

            pdf.saveAsDirect(outputFilePath);

            fos.flush();
            fos.close();

            return fileUploadService.uploadFile(outputFile);
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            FileUtil.del(outputFile);
        }
    }

    private String generateBodyHtml(Map<String, Object> data) throws IOException, TemplateException {
        Template template = configuration.getTemplate("/custom/article/规章详情-PDF-Body.html");
        try (ByteArrayOutputStream fileOutputStream = new ByteArrayOutputStream();
             Writer writer = new OutputStreamWriter(fileOutputStream, StandardCharsets.UTF_8)) {
            template.process(data, writer);

            return fileOutputStream.toString();
        }
    }

总结

  1. 在生成pdf文件中的过程中发现,如果网页不标准,比如缺少标题title,会出错,导致生成空白的pdf文件。
  2. 字体问题:在linux服务器环境,需要提前安装相关的中文字体
有服务器上运行fc-list命令查看安装的中文字体
fc-list :lang=zh-cn
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345

推荐阅读更多精彩内容