服務器安裝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