服务器安装wkhtmltopdf示例

50 阅读5分钟

服务器安装wkhtmltopdf

下载rpm包至本地

https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox-0.12.6-1.centos8.x86_64.rpm

上传rpm包至服务器

scp -r wkhtmltox-0.12.6-1.centos8.x86_64.rpm root@服务器IP:/tmp/

云服务器上执行以下命令

cd /tmp

安装wkhtmltopdf

sudo dnf install -y wkhtmltox-0.12.6-1.centos8.x86_64.rpm

验证是否安装成功

wkhtmltopdf --version

预期输出结果

wkhtmltopdf 0.12.6 (with patched qt)

html包含中文,wkhtmltopdf需要安装中文字体

下载字体并上传至云服务器

https://sourceforge.net/projects/source-han-serif.mirror/files/2.002R/14_SourceHanSerifCN.zip
https://github.com/adobe-fonts/source-han-sans/releases/download/2.004R/SourceHanSansCN.zip
scp -r 14_SourceHanSerifCN.zip root@服务器IP:/tmp/
scp -r SourceHanSansCN.zip root@服务器IP:/tmp/

安装字体

sudo unzip /tmp/SourceHanSansCN.zip -d /usr/share/fonts/source-han-sans
mv 14_SourceHanSerifCN.zip SourceHanSerifCN.zip
sudo unzip /tmp/SourceHanSerifCN.zip -d /usr/share/fonts/source-han-serif

刷新字体缓存

sudo fc-cache -fv

执行后应该看到类似:

/usr/share/fonts/source-han-sans: caching...
/usr/share/fonts/source-han-serif: caching...

wkhtmltopdf 命令行测试(验证字体是否能显示)

创建test.html

<p style="font-family:'Source Han Sans CN'; font-size:40px;">
  我是中文测试
</p>

执行

wkhtmltopdf --encoding UTF-8 test.html test.pdf

Python 远程 PDF 生成服务(Flask 版本)

创建pdf_service.py

from flask import Flask, request, send_file, jsonify
import subprocess
import uuid
import os

app = Flask(__name__)

PDF_OUTPUT_DIR = "/tmp/pdf-service"
os.makedirs(PDF_OUTPUT_DIR, exist_ok=True)


@app.route("/html2pdf", methods=["POST"])
def html_to_pdf():
    try:
        data = request.json
        html_content = data.get("html")

        if not html_content:
            return jsonify({"error": "html content is required"}), 400

        # 临时 HTML 文件
        html_path = os.path.join(PDF_OUTPUT_DIR, f"{uuid.uuid4()}.html")
        pdf_path = html_path.replace(".html", ".pdf")

        with open(html_path, "w", encoding="utf-8") as f:
            f.write(html_content)

        # 调用 wkhtmltopdf 
        cmd = ["wkhtmltopdf", html_path, pdf_path]
        subprocess.run(cmd, check=True)

        # 返回 PDF
        return send_file(pdf_path, as_attachment=True, download_name="output.pdf")

    except subprocess.CalledProcessError as e:
        return jsonify({"error": f"wkhtmltopdf failed: {str(e)}"}), 500

    except Exception as e:
        return jsonify({"error": str(e)}), 500

    finally:
        # 清理临时文件
        try:
            if os.path.exists(html_path):
                os.remove(html_path)
        except:
            pass


@app.route("/ping", methods=["GET"])
def ping():
    return "ok"


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=15005)

服务器防火墙开放端口

sudo firewall-cmd --permanent --add-port=15005/tcp
sudo firewall-cmd --reload

运行python服务

nohup python3 pdf_service.py   > pdfService.log 2>&1 &

重启python服务

ps aux | grep pdf_service.py
kill PID
nohup python3 pdf_service.py > pdf_service.log 2>&1 &

本地服务调用

pom.xml内容

