返回 导航

SpringBoot / Cloud

hangge.com

SpringBoot - 实现将HTML内容转换为PDF文档教程(使用OpenHTMLToPDF)

作者:hangge | 2025-11-21 08:49
    在前一篇文章中我演示了如何借助 docx4jHTML(或 XHTML)转换为 Word 文档(点击查看),本文接着演示如何使用 OpenHTMLToPDF 这个第三方库 OpenHTMLToPDFHTML 内容转换成 PDF 文件。

1,准备工作

(1)首先我们需要在项目的 pom.xml 中添加 OpenHTMLToPDF 以及相关的依赖。
<!-- OpenHTMLToPDF (基于 PDFBox 输出) -->
<dependency>
	<groupId>com.openhtmltopdf</groupId>
	<artifactId>openhtmltopdf-pdfbox</artifactId>
	<version>1.0.10</version>
</dependency>

<!-- 可选:Svg 支持(如果你的 html 含 svg 图片) -->
<dependency>
	<groupId>com.openhtmltopdf</groupId>
	<artifactId>openhtmltopdf-slf4j</artifactId>
	<version>1.0.10</version>
</dependency>

<!-- 用于解析/修改 HTML(处理 img) -->
<dependency>
	<groupId>org.jsoup</groupId>
	<artifactId>jsoup</artifactId>
	<version>1.16.1</version>
</dependency>

<!-- apache commons-io:简化文件/流操作 -->
<dependency>
	<groupId>commons-io</groupId>
	<artifactId>commons-io</artifactId>
	<version>2.14.0</version>
</dependency>

