服務器安裝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
本文章為轉載內容,我們尊重原作者對文章享有的著作權。如有內容錯誤或侵權問題,歡迎原作者聯繫我們進行內容更正或刪除文章。