<properties>
    <java.version>17</java.version>
    <spring-boot.version>3.3.4</spring-boot.version>
  </properties>

  <dependencyManagement>
    <dependencies>
      <!-- 覆盖 Spring Boot 依赖 -->
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-dependencies</artifactId>
        <version>${spring-boot.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <!-- Spring Boot Web -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Thymeleaf模板引擎 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <scope>provided</scope>
    </dependency>

    <dependency>
      <groupId>com.itextpdf</groupId>
      <artifactId>itext7-core</artifactId>
      <version>7.2.3</version>
      <type>pom</type>
    </dependency>
    <dependency>
      <groupId>com.itextpdf</groupId>
      <artifactId>layout</artifactId>
      <version>7.2.3</version>
    </dependency>
    <!-- PDF生成 -->
    <dependency>
      <groupId>org.xhtmlrenderer</groupId>
      <artifactId>flying-saucer-pdf</artifactId>
      <version>9.1.22</version>
    </dependency>
    <dependency>
      <groupId>org.apache.pdfbox</groupId>
      <artifactId>pdfbox</artifactId>
      <version>3.0.3</version> <!-- 或更高版本 -->
    </dependency>
    <dependency>
      <groupId>org.apache.pdfbox</groupId>
      <artifactId>pdfbox-tools</artifactId>
      <version>3.0.3</version>
    </dependency>
    <dependency>
      <groupId>org.apache.httpcomponents</groupId>
      <artifactId>httpclient</artifactId>
      <version>4.5.14</version>
      <scope>compile</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.11.0</version>
        <configuration>
          <source>17</source>
          <target>17</target>
          <compilerArgs>
            <arg>-parameters</arg>
          </compilerArgs>
        </configuration>
      </plugin>
    </plugins>
  </build>

resources/templates目录下创建test-template.html文件

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8"/>
  <title>xxx证书</title>
  <style>
    body {
      font-family: "Source Han Sans CN", sans-serif;
    }

    .container {
      width: 900px;
      height: 600px;
      padding: 80px 94px 120px;
      box-sizing: border-box;
      margin: 0 auto;
      position: relative;   /* 必须有 */
      color: #000;
    }

    .background {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      z-index: 0;
    }

    .description {
      position: absolute;
      z-index: 1;
      bottom: 8%;
      margin-left: 6%;
      font-size: 18px;
      font-family: "Source Han Serif CN", serif;
    }

    .con-unit {
      font-size: 16px;
      font-weight: 700;
      position: absolute;
      right: 1px;
      bottom: 13%;
      letter-spacing: 3px;
      margin-right: 5%;
    }

    .chapter {
      position: absolute;
      top: 64.5%;
      right: 6%;
      width: 120px;
      height: 120px;
    }

    .tit {
      position: absolute;
      color: #E99D42;
      font-size: 48px;
      font-weight: 700;
      text-align: center;
      margin: 20px 0;
      letter-spacing: 20px;
      font-family: "Source Han Serif CN", serif;
      top: 16%;
      left: 36%;
    }

    .con {
      position: absolute;
      font-size: 24px;
      font-weight: 700;
      text-align: left;
      top: 30%;
      left: 15%;
      right: 5%;
      line-height: 1.5;
    }

    .con-name {
      text-decoration: underline;
      padding: 0 5px;
    }

    .indented {
      margin-left: 2em; /* 1em约等于当前字体大小,通常2em大约是4个字符宽度 */
    }

    .code-wrap {
      position: absolute;
      font-size: 22px;
      font-weight: 700;
      margin: 10px 0;
      line-height: 20px;
      text-indent: 2em;
      top: 54%;
      left: 13%;
    }

    .code-wrap-certificate-number {
      position: absolute;
      font-size: 22px;
      font-weight: 600;
      margin: 10px 0;
      line-height: 20px;
      text-indent: 2em;
      top: 61%;
      left: 13%;
    }
  </style>
</head>
<body>
<div class="container">
  <div class="content">
    <img class="background" th:src="${zsImgBase64}" alt="Certificate Background"/>
    <div class="wave-background"></div>

    <div>
      <img class="chapter" th:src="${zsSealImgBase64}" alt="Seal"/>
    </div>

    <!-- 调整文字排版和间距 -->
    <p class="tit">xxx证书</p>

    <p class="con">
      <span class="con-name indented" th:text="${templateDetails.xxx9}"></span>
      <span class="con-name" th:text="${templateDetails.xxx1}"></span>
      xxx,于
      <span class="con-name" th:text="${xxx2}"></span><span class="con-name" th:text="${xxx3}"></span><span class="con-name" th:text="${templateDetails.xxx4}"></span>
      参加xxx,xx为
      <span class="con-name" th:text="${templateDetails.xxx5}"></span>
      ,经xx与xxxx。
    </p>

    <p class="code-wrap">
      <span>xxxx:<span th:text="${templateDetails.xxx6}"></span></span>
    </p>
    <p class="code-wrap-certificate-number">
      <span>证书编号:<span th:text="${templateDetails.xxx7}"></span></span>
    </p>

    <div class="con-unit">
      <p class="time" th:text="${xxx8}"></p>
    </div>

    <div class="description">
      *本次由xxx提供信息管理与认证服务
    </div>
  </div>
</div>

</body>
</html>

resources/images/目录下需要两张背景图片

zsImgBase64.png
zsSealImgBase64.png

创建FileToPdfUtils类

package com.hyb.utils;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.tools.imageio.ImageIOUtil;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

/**
 * 文件转PDF工具类
 **/
@Slf4j
@Configuration
public class FileToPdfUtils {

    private String tempFilePath = "临时文件存放目录";

    private String htmlToPdfServiceUrl = "http://IP地址:15005/html2pdf";// html转pdf服务地址

    /**
     * html转pdf
     * @param htmlContent html内容
     * @return oss文件上传返回对象
     * @throws Exception
     */
    public File htmlToPdf(String htmlContent) throws Exception {
        File tempFile = htmlToFile(htmlContent);
        if (tempFile == null) {
            return null;
        }
        return tempFile;
    }

    /**
     * html转图片
     * @param htmlContent html内容
     * @return oss文件上传返回对象
     * @throws Exception
     */
    public File htmlToImage(String htmlContent) throws Exception {
        File tempFile = htmlToFile(htmlContent);
        if (tempFile == null) {
            return null;
        }
        String tempImage = tempFilePath + "/" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddhhmmss")) + ".PNG";
        // 使用 Loader 类加载 PDF
        try (PDDocument document = Loader.loadPDF(tempFile)) {
            PDFRenderer renderer = new PDFRenderer(document);
            // 逐页渲染为图片
            for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
                BufferedImage image = renderer.renderImageWithDPI(pageIndex, 300);
                ImageIOUtil.writeImage(image, tempImage, 300);
            }
        }
        return new File(tempImage);
    }

    /**
     * html转File文件对象
     * @param htmlContent html内容
     * @return File文件对象
     * @throws Exception
     */
    private File htmlToFile(String htmlContent) throws Exception {
        CloseableHttpClient client = HttpClients.createDefault();

        HttpPost post = new HttpPost(htmlToPdfServiceUrl);
        Map<String, Object> req = new HashMap<>();
        req.put("html", htmlContent);
        req.put("zoom", 1.0);
        ObjectMapper mapper = new ObjectMapper();
        String jsonBody = mapper.writeValueAsString(req);
        post.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON));
        CloseableHttpResponse response = client.execute(post);
        try {
            int status = response.getStatusLine().getStatusCode();
            if (status == 200) {
                // 读取 PDF 内容(二进制)
                InputStream is = response.getEntity().getContent();
                String fileName = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddhhmmss")) + ".pdf";
                File tempFile = new File(tempFilePath + fileName);
                FileOutputStream fos = new FileOutputStream(tempFile);
                byte[] buffer = new byte[4096];
                int len;
                while ((len = is.read(buffer)) != -1) {
                    fos.write(buffer, 0, len);
                }
                fos.close();
                is.close();
                return tempFile;
            } else {// 错误处理
                InputStream is = response.getEntity().getContent();
                String error = streamToString(is);
                log.error("HTML转PDF失败:{}", status);
                log.error("HTML转PDF失败错误内容:{}", error);
            }
        } finally {
            response.close();
            client.close();
        }
        return null;
    }

    /**
     * 输入流转字符串
     * @param is 输入流
     * @return
     * @throws IOException
     */
    public static String streamToString(InputStream inputStream) throws IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line);
        }
        return sb.toString();
    }

    /**
     * 将图片文件转换为Base64编码字符串
     *
     * @param imagePath 图片路径
     * @return Base64编码字符串
     * @throws Exception 读取图片异常
     */
    public static String getImageBase64(String imagePath) throws Exception {
        ClassPathResource imageResource = new ClassPathResource(imagePath);
        byte[] imageBytes = org.apache.commons.io.IOUtils.toByteArray(imageResource.getInputStream());
        return Base64.getEncoder().encodeToString(imageBytes);
    }
}

