乐于分享
好东西不私藏

Springboot动态将HTML模版文件生成PDF

Springboot动态将HTML模版文件生成PDF
一、需求
1. 将项目实施中物料和图片展示在小程序端,并可点击下载为pdf文件
二、需求分析
1. 开放多个接口,展示各类物料接口和下载pdf接口
2. 重点讲解展示页面下载为pdf接口,因该接口实现方式导致OOM
三、技术实现
1. 根据Deepseek搜索比较和综合考量,选取容易上手且简易的实现方式:使用thymeleaf+saucer生成简单的PDF:html模版开发,入手快,便于学习,无需管理分页等事项。
2.pom引入,使用jdk1.8+springboot2.7.18版本
<dependency>      <groupId>org.springframework.boot</groupId>  <artifactId>spring-boot-starter-thymeleaf</artifactId>   <!--            <version>${spring-boot.version}</version>-->  </dependency>  <dependency>       <groupId>org.xhtmlrenderer</groupId>      <artifactId>flying-saucer-pdf</artifactId>   <version>9.4.1</version>       <!--            <version>9.1.22</version>-->       <exclusions>            <exclusion>                 <groupId>org.slf4j</groupId>                 <artifactId>slf4j-api</artifactId>             </exclusion>              <exclusion>                  <groupId>com.github.librepdf</groupId>        <artifactId>openpdf</artifactId>            </exclusion>       </exclusions>   </dependency>   <dependency>        <groupId>com.github.librepdf</groupId>        <artifactId>openpdf</artifactId>        <!--            <version>1.3.30</version>-->        <version>1.3.43</version>  </dependency>
3. 在配置文件中thymeleaf添加模板设置
thymeleaf:    prefix: classpath:/template/    suffix: .html    mode: HTML
4. html模板编写
<!DOCTYPE html><htmlxmlns:th="http://www.thymeleaf.org"><head>    <metacharset="UTF-8"/>    <title>indoor-services-2-pdf</title>    <style>        body {            font-family: SimSun;        }        .title {            align-content: center;            text-align: center;        }        .signature {            float: right        }        h1 {            color#2c3e50;            text-align: center;        }        .content {            margin20px 0;        }        .image-container {            text-align: center;            margin20px 0;        }        .image-container img {            max-width100%;            height: auto;        }        /*.rotate-90 {*/        /*    -fs-pdf-image-rotation: 90; !* OpenPDF 专有属性*!*/        /*}*/    </style></head><body><h1th:text="${title}">title</h1><p>安装须知</p><p>1. 程序计数器、虚拟机栈、本地方法栈、堆、方法区;</p><p>2. 优先栈上分配(逃逸分析)→ Eden → Survivor → 老年代;</p><p>3. 标记-清除、标记-复制、标记-整理;</p><p>5. 注意事项:</p><p>(1)Region 分区、可预测停顿、并发标记、整理无碎片</p><p>项目信息</p><p>项目名称:<spanth:text="${projectName}">projectName</span></p><divth:if="${not #lists.isEmpty(pages)}">    <divth:each="page:${pages}">        <h2th:text="${page}+'信息-->Start'">信息-->Start</h2>        <divth:if="${not #lists.isEmpty(floors)}">            <divth:each="floor:${floors}">                <h2th:text="${floor.name}">floorName</h2>                <p>数量:<spanth:text="${floor.number}">number</span></p>                <p>方式:<spanth:text="${floor.connectType}">connectType</span></p>                <p>情况:<spanth:text="${floor.partitions}">partitions</span></p>            </div>        </div>    </div></div><divth:if="${not #lists.isEmpty(projectImages)}">    <h2>照片</h2>    <divth:each="image:${projectImages}">        <divstyle="text-align: center">            <imgth:src="${image}"style="max-height: 50%;object-fit: contain;"alt="projectImage"/>        </div>    </div></div></body></html>
5. 图片上传到Minio上,3张不同大小的图片
6.后端代码编写
    6.1 配置注入
@Beanpublic ITextRenderer createTextRenderer() throws DocumentException, IOException {    ITextRenderer renderer = new ITextRenderer();    final ClassPathResource fonts = new ClassPathResource("fonts/simsun.ttf");    ITextFontResolver resolver = renderer.getFontResolver();    resolver.addFont(fonts.getPath(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);    return renderer;}
 6.2导出接口,代码中使用图片为8.2MB
 @Autowired private TemplateEngine templateEngine;@Autowired private ITextRenderer renderer;@GetMapping("downPdf")public ResponseEntity<ByteArrayResource> downIndoorServices2Pdf(@RequestParam(name = "count",defaultValue = "50") Integer count) throws Exception {    Context context = new Context();    context.setVariable("title""【" + 1223 + "】安装明细表");    context.setVariable("projectName""天安门项目");    List<KanchaSingleFloorInfoVo> floors = Lists.newArrayList();    SingleFloorInfoVo floorVo =            SingleFloorInfoVo.builder()                    .floorId(100L)                    .number(2)                    .name("1号楼层")                    .connectType("WIFI")                    .partitions("1号分区、2号分区、3号分区")                    .build();    SingleFloorInfoVo floorVo1 =            SingleFloorInfoVo.builder()                    .floorId(1000L)                    .number(20)                    .name("2号楼层")                    .connectType("WIFI")                    .partitions("1号分区、2号分区、3号分区")                    .build();    floors.add(floorVo);    floors.add(floorVo1);    context.setVariable("floors", floors);    List<String> projectImages = Lists.newArrayList();    // 28kb image 30000->6.1mb cost 2 min,image url是存储在minio上    // 8.2Mb*100->8.2Mb 8.2Mb*300->8.3Mb  8.2Mb*500->8.3Mb 8.2Mb*500->8.5Mb    for (int i = 0; i < count; i++) {        projectImages.add(                "http://127.0.0.1:9000/work/96b62cff9e4844d4a13098fc33138c45?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260423%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260423T103341Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=2f60c1e598eaf22f8f91c059cdaed9c55002c2f035a2adae7b4807a2f6f7a280");    }    context.setVariable("projectImages", projectImages);    List<Integer> pages = Lists.newArrayList();    for (int i = 0; i < 10; i++) {        pages.add(i);    }    context.setVariable("pages", pages);    String html = templateEngine.process("indoor-service-2-pdf-back", context);    HttpHeaders headers = new HttpHeaders();    headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=indoor-service.pdf");    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();    renderer.setDocumentFromString(html);    renderer.getSharedContext().setPrint(Boolean.TRUE);    renderer.getSharedContext().setInteractive(false);    renderer.layout();    renderer.createPDF(outputStream);   //释放资源    renderer.finishPDF();    return ResponseEntity.ok()            .headers(headers)            .contentType(MediaType.APPLICATION_PDF)            .body(new ByteArrayResource(outputStream.toByteArray()));}
  6. 设置启动参数vm
-Xms256m -Xmx256m -XX:+HeapDumpOnOutOfMemoryError    
  7. 编写shell脚本多次下载,验证程序是否报OOM
#/bin/bashfor i in {1..100}; do wget 'http://localhost:8080/pdf/downPdf?count=100'; done
    8. 执行结果如下
四、遇到的问题
1. 上传大图片时,发生OOM,错误日志如下:
Caused by: java.lang.OutOfMemoryError: Java heap spaceat java.util.Arrays.copyOf(Arrays.java:3236) ~[na:1.8.0_212]at java.io.ByteArrayOutputStream.grow(ByteArrayOutputStream.java:118) ~[na:1.8.0_212]at java.io.ByteArrayOutputStream.ensureCapacity(ByteArrayOutputStream.java:93) ~[na:1.8.0_212]at java.io.ByteArrayOutputStream.write(ByteArrayOutputStream.java:153) ~[na:1.8.0_212]at java.io.BufferedOutputStream.write(BufferedOutputStream.java:122) ~[na:1.8.0_212]//jdk8-211,没用到上述方法,openjdk version "1.8.0_212" 中的源码//java.io.FileOutputStream#writeBytes(FilterOutputStream.java:)at java.io.FilterOutputStream.write(FilterOutputStream.java:97) ~[na:1.8.0_212]at com.lowagie.text.pdf.OutputStreamCounter.write(OutputStreamCounter.java:111) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfStream.toPdf(PdfStream.java:361) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfIndirectObject.writeTo(PdfIndirectObject.java:164) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfWriter$PdfBody.add(PdfWriter.java:410) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfWriter$PdfBody.add(PdfWriter.java:390) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfWriter$PdfBody.add(PdfWriter.java:343) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfWriter.addToBody(PdfWriter.java:776) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfWriter.add(PdfWriter.java:3051) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfWriter.addDirectImageSimple(PdfWriter.java:3026) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfWriter.addDirectImageSimple(PdfWriter.java:2953) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfContentByte.addImage(PdfContentByte.java:1317) ~[openpdf-1.3.35.jar!/:na]at com.lowagie.text.pdf.PdfContentByte.addImage(PdfContentByte.java:1230) ~[openpdf-1.3.35.jar!/:na]at org.xhtmlrenderer.pdf.ITextOutputDevice.drawImage(ITextOutputDevice.java:858) ~[flying-saucer-pdf-9.4.1.jar!/:na]at org.xhtmlrenderer.pdf.ITextImageElement.paint(ITextImageElement.java:72) ~[flying-saucer-pdf-9.4.1.jar!/:na]at org.xhtmlrenderer.pdf.ITextOutputDevice.paintReplacedElement(ITextOutputDevice.java:195) ~[flying-saucer-pdf-9.4.1.jar!/:na]at org.xhtmlrenderer.layout.Layer.paintReplacedElement(Layer.java:522) ~[flying-saucer-core-9.4.1.jar!/:na]at org.xhtmlrenderer.layout.Layer.paintReplacedElements(Layer.java:479) ~[flying-saucer-core-9.4.1.jar!/:na]at org.xhtmlrenderer.layout.Layer.paintAsLayer(Layer.java:453) ~[flying-saucer-core-9.4.1.jar!/:na]at org.xhtmlrenderer.render.BlockBox.paintInline(BlockBox.java:268) ~[flying-saucer-core-9.4.1.jar!/:na]at org.xhtmlrenderer.layout.Layer.paintInlineContent(Layer.java:268) ~[flying-saucer-core-9.4.1.jar!/:na]at org.xhtmlrenderer.layout.Layer.paint(Layer.java:325) ~[flying-saucer-core-9.4.1.jar!/:na]at org.xhtmlrenderer.pdf.ITextRenderer.paintPage(ITextRenderer.java:456) ~[flying-saucer-pdf-9.4.1.jar!/:na]at org.xhtmlrenderer.pdf.ITextRenderer.writePDF(ITextRenderer.java:404) ~[flying-saucer-pdf-9.4.1.jar!/:na]at org.xhtmlrenderer.pdf.ITextRenderer.createPDF(ITextRenderer.java:357) ~[flying-saucer-pdf-9.4.1.jar!/:na]at org.xhtmlrenderer.pdf.ITextRenderer.createPDF(ITextRenderer.java:289) ~[flying-saucer-pdf-9.4.1.jar!/:na]at com.henry.work.rest.PdfController.downIndoorServices2Pdf(downIndoorServices2Pdf.java:122) ~[classes!/:na]
2.升级pom到稳定版本,并优化代码,释放资源,解决OOM问题,多次执行亦不报错。
3.jdk1.8+springboot2.7.18+flying-saucer-pdf9.+openpdf1.3.x源码无法通过后端解决图片旋转角度问题,如签名之类的,需要前端进行旋转正向,前端可对上传的原始图片进行压缩,减小存储规格和后端导出pdf规格。
4.中文字体无法显示问题,需要引入simsun.ttf等中文字体,放在resource/fonts下,因在docker运行时,运行在/ 路径下,不能找到该路径文件,将该文件同时放一份与dockerfile同文件夹下,通过dockerfile文件进行复制
COPY simsun.ttf /fonts/simsun.ttf