(2)接着为了避免导出的文件中文不显示(显示 # 号乱码),需要将中文字体文件放在 resources/fonts 目录下, 后面使用时进行注册。
提示:这里我使用的“思源黑体”这个开源字体。GoogleAdobe 联合开发的思源黑体(Noto Sans SC)常规字重版本,属于无衬线开源字体,支持中文、拉丁文等 30,000+ 字符,采用 OFL 开源协议允许免费商用。(点击下载

2,样例代码

(1)我这里创建一个 Controller,该 Controller 暴露 /export-pdf 接口,接收形如 { html: "...", filename?: "xxx.docx" } JSON 数据,返回 application/pdf 二进制流。
package com.example.demo.controller;

import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.apache.commons.io.FilenameUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.apache.commons.io.FileUtils;
import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;

@RestController
@CrossOrigin
public class ExportController {
    /**
     * POST /export-pdf
     * 请求体示例:
     * { "html": "<html>...</html>", "filename": "report.pdf" }
     *
     * 注意:OpenHTMLToPDF 不会执行 JS,请确保前端传过来的是已经渲染好的静态 HTML(editor.getHTML())。
     */
    @PostMapping(value = "/export-pdf", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<byte[]> exportPdf(@RequestBody ExportRequest req,
                    @RequestHeader(value = "baseUri", required = false) String baseUri) {
        String html = req.getHtml();
        String filename = (req.getFilename() == null || req.getFilename().isEmpty()) ?
                ("document-" + System.currentTimeMillis() + ".pdf") : req.getFilename();

        List<File> tmpFiles = new ArrayList<>(); // 用于所有临时图片/字体,最后统一删除

        try {
            // 1) 用 Jsoup 处理 HTML,方便操作 <img> 等
            Document doc = Jsoup.parse(html);
            // 处理 <img>:若 data: 或 http(s) -> 下载或转为临时文件,并替换 src 为 file:///
            Elements imgs = doc.select("img");
            for (Element img : imgs) {
                String src = img.attr("src");
                if (src == null || src.isEmpty()) continue;

                try {
                    if (src.startsWith("data:")) {
                        // data:image/...;base64,...
                        String base64Part = src.substring(src.indexOf(",") + 1);
                        byte[] data = Base64.getDecoder().decode(base64Part);
                        String ext = guessExtensionFromData(src);
                        File tmp = File.createTempFile("img_" + UUID.randomUUID(), "." + ext);
                        FileUtils.writeByteArrayToFile(tmp, data);
                        tmpFiles.add(tmp);
                        img.attr("src", tmp.toURI().toString());
                    } else if (src.startsWith("http://") || src.startsWith("https://")) {
                        // 下载远程图片
                        URL url = new URL(src);
                        String ext = guessExtensionFromUrl(src);
                        File tmp = File.createTempFile("img_" + UUID.randomUUID(),
                                ext != null ? "." + ext : ".img");
                        try (InputStream in = url.openStream()) {
                            FileUtils.copyInputStreamToFile(in, tmp);
                            tmpFiles.add(tmp);
                            img.attr("src", tmp.toURI().toString());
                        } catch (Exception ex) {
                            // 若某张图片下载失败,记录并继续(不要阻塞整个转换)
                            ex.printStackTrace();
                        }
                    } else {
                        // 相对路径或 file:// 等,保留原样
                    }
                } catch (Exception ex) {
                    // 单张处理异常不影响全局
                    ex.printStackTrace();
                }
            }

            // 2) optional: 注入默认打印样式,提升与浏览器一致性(用户的 <style> 会与它合并)
            //    如果前端已经包含专门的 print CSS,可以考虑跳过或覆盖部分属性。
            String defaultPrintStyle = "<style>" +
                    "@page { size: A4; margin: 10mm; }" +
                    "body { font-family: 'NotoSansSC-Regular', Arial, Helvetica, sans-serif; " +
                    "font-size: 12pt; line-height: 1.6; color: #222; }" +
                    "p { margin: 0 0 10px; }" +
                    "img { max-width: 100%; height: auto; }" +
                    "table { border-collapse: collapse; width: 100%; }" +
                    "th { background-color: #f2f2f2; }" +
                    "th, td { padding: 8px; border: 1px solid #ddd; vertical-align: top; }" +
                    "thead { display: table-header-group; }" +
                    "tr { page-break-inside: avoid; }" +
                    "</style>";

            // 将打印样式插入到 <head> 中(若没有 head,Jsoup 会补齐)
            Element head = doc.head();
            head.append(defaultPrintStyle);

            // 3) 最终 HTML
            String processedHtml = doc.outerHtml();
            // 先处理自闭合标签
            processedHtml = processedHtml.replaceAll("<(img|meta|br|hr)([^>]*)>", "<$1$2/>");
            processedHtml = processedHtml.replaceAll("<(col)(?!group)([^>]*)>", "<$1$2/>");

            // 4) OpenHTMLToPDF 渲染
            try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
                PdfRendererBuilder builder = new PdfRendererBuilder();

                // baseUri 用于解析 HTML 中的相对链接。如果请求 header 中传了 baseUri 就用它,
                // 否则可以把 baseUri 指向临时目录(where tmp images are located),或留空 ""。
                String effectiveBaseUri = (baseUri != null && !baseUri.isEmpty()) ? baseUri : "";

                // 如果你把远程图片下载为 file://... ,用 file 的 parent dir 作为 baseUri 也可
                if (tmpFiles.size() > 0) {
                    // 取第一个临时文件的 parent 作为 baseUri(保证 file:///... 能解析相对路径)
                    File parent = tmpFiles.get(0).getParentFile();
                    if (parent != null) {
                        effectiveBaseUri = parent.toURI().toString();
                    }
                }

                builder.withHtmlContent(processedHtml, effectiveBaseUri);

                // 可选:如果你需要中文或自定义字体,请把字体文件放在 resources/fonts 下,
                // 并在下面注册(示例给出如何加载 fonts/NotoSansSC-Regular.otf)
                // 如果没有自定义字体可以跳过这一步
                try {
                    // 下面示例尝试加载 resources/fonts 下的几个常见字体文件(按需修改)
                    List<String> resourceFonts = Arrays.asList(
                            "fonts/NotoSansSC-Regular.ttf",
                            "fonts/NotoSansSC-Bold.ttf"
                    );
                    for (String resFontPath : resourceFonts) {
                        InputStream fis = getClass().getClassLoader().getResourceAsStream(resFontPath);
                        if (fis != null) {
                            File tmpFont = File.createTempFile("font_" + UUID.randomUUID(), ".tmp");
                            FileUtils.copyInputStreamToFile(fis, tmpFont);
                            tmpFiles.add(tmpFont);
                            // 使用文件名(不含扩展)作为 font-family 名称,随后你可以在 CSS 中使用该名称
                            String family = FilenameUtils.getBaseName(resFontPath); // NotoSansSC-Regular
                            builder.useFont(tmpFont, family);
                            // 也可以在 processedHtml 的 style 中设置 body { font-family: 'family'; }
                        }
                    }
                } catch (Exception fe) {
                    // 字体加载失败不应阻止渲染,打印日志并继续
                    fe.printStackTrace();
                }

                builder.toStream(os);
                // 重要:run() 可能抛异常
                builder.run();

                byte[] pdfBytes = os.toByteArray();
                HttpHeaders headers = new HttpHeaders();
                headers.setContentType(MediaType.APPLICATION_PDF);
                headers.setContentDisposition(ContentDisposition.attachment().filename(filename).build());
                headers.setContentLength(pdfBytes.length);

                return ResponseEntity.ok()
                        .headers(headers)
                        .body(pdfBytes);
            }
        } catch (Exception ex) {
            ex.printStackTrace();
            return ResponseEntity.status(500).body(("PDF Conversion failed: " + ex.getMessage())
                    .getBytes(StandardCharsets.UTF_8));
        } finally {
            // 清理临时文件
            for (File f : tmpFiles) {
                try {
                    if (f != null && f.exists()) f.delete();
                } catch (Exception ignored) { }
            }
        }
    }

    // 从 data: URI 头部猜测图片格式
    private static String guessExtensionFromData(String dataUri) {
        try {
            String mime = dataUri.substring(5, dataUri.indexOf(";"));
            if (mime.contains("/")) {
                return mime.substring(mime.indexOf("/") + 1);
            }
        } catch (Exception ignored) {}
        return "png";
    }

    // 从 URL 路径猜测图片格式
    private static String guessExtensionFromUrl(String url) {
        try {
            String lower = url.toLowerCase();
            if (lower.contains(".png")) return "png";
            if (lower.contains(".jpg") || lower.contains(".jpeg")) return "jpg";
            if (lower.contains(".gif")) return "gif";
            if (lower.contains(".svg")) return "svg";
            if (lower.contains(".bmp")) return "bmp";
        } catch (Exception ignored) {}
        return null;
    }

    // DTO (复用你现有的 ExportRequest)
    public static class ExportRequest {
        private String html;
        private String filename;

        public String getHtml() { return html; }
        public void setHtml(String html) { this.html = html; }
        public String getFilename() { return filename; }
        public void setFilename(String filename) { this.filename = filename; }
    }
}

(2)代码主要说明如下:
  • 处理 data: URI 与远程图片(下载为临时文件并把 src 改为 file://
  • 注入一个默认的打印样式(@pageline-heighttable 样式等),可与前端传入的 <style> 一起工作
  • 可选地从 resources/fonts/ 加载字体并注册到 renderer(如需中文支持,务必把对应字体放入 src/main/resources/fonts/ 并在代码中列出)
  • 使用 PdfRendererBuilder 输出 PDF 并返回二进制响应
  • 清理所有临时文件(图片 + 可能的字体临时文件)

3,运行测试

(1)假设我需要将如下 html 页面内容转换成 pdf
<html>
<head>
    <meta charset="utf-8" />
    <style>
        body {
            font-family: "Arial", sans-serif;
            font-size: 12p;
        }

        table {
            border-collapse: collapse;
        }

        th {
            background: #F9FAFB;
            font-weight: 600;
        }

        td,
        th {
            border: 1px solid #ccc;
            padding: 4px;
        }
    </style>
</head>
<body>
    <p>欢迎访问<a href="https://www.hangge.com">hangge.com</a></p>
    <p><img src="https://www.hangge.com/blog/images/logo.png" alt="logo"></p>
    <p></p>
    <p>测试<strong>加粗</strong>测试<em>斜体</em>测试<u>下划线</u>测试<s>删除线</s></p>
    <p></p>
    <p><span style="color: rgb(255, 102, 255);">修改文字颜色</span></p>
    <p><span style="font-size: 22px;">调整文字大小</span></p>
    <p></p>
    <p>下面是一个3x3表格</p>
    <table>
        <thead>
            <tr>
                <th>ID</th>
                <th>用户名称</th>
                <th>用户年龄</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>1</td>
                <td>刘小龙</td>
                <td>88</td>
            </tr>
            <tr>
                <td>2</td>
                <td>张大伟</td>
                <td>66</td>
            </tr>
        </tbody>
    </table>
</body>
</html>

(2)我们使用 Postman 测试这个 /export-pdf 接口,传入 html 内容和文件名,并将结果保存到本地。
提示html 内容可以是完整的 html 代码,也可以想示例图片一样只传递 <body> 标签里面的内容。

(3)打开保存下来的 report.pdf 文件,可以看到该文档内容如下:
评论

全部评论(0)

回到顶部