SpringBoot集成itext5,实现pdf生成

1、前言

在项目开发中有需要生成pdf文件的功能,常见的方案如下:

  1. html作为模板,(Thymeleaf、freemaker等模板引擎)渲染数据,最后生成pdf
  2. 使用pdf模板(使用如:Adobe Acrobat编辑器,创建一个带有表单字段的PDF),填充数据,最后生成pdf
  3. 使用pdf模板(使用占位符,比如${name}),用数据替换占位符,最后生成pdf。比较复杂,不容易实现,不建议。

如何得到pdf文件,主要有两种方法,一种是写代码生成,还有就是用pdf转换工具,比如wkhtmltopdf之类的。由于在项目中考虑到,使用html的方案,在修改模板时对用户不友好,所以采用了方案2:pdf模板+表单+填充数据。

工具使用itext5,因为itext7免费使用受到了某些限制,必须遵循 AGPL 开源协议,或购买商业许可证。还有其他免费的,如:Apache PDFBox,特点如下:

技术 特点 适用场景
iText 功能强大,支持复杂 PDF 操作 复杂 PDF 生成
Apache PDFBox 免费,简单易用 简单 PDF 生成
Thymeleaf + Flying Saucer 基于 HTML 模板生成 PDF 需要 HTML 模板的场景
OpenPDF iText 的免费分支,轻量级 简单 PDF 生成
JFreeReport + JFreeChart 支持生成带图表的 PDF 需要图表的 PDF 生成

2、开发过程

环境:SpringBoot 2.7.6、JDK1.8、itext 5.x

引入相关依赖

<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itextpdf</artifactId>
    <version>5.5.9</version>
    <scope>compile</scope>
</dependency>
<!--中文字体问题解决-->
<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itext-asian</artifactId>
    <version>5.2.0</version>
</dependency>

准备好pdf,并且设置好表单字段。

直接打卡Adobe Acrobat,创建一个空白pdf,然后在工具里面,点击准备表单。实际上,在创建pdf时,可以选择word文件,因为直接编辑pdf格式很难调整,而格式调整好的word转成pdf就好办多了,在相应对方添加表单域就可以了。

image-20250227155017718

按钮一栏可以自行摸索,添加相应的域,这里就不多解释了。注意pic字段,我用的依然是文本域,而不是图片域,因为代码不一样,这个后面再说。

image-20250227162640729

注意,复选框要设置选中显示的值,也就是图中的导出值,样式也可以设置。

编写代码

PdfUtils的主要方法如下:

/**
 * 通过pdf模板输出到流
 *
 * @param templateFile 模板
 * @param dataMap      input数据
 * @param picData      image图片
 * @param checkboxMap  checkbox勾选框
 * @param params       设置属性,字体大小、颜色 textfont/textcolor/textsize等
 * @param outputStream 输出流
 */