创建TemplateService类

package com.hyb.service;

import com.hyb.utils.FileToPdfUtils;
import java.io.File;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

@Slf4j
@Service
public class TemplateService {

    private final TemplateEngine templateEngine;
    private final FileToPdfUtils fileToPdfUtils;

    public TemplateService(TemplateEngine templateEngine, FileToPdfUtils fileToPdfUtils) {
        this.templateEngine = templateEngine;
        this.fileToPdfUtils = fileToPdfUtils;
    }

    /**
     * 生成PDF
     *
     * @param templateDetails 模版数据
     * @return PDF字节数组
     * @throws Exception 转换异常
     */
    public byte[] generatePdf(TemplateDetails templateDetails) throws Exception {
        // 准备模板数据
        Context context = new Context();
        context.setVariable("templateDetails", templateDetails);
        context.setVariable("xxx2", certificateDetails.getXxx2().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")));
        context.setVariable("xxx3", certificateDetails.getXxx3().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")));
        context.setVariable("xxx8", certificateDetails.getXxx8().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")));

        // 将图片转换为Base64编码
        context.setVariable("zsImgBase64", "data:image/png;base64," + getImageBase64("images/1.png"));
        context.setVariable("zsSealImgBase64", "data:image/png;base64," + getImageBase64("images/2.png"));

        // 渲染HTML模板
        String htmlContent = templateEngine.process("test-template", context);
        log.info("htmlContent: " + htmlContent);
        File file = fileToPdfUtils.htmlToPdf(htmlContent);
        return Files.readAllBytes(file.toPath());
    }
    
    /**
     * 生成图片
     *
     * @param 模版数据
     * @return 图片字节数组
     * @throws Exception 转换异常
     */
    public byte[] generateImage(TemplateDetails templateDetails) throws Exception {
        // 准备模板数据
        Context context = new Context();
        context.setVariable("templateDetails", templateDetails);
        context.setVariable("xxx2", templateDetails.getXxx2().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")));
        context.setVariable("xxx3", templateDetails.getXxx3().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")));
        context.setVariable("xxx8", templateDetails.getXxx8().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")));

        // 将图片转换为Base64编码
        context.setVariable("zsImgBase64", "data:image/png;base64," + getImageBase64("images/1.png"));
        context.setVariable("zsSealImgBase64", "data:image/png;base64," + getImageBase64("images/2.png"));

        // 渲染HTML模板
        String htmlContent = templateEngine.process("test-template", context);

        File file = fileToPdfUtils.htmlToImage(htmlContent);
        System.out.println(file.getAbsolutePath());
        System.out.println(file.getName());
        return Files.readAllBytes(file.toPath());
    }

    /**
     * 将图片文件转换为Base64编码字符串
     *
     * @param imagePath 图片路径
     * @return Base64编码字符串
     * @throws Exception 读取图片异常
     */
    private static String getImageBase64(String imagePath) throws Exception {
        ClassPathResource imageResource = new ClassPathResource(imagePath);
        byte[] imageBytes = IOUtils.toByteArray(imageResource.getInputStream());
        return Base64.getEncoder().encodeToString(imageBytes);
    }

    @Data
    public static class TemplateDetails {
        private String xxx1;           
        private LocalDateTime xxx2;  
        private LocalDateTime xxx3;    
        private String xxx4;          
        private String xxx5;              
        private String xxx6;        
        private String xxx7;    
        private LocalDateTime xxx8;  
        private String xxx9;           
    }
}

创建TemplateController类

package com.hyb.controller;

import com.hyb.service.TemplateService;
import com.hyb.service.TemplateService.TemplateDetails;
import jakarta.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/template")
public class TemplateController {

    @Autowired
    private TemplateService templateService;

    /**
     * 下载转换后的pdf文件或图片
     * @param type       下载类型(image/pdf)
     */
    @GetMapping("/download")
    public void download(@RequestParam(value = "type", defaultValue = "pdf") String type, HttpServletResponse response) throws Exception {
        // 模版数据
        TemplateDetails templateDetails = buildTemplateDetails();
        
        byte[] bytes;
        String fileName;
        String contentType;
        if ("image".equalsIgnoreCase(type)) {
            bytes = templateService.generateImage(templateDetails);
            fileName = "转换后图片.png";
            contentType = "image/png";
        } else {
            bytes = templateService.generatePdf(templateDetails);
            fileName = "转换后PDF.pdf";
            contentType = "application/pdf";
        }
        
        try {
            // 对文件名进行 UTF-8 编码,防止中文或特殊字符导致的问题
            String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString());
            // 设置 Content-Disposition 响应头,指定文件以附件形式下载,并设置文件名
            response.setHeader("Content-Disposition", "attachment;filename=" + encodedFileName);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("文件名编码失败,请稍后重试");
        }
        response.setContentType(contentType);
        response.setContentLength(bytes.length);
        
        // 使用 try-with-resources 自动管理资源
        try (OutputStream os = response.getOutputStream();
             InputStream ins = new ByteArrayInputStream(bytes)) {
            byte[] buffer = new byte[1024];
            int bytesRead;
            while ((bytesRead = ins.read(buffer)) != -1) {
                os.write(buffer, 0, bytesRead);
            }
            os.flush();
        } catch (IOException e) {
            throw new RuntimeException("文件下载失败,请稍后重试");
        }
    }

    /**
     * 构建模版数据
     */
    public static TemplateDetails buildTemplateDetails() {
        TemplateDetails details = new TemplateDetails();
        details.setXxx1("xxx");
        details.setXxx2(LocalTime.now());
        details.setXxx3(LocalTime.now());
        details.setXxx4("xxx");
        details.setXxx5("xxx");
        details.setXxx6("xxx");
        details.setXxx7("xxx");
        details.setXxx8(LocalDateTime.now());
        details.setXxx9("xxx");
        return details;
    }
}

application.yml

server:
  port: 8080

使用postman或apifox get请求访问,即可看到转换后的pdf文件或图片

http://localhost:8080/template/download?type=pdf
http://localhost:8080/template/download?type=image