public static void output(String templateFile, Map<String, Object> dataMap, Map<String, byte[]>picData,
                          Map<String, byte[]> picData2,Map<String, String> checkboxMap, 
                          Map<String,Object> params,OutputStream outputStream) {
    PdfStamper ps = null;
    PdfReader reader = null;
    try {
        reader = new PdfReader(templateFile);
        ps = new PdfStamper(reader, outputStream);
        AcroFields form = ps.getAcroFields();
        BaseFont bf = BaseFont.createFont("STSongStd-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);
        form.addSubstitutionFont(bf);
        if (ObjectUtils.isNotEmpty(dataMap)) {
            for (String key : dataMap.keySet()) {
                form.setField(key, String.valueOf(dataMap.getOrDefault(key, "")));
            }
        }
        //不能编辑
        ps.setFormFlattening(true);
        if (ObjectUtils.isNotEmpty(checkboxMap)) {
            for (String key : checkboxMap.keySet()) {
                form.setField(key, checkboxMap.get(key), true);
            }
        }

        PdfStamper stamper = ps;
        if (ObjectUtils.isNotEmpty(picData)) {
            picData.forEach((filedName, imgSrc) -> {
                List<AcroFields.FieldPosition> fieldPositions = form.getFieldPositions(filedName);
                for (AcroFields.FieldPosition fieldPosition : fieldPositions) {
                    int pageNo = fieldPosition.page;
                    Rectangle signRect = fieldPosition.position;
                    float x = signRect.getLeft();
                    float y = signRect.getBottom();
                    try {
                        Image image = Image.getInstance(imgSrc);
                        PdfContentByte under = stamper.getOverContent(pageNo);
                        image.scaleToFit(signRect.getWidth(), signRect.getHeight());
                        image.setAbsolutePosition(x, y);
                        under.addImage(image);
                    } catch (BadElementException e) {
                        log.error("pdf模板,文档元素错误 ", e);
                    } catch (IOException e) {
                        log.error("pdf模板,IO错误 ", e);
                    } catch (DocumentException e) {
                        log.error("pdf模板,文档操作错误 ", e);
                    }
                }
            });
        }
        if (!ObjectUtils.isEmpty(picData2)) {
                picData2.forEach((filedName, imgSrc) -> {
                    List<AcroFields.FieldPosition> fieldPositions = form.getFieldPositions(filedName);
                    for (AcroFields.FieldPosition fieldPosition : fieldPositions) {
                        int pageNo = fieldPosition.page;
                        Rectangle signRect = fieldPosition.position;
                        float x = signRect.getLeft();
                        float y = signRect.getBottom();
                        try {
                            URL url = new URL(imgSrc);
                            Image image = Image.getInstance(url);
                            PdfContentByte under = stamper.getOverContent(pageNo);
                            image.scaleToFit(signRect.getWidth(), signRect.getHeight());
                            image.setAbsolutePosition(x, y);
                            under.addImage(image);
                        } catch (BadElementException e) {
                            e.printStackTrace();
                        } catch (IOException e) {
                            e.printStackTrace();
                        } catch (DocumentException e) {
                            e.printStackTrace();
                        }
                    }
                });
            }
    } catch (Exception e) {
        log.error("pdf模板填充数据异常 ", e);
    } finally {
        if (Objects.nonNull(ps)) {
            try {
                ps.close();
            } catch (Exception e) {
                log.error("pdf模板关闭异常 ", e);
            }
        }
        if (Objects.nonNull(reader)) {
            try {
                reader.close();
            } catch (Exception e) {
                log.error("pdf模板关闭异常 ", e);
            }
        }

    }
}

参数说明:

参数名称 参数类型 说明
templateFile String /xx/xx/template.pdf pdf模板,全路径
dataMap Map<String, Object> { “name”: “John”, “age”: 30, “address”: “北京”} 文本字段数据的map
picData Map<String, byte[]> {“pic1”:”xxx”} 图片的数据map,其中byte[]是图片文件转成的字节数组
picData2 Map<String, String> {“pic2”:”xxx”} 图片的数据map,其中值是图片的url地址
checkboxMap Map<String, String> {“checkbox1”:”1”} 复选框的数据map。1代表选中,这个是在编辑pdf模板时设置的。
params Map<String,Object> {“textsize”:”12f”} 设置属性,字体大小、颜色 textfont/textcolor/textsize等
outputStream OutputStream 生成的pdf后,输出流

接口代码:

@GetMapping("/previewPdf")
public void previewPdf(HttpServletRequest request, HttpServletResponse response) throws IOException {
    String templateFile = "D:\\workspace\\IdeaProjects\\pdfDemo\\src\\main\\resources\\static\\template.pdf";
    Map<String, Object> dataMap = new HashMap<>();
    dataMap.put("name", "张小凡");
    dataMap.put("age", "101");
    dataMap.put("address", "上海市宛平南路600号");
    dataMap.put("year", LocalDate.now().getYear());
    dataMap.put("month", LocalDate.now().getMonthValue());
    dataMap.put("day", LocalDate.now().getDayOfMonth());

    response.setCharacterEncoding("utf-8");
    response.setContentType("application/pdf");

    Map<String, byte[]> picMap = new HashMap<>();
    byte[] imageToBytes = ImageUtil.imageToBytes("D:\\workspace\\IdeaProjects\\pdfDemo\\src\\main\\resources\\static\\33981008.jpg");
    picMap.put("pic", imageToBytes);

    Map<String, String> picMap2 = new HashMap<>();
    picMap2.put("pic2", "https://tse3-mm.cn.bing.net/th/id/OIP-C.jdP04yEoxG10mcywseQj7gAAAA?rs=1&pid=ImgDetMain");

    Map<String, String> checkboxMap = new HashMap<>();
    checkboxMap.put("smoke", "1");
    checkboxMap.put("drink", "1");
    checkboxMap.put("hair", "0");

    String fileName = new String("测试预览pdf文件".getBytes(), "ISO-8859-1");//避免中文乱码
    response.setHeader("Content-Disposition", "inline;filename=".concat(String.valueOf(fileName) + ".pdf"));
    PdfUtil.output(templateFile, dataMap, picMap, picMap2,checkboxMap, response.getOutputStream());
}

下载的接口只要修改一行代码:

response.setHeader("Content-Disposition", "attachment;filename=".concat(fileName + ".pdf"));

结果展示:

image-20250227164234458

ok,完美

3、代码解释以及一些问题

获取表单的所有字段:

AcroFields form = ps.getAcroFields();

创建BaseFont对象,用于加载字体

BaseFont bf = BaseFont.createFont("STSongStd-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);

第一个参数name,是字体名称。”STSongStd-Light” 来自iTextAsian.jar,iTextAsian.jar 里面预制了各种字体,用于中日韩等文字的显示问题。”UniGB-UCS2-H”是对应的字符编码。BaseFont.NOT_EMBEDDED 字体不会嵌入到PDF文件中(没有保存),可能会导致不同电脑显示不正确。BaseFont.EMBEDDED 使用的字体会嵌入(保存)到PDF文件中,这样文件会更大一些,这样PDF文件显示就是跨平台的

aaa

添加一个替代字体。这种方法用于指定一个或多个备用字体,当PDF中的字段需要显示的字符在原始字体中找不到时,iText将尝试使用这些备用字体显示字符.

form.addSubstitutionFont(bf);

设置字段值以及字体样式、大小等:

form.setField(key, String.valueOf(dataMap.getOrDefault(key, "")));
form.setFieldProperty(key, "textfont", bf,null);
form.setFieldProperty(key, "textsize", 15.95f,null);
//重新生成字段以应用属性更改
form.regenerateField(key);

注意:form.regenerateField(key);这行代码是必须的,不然设置不会生效。

设置文本域不能编辑:

//不能编辑
ps.setFormFlattening(true);

图片展示的两种实现:

1、图片流的实现

Image image = Image.getInstance(imgSrc);
PdfContentByte under = stamper.getOverContent(pageNo);
image.scaleToFit(signRect.getWidth(), signRect.getHeight());
image.setAbsolutePosition(x, y);
under.addImage(image);

2、图片url的实现:

URL url = new URL(imgSrc);
Image image = Image.getInstance(url);
PdfContentByte under = stamper.getOverContent(pageNo);
image.scaleToFit(signRect.getWidth(), signRect.getHeight());
image.setAbsolutePosition(x, y);
under.addImage(image);

字体的问题:

如果想使用iTextAsian不包含的字体怎么办?

1、使用Windows系统下的字体

path是字体文件的全路径。

 String path = "C:\\Windows\\Fonts\\simhei.ttf";
 BaseFont font = BaseFont.createFont(path, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);

Windows系统下的字体有的是.ttf类型,有的是.ttc类型。ttc类型的是组合字体,里面包含了几个字体的集合。若想使用ttc类型的文件,可以打开ttc文件,点击右上角下一个,看看你需要的是第几个字体,在声明字体的时候标注索引就好了。

 String path = "C:\\Windows\\Fonts\\simhei.ttf 0";
 BaseFont font = BaseFont.createFont(path, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);

2、使用外部的字体

自己从网上下载字体,然后放在项目的classpath路径下:比如我的黑体文件路径:src/main/resources/fonts/simhei.ttf

BaseFont bf = BaseFont.createFont("/fonts/simhei.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);

或者使用字体流的方式:

// 使用类加载器获取字体文件的输入流
InputStream fontStream = PdfUtil.class.getResourceAsStream("/fonts/simsun.ttf");
if (Objects.isNull(fontStream)) {
    throw new RuntimeException("找不到字体文件");
}
byte[] fontBytes = IoUtil.readBytes(fontStream);
// 使用输入流创建BaseFont对象
BaseFont bf = BaseFont.createFont("simsun.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED, true, fontBytes, null);

注意:这个时候name参数是字体的文件名称带后缀。

这种方式是比较推荐的,因为项目部署到不同平台,服务器自带的字体可能不支持,所以最好是放在项目中,然后嵌入pdf中。

3、使用iTextAsian的字体,或者itext自带字体(不支持中文)

BaseFont bf = BaseFont.createFont("STSongStd-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);
BaseFont bf = BaseFont.createFont(BaseFont.HELVETICA, "Cp1252", BaseFont.NOT_EMBEDDED);

浏览器预览时,标签页的问题:

String fileName = new String("测试预览pdf文件".getBytes(), "ISO-8859-1");//避免中文乱码
response.setHeader("Content-Disposition", "inline;filename=".concat(String.valueOf(fileName) + ".pdf"));

虽然代码中设置了fileName,但是标签页却无法展示。

原因是标签页默认展示的是文档属性里面的标题。

image-20250227180135113

如果不想修改文档属性的标题,可以在代码中修改重新设置标题。

// 获取文档信息
  Map<String, String> info = reader.getInfo();
  if (info == null) {
      info = new HashMap<>();
  }
  // 修改标题,默认显示在浏览器的标签页上,如果不设置,标签页会显示原先文档属性的标题(adobe,打开文件,属性,设置标题)
  info.put("Title", "这是一个pdf");
  // 设置文档信息
  ps.setMoreInfo(info);

效果:

image-20250228142139535

4、使用itext5实现html转pdf

由于pdf模板无法做到word那样根据内容大小自动换行,只能提前预估内容,设置文本域高度和宽度,特别是下划线上填充内容时,一行显示不下,不能自动换行,所以一些情况下,pdf模板填充内容无法满足需求。这时候考虑使用word模板然后转pdf,或者使用html转pdf。

itext5可以实现html转pdf。引入新的依赖:

<dependency>
    <groupId>com.itextpdf.tool</groupId>
    <artifactId>xmlworker</artifactId>
    <version>5.5.13.4</version>
</dependency>

html可以是一个真实的文件,也可以是html字符串,还可以用模板引擎。最后都是转成流参数。简单实例:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8"/>
    <style>
        body {
            font-family: "SimSun", "Microsoft YaHei", sans-serif;
        }
    </style>

</head>
<body>
<h1 style="text-align: center">标题</h1>
<p>这是一段测试文本!</p>
<p style="text-align: left;color: red">这是第二段测试文本!</p>
<div>
    <img src="http://www.baidu.com/img/bdlogo.gif" alt="百度" title="百度" width="100" height="100"/>
</div>
</body>
</html>
@GetMapping("/previewPdf7")
public void previewPdf7(HttpServletRequest request, HttpServletResponse response) throws IOException, DocumentException {
    // 创建 PDF 文档
    Document document = new Document();
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    PdfWriter writer = PdfWriter.getInstance(document, baos);
    document.open();

    // 读取 HTML 文件
   // FileInputStream htmlFile = new FileInputStream("input.html");

    String html = "<html><body><h1>Hello, World!</h1></body></html>";
    InputStream htmlStream = new ByteArrayInputStream(html.getBytes(StandardCharsets.UTF_8));
    // 从类路径加载 HTML 文件
    InputStream htmlFile = MyPdfController.class.getResourceAsStream("/static/index2.html");
    document.addTitle("html转pdf测试");

    FontProvider asianFontProvider = new FontProvider() {
        @Override
        public boolean isRegistered(String s) {
            return false;
        }

        @Override
        public Font getFont(String s, String s1, boolean b, float v, int i, BaseColor baseColor) {
            BaseFont bfChinese = null;
            try {
                bfChinese = BaseFont.createFont("STSongStd-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);
            } catch (DocumentException | IOException e) {
                throw new RuntimeException(e);
            }
            return new Font(bfChinese, v, i, baseColor);
        }
    };

    // 使用 XMLWorkerHelper 将 HTML 转换为 PDF
   // XMLWorkerHelper.getInstance().parseXHtml(writer, document, htmlFile, StandardCharsets.UTF_8, new AsianFontProvider());
    XMLWorkerHelper.getInstance().parseXHtml(writer, document, htmlFile, StandardCharsets.UTF_8, asianFontProvider);
    // 关闭文档
    document.close();


    // 获取 PDF 字节流
    byte[] pdfBytes = baos.toByteArray();

    response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
    response.setContentType(MediaType.APPLICATION_PDF_VALUE);
    String fileName = new String("test".getBytes(), StandardCharsets.ISO_8859_1);
    response.setHeader(Header.CONTENT_DISPOSITION.getValue(), "inline;filename=".concat(fileName + ".pdf"));
    // 将 PDF 字节流写入响应输出流
    OutputStream out = response.getOutputStream();
    out.write(pdfBytes);
    out.flush();
    out.close();
}

注意中文的问题,需要FontProvider加载中文字体才能显示中文,否则会乱码。也可以单独写一个中文的class,然后,当成参数,传给parseXHtml方法。比如:

/**
 * 用于中文显示的Provider
 */
class AsianFontProvider extends XMLWorkerFontProvider {
    @Override
    public Font getFont(final String fontname, String encoding, float size, final int style) {
        try {
            BaseFont bfChinese = BaseFont.createFont("STSongStd-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);
            return new Font(bfChinese, size, style);
        } catch (Exception e) {
        }
        return super.getFont(fontname, encoding, size, style);
    }
}

字体可以是iTextAsian中的,也可以自定义加载字体,这个和前面的一样。效果如下:

image-20250318160427999

到这里还没结束,简单的html和简单样式可以,但是稍微复杂的样式,itext5就不支持了,所以样式多的html用itext5效果很不好。比如这样一个html页面转成pdf后,效果很差。

image-20250318161132356

pdf:

image-20250318161321981

最重要的是表格显示不全,也许调整html样式可以解决,但是太麻烦,itext5对css样式支持有限是主要问题。text7支持更多的css样式,但是有开源协议的限制。

继续尝试其他的转pdf工具,比如:

<dependency>
    <groupId>org.xhtmlrenderer</groupId>
    <artifactId>flying-saucer-pdf-itext5</artifactId>
    <version>9.1.22</version>
</dependency>

但是效果一样不好,而且需要在html中指定字体,不然中文显示不出来。

<style>
    body {
        font-family: "SimSun", "Microsoft YaHei", sans-serif;
    }
</style>

image-20250318162158739

最后使用wkhtmltox工具,经过测试,这个工具转pdf效果好,所以采用这个方案,缺点是需要在服务器上安装工具、还有字体。

用工具转pdf后,可以直接保存到服务器上,也可以返回文件流。

@GetMapping("/previewPdf8")
public void previewPdf8(HttpServletResponse response) {
    response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
    response.setContentType(MediaType.APPLICATION_PDF_VALUE);

    // HTML 内容
    String htmlContent = "<!DOCTYPE html>\n" +
            "<html>\n" +
            "<head>\n" +
            "    <meta charset=\"UTF-8\"/>\n" +
            "</head>\n" +
            "<body> <div data-v-137a61c0=\"\" style=\"text-align: center; font-size: 20px; font-weight: bold; margin-bottom: 30px;\"> xxx患者病历单 </div><table data-v-137a61c0=\"\" cellspacing=\"0\" style=\"width: 100%; border-collapse: collapse;\"><tr data-v-137a61c0=\"\" style=\"border: 1px solid rgb(102, 102, 102); min-height: 40px;\"><td data-v-137a61c0=\"\" colspan=\"6\" style=\"background: lightblue; line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 200px;\">患者基本信息</td></tr><tr data-v-137a61c0=\"\" style=\"border: 1px solid rgb(102, 102, 102); min-height: 40px;\"><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 100px;\">姓名</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 200px;\">test012</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 100px;\">性别</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 200px;\">男</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 100px;\">证件号</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 200px;\"></td></tr><tr data-v-137a61c0=\"\" style=\"border: 1px solid rgb(102, 102, 102); min-height: 40px;\"><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 100px;\">年龄</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 200px;\"></td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 100px;\">婚姻</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 200px;\"> </td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 100px;\">身高</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 200px;\"> </td></tr><tr data-v-137a61c0=\"\" style=\"border: 1px solid rgb(102, 102, 102); min-height: 40px;\"><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 100px;\">体重</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 200px;\"> </td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 100px;\">民族</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 200px;\"></td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 100px;\">就诊ID</td><td data-v-137a61c0=\"\" style=\"line-height: 30px; padding: 5px 10px; border-spacing: 0px; border-right: 1px solid rgb(102, 102, 102); font-size: 14px; width: 200px;\"></td></tr></table><div data-v-137a61c0=\"\" style=\"font-size: 12px; color: rgb(102, 102, 102); margin: 20px 0px;\"> 注:远程诊疗是异地医疗咨询活动,门诊医师与申请门诊医师之间的关系属于医学知识的咨询关系,对病人的诊断与治疗的决定权属于收治病人的医疗机构(引自原卫生部卫办发[1999]第2号)。 </div>" +
            "</body>\n" +
            "</html>";
    // 调用 wkhtmltopdf 将 HTML 字符串转换为 PDF 字节流
    try {
        byte[] pdfBytes = htmlToPdf(htmlContent);
        System.out.println("PDF 生成成功!大小: " + pdfBytes.length + " 字节");

        //避免中文乱码
        String fileName = new String("test".getBytes(), StandardCharsets.ISO_8859_1);
        response.setHeader("Content-Disposition", "inline;filename=".concat(fileName) + ".pdf");
        OutputStream outputStream = response.getOutputStream();
        outputStream.write(pdfBytes);
        outputStream.flush();
        outputStream.close();
    } catch (IOException | InterruptedException e) {
        e.printStackTrace();
    }
}


public  byte[] htmlToPdf(String htmlContent) throws IOException, InterruptedException {
    // 创建 ProcessBuilder
    ProcessBuilder processBuilder = new ProcessBuilder("wkhtmltopdf", "-", "-");
    processBuilder.redirectErrorStream(true);

    // 启动进程
    Process process = processBuilder.start();

    // 将 HTML 内容写入进程的标准输入
    try (OutputStream stdin = process.getOutputStream();
         BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(stdin))) {
        writer.write(htmlContent);
    }

    // 捕获进程的标准输出(PDF 字节流)
    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    try (InputStream stdout = process.getInputStream()) {
        byte[] buffer = new byte[1024];
        int length;
        while ((length = stdout.read(buffer)) != -1) {
            outputStream.write(buffer, 0, length);
        }
    }

    // 等待进程完成
    int exitCode = process.waitFor();
    if (exitCode != 0) {
        throw new IOException("wkhtmltopdf 进程执行失败,退出码: " + exitCode);
    }

    // 返回 PDF 字节流
    return outputStream.toByteArray();
}

效果如下:

image-20250318162816661

工具的效果还是比较好的。