后端构建PDF

34 阅读4分钟

背景

Springboot 中通过组件依赖实现构建PDF

依赖组件

<!--PDF-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<dependency>
    <groupId>org.xhtmlrenderer</groupId>
    <artifactId>flying-saucer-pdf</artifactId>
    <version>9.1.22</version>
</dependency>
<!-- Flying Saucer 强制需要 lowagie itext2 -->
<dependency>
    <groupId>com.lowagie</groupId>
    <artifactId>itext</artifactId>
    <version>2.1.7</version>
</dependency>

关联代码

1. PDF 模版

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>粒径分析汇总报告</title>
    <style>
        /* ========== A4 纵向 1 cm 边距 ========== */
        @page { size: A4 portrait; margin: 1cm; }

        /* ========== 全局 ========== */
        body {
            font-family: "SimSun", serif;
            font-size: 11px;
            color: #333;
            margin: 0;
            padding: 0;
            background: linear-gradient(to bottom, #f8fafc 0%, #ffffff 3%);
            background-repeat: no-repeat;
            background-attachment: fixed;
        }

        table {
          border: none;
          border-collapse: collapse;
          border-spacing: 0;
        }

        /* ========== 标题 ========== */
        h1 {
            font-size: 18px;
            text-align: center;
            margin: 4px 0 8px;
            padding: 8px 0;
            border-bottom: 2px solid #2c5aa0;
            color: #1a365d;
            background: linear-gradient(to right, #ebf4ff, #ffffff, #ebf4ff);
            box-shadow: 0 2px 4px rgba(0,0,0,0.05);
        }
        h2 {
            font-size: 14px;
            margin: 15px 0 4px;
            padding: 4px 8px;
            border-bottom: 1px solid #c3dafe;
            color: #2c5282;
            background-color: #ebf4ff;
            border-radius: 3px;
        }

        /* ========== 表格基础 ========== */
        table { width: 100%; border-collapse: collapse; }
        th, td {
            border: 1px solid #cbd5e0;
            padding: 3px 2px;
            text-align: center;
        }
        th {
            background: linear-gradient(to bottom, #ebf4ff, #c3dafe);
            font-weight: bold;
            color: #2c5282;
        }

        /* ========== 基础信息表 ========== */
        .info-table {
            margin: 6px 0;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
        }
        .info-table th {
            width: 15%;
            background: linear-gradient(to bottom, #e6fffa, #b2f5ea);
            color: #234e52;
        }
        .info-table td {
            width: 35%;
            background-color: #f0fff4;
        }
        .info-table tr:hover td {
            background-color: #e6fffa;
        }

        /* ========== 粒径分布:同一行 ========== */
        .dist-table {
            margin: 6px 0;
            page-break-inside: avoid;
        }
        .dist-table td {
            vertical-align: top;
            border: none;
            padding: 0 3px;
        }
        .dist-left {
            width: 50%;
        }
        .dist-right {
            width:40 %;
            height: auto;
            text-align: center;
        }

        /* 分布表格 */
        .data-table {
            font-size: 10px;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
        }
        .data-table th {
            background: linear-gradient(to bottom, #feebc8, #fbd38d);
            color: #744210;
        }
        .data-table td {
            border: 1px solid #fbd38d;
            background-color: #fffbeb;
        }
        .data-table tr:nth-child(even) td {
            background-color: #fefcbf;
        }

        /* 统计条形图 */
        .chart-img {
            width: 260px;
            height: auto;
            border: 1px solid #cbd5e0;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            background-color: white;
        }
        .caption {
            margin-top: 4px;
            font-weight: bold;
            font-size: 10px;
            color: #4a5568;
        }

        /* ========== 分析结果:右侧图 右侧竖排 ========== */
        .result-table {
            margin: 6px 0;
            page-break-inside: avoid;
        }
        .result-table td {
            vertical-align: top;
            padding: 3px 2px;
            text-align: center;
        }
        .result-right-img  {
            width: 260px;
            text-align: center;
        }

        .result-right-img img,
        .result-img {             /* 你已有的 class 也可以一起写 */
           width: 710px;
           height: 400px;         /* 保持原图比例 */
           display: block;       /* 去掉图片底部多余空隙 */
        }

        /* 竖排字段表 */
        .vertical-table {
            width: 100%;
            font-size: 10px;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
        }
        .vertical-table th {
            background: linear-gradient(to bottom, #e9d8fd, #d6bcfa);
            color: #44337a;
        }
        .vertical-table td {
            font-weight: bold;
            background-color: #faf5ff;
        }
        .vertical-table tr:nth-child(even) td {
            background-color: #fefcbf !important;
        }
        .result-img {
            height: auto;
            width: 260px;
            border: 1px solid #d6bcfa;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            background-color: white;
        }

        /* 强调重要数据 */
        .highlight {
            background-color: #fefcbf !important;
            color: #744210;
            font-weight: bold;
        }
    </style>
</head>

<body>
<h1>XXXXXX</h1>

<!-- ① 基础信息 -->
<table class="info-table">
    <tr>
        <th>样品种类</th>
        <td th:text="${instrumentType} ?: 'AFM'">AFM</td>
        <th>项目号</th>
        <td th:text="${projectNo} ?: '-'">-</td>
    </tr>
    <tr>
        <th>部门ID</th>
        <td th:text="${departmentId} ?: '1'">1</td>
        <th>日期</th>
        <td th:text="${date} ?: '2025-08-01'">2025-08-01</td>
    </tr>
    <tr>
        <th>送样人</th>
        <td th:text="${userName} ?: 'xxxxx'">xxxxx</td>
        <th>电话</th>
        <td th:text="${phoneNumber} ?: '-'">-</td>
    </tr>
    <tr>
        <th>样品测试条件</th>
        <td colspan="3" th:text="${detectionCondition} ?: '-'">-</td>
    </tr>
    <tr>
        <th>样品研究目的</th>
        <td colspan="3" th:text="${detectionPurpose} ?: '-'">-</td>
    </tr>
</table>

<h2>XXX<span style="font-size: 28px;">概况</span></h2>
<table class="vertical-table">
    <tr>
        <th>最大粒径(μm)</th>
        <td class="highlight" th:text="${max} ?: '0.8673'">0.8673</td>
        <th>样本数量</th>
        <td class="highlight" th:text="${sampleCount} ?: '837'">837</td>
    </tr>
    <tr>
        <th>最小粒径(μm)</th>
        <td class="highlight" th:text="${min} ?: '0.0003'">0.0003</td>
        <th>样本组距(μm)</th>
        <td class="highlight" th:text="${binWidth} ?: '0.05'">0.05</td>
    </tr>

    <tr>
        <th>平均粒径(μm)</th>
        <td class="highlight" th:text="${mean} ?: '0.083'">0.083</td>
        <th>样本组数</th>
        <td class="highlight" th:text="${binCount} ?: '18'">18</td>
    </tr>
</table>
<h2>XXXX<span style="font-size: 28px;">直方分布</span></h2>
<table class="dist-table">
    <tr>
        <td class="dist-left">
            <table class="data-table">
                <thead>
                <tr>
                    <th>区间(μm)</th>
                    <th>平均值</th>
                    <th>数量</th>
                    <th>频率(%)</th>
                </tr>
                </thead>
                <tbody>
                <tr th:each="r : ${bins}">
                    <td th:text="${r.range}">0.00-0.05</td>
                    <td th:text="${r.midpoint}">0.025</td>
                    <td th:text="${r.count}">358</td>
                    <td th:text="${#numbers.formatDecimal(r.frequency,1,1)}">42.8</td>
                </tr>
                </tbody>
            </table>
        </td>

        <td class="dist-right">
            <img th:src="${summaryImageBase64}" class="chart-img" alt="统计条形图"/>
            <div class="caption">统计条形图</div>
        </td>
    </tr>
</table>

<h2>XXXX<span style="font-size: 28px;">样本图</span></h2>
<!-- ③ 分析结果:左侧图 右侧竖排表格 -->
<table class="result-table">
    <tr>
        <td class="result-right-img">
            <img th:src="${sampleImageBase64}" class="result-img" alt="分析结果示意图"/>
            <div class="caption">分析结果示意图</div>
        </td>
    </tr>
</table>
</body>
</html>

2. PDF 构建接口

@GetMapping(value = "/{summaryId}/pdf", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> preview(@PathVariable("summaryId") Long summaryId) throws Exception {
    PdfVO pdfVO = tSummaryServiceImpl.generatePDFBySummaryId(summaryId);
    byte[] pdf = createPdf(pdfVO);
    String fileName = URLEncoder.encode(pdfVO.getProjectNo() + "_XXXXX.pdf", "UTF-8")
            .replaceAll("\+", "%20");
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    "inline; filename="" + fileName + ""; filename*=UTF-8''" + fileName)
            .contentType(MediaType.APPLICATION_PDF)
            .body(pdf);
}

private byte[] createPdf(PdfVO pdfVO) throws Exception {
    Context ctx = new Context();
    ctx.setVariable("instrumentType", pdfVO.getInstrumentType());
    ctx.setVariable("projectNo", pdfVO.getProjectNo());
    ctx.setVariable("departmentId", pdfVO.getDepartmentId());
    ctx.setVariable("date", pdfVO.getDate());
    ctx.setVariable("userName", pdfVO.getUserName());
    ctx.setVariable("phoneNumber", pdfVO.getPhoneNumber());
    ctx.setVariable("detectionCondition", pdfVO.getDetectionCondition());
    ctx.setVariable("detectionPurpose", pdfVO.getDetectionPurpose());
    ctx.setVariable("sampleImageBase64", "data:image/jpeg;base64," + pdfVO.getSampleImageBase64());
    ctx.setVariable("summaryImageBase64", "data:image/jpeg;base64," + pdfVO.getSummaryImageBase64());
    ctx.setVariable("max", pdfVO.getMax());
    ctx.setVariable("min", pdfVO.getMin());
    ctx.setVariable("sampleCount", pdfVO.getSampleCount());
    ctx.setVariable("binWidth", pdfVO.getBinWidth());
    ctx.setVariable("binCount", pdfVO.getBinCount());
    ctx.setVariable("bins", pdfVO.getBins());
    String html = templateEngine.process("report-01", ctx);
    return PdfUtil.genPdf(html);   // 上文已实现
}

3. PDF 构建类

package com.pcitc.common.utils;

import com.lowagie.text.pdf.BaseFont;
import org.xhtmlrenderer.pdf.ITextRenderer;

import java.io.ByteArrayOutputStream;

public class PdfUtil {
    public static byte[] genPdf(String html) throws Exception {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        ITextRenderer renderer = new ITextRenderer();

        renderer.getSharedContext().setReplacedElementFactory(
                new Base64ImageReplacedElementFactory(renderer.getSharedContext().getReplacedElementFactory())
        );
        /* 中文字体 */
        String fontPath = PdfUtil.class.getResource("/fonts/SimSun.ttf").toString();
        renderer.getFontResolver().addFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);


        renderer.setDocumentFromString(html);
        renderer.layout();
        renderer.createPDF(out);
        return out.toByteArray();

    }
}

4. 处理PDF 中存在图片,但是不显示的问题。

package com.pcitc.common.utils;

import com.lowagie.text.Image;
import org.springframework.util.Base64Utils;
import org.xhtmlrenderer.extend.FSImage;
import org.xhtmlrenderer.extend.ReplacedElement;
import org.xhtmlrenderer.extend.ReplacedElementFactory;
import org.xhtmlrenderer.extend.UserAgentCallback;
import org.xhtmlrenderer.layout.LayoutContext;
import org.xhtmlrenderer.pdf.ITextFSImage;
import org.xhtmlrenderer.pdf.ITextImageElement;
import org.xhtmlrenderer.render.BlockBox;
import org.xhtmlrenderer.simple.extend.FormSubmissionListener;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;

public class Base64ImageReplacedElementFactory implements ReplacedElementFactory {

    private final ReplacedElementFactory superFactory;

    public Base64ImageReplacedElementFactory(ReplacedElementFactory superFactory) {
        this.superFactory = superFactory;
    }

    @Override
    public ReplacedElement createReplacedElement(LayoutContext layoutContext, BlockBox blockBox,
                                                 UserAgentCallback userAgentCallback, int cssWidth, int cssHeight) {
        org.w3c.dom.Element element = blockBox.getElement();
        if (element == null) {
            return null;
        }
        String nodeName = element.getNodeName();
        if ("img".equals(nodeName)) {
            String src = element.getAttribute("src");
            if (src.startsWith("data:image/")) {
                String base64Data = src.substring(src.indexOf("base64,") + 7);
                byte[] imageBytes = Base64Utils.decodeFromString(base64Data.replace("\s", ""));
                try {
                    BufferedImage bufferedImage = ImageIO.read(new ByteArrayInputStream(imageBytes));
                    if (bufferedImage == null) {
                        throw new IOException("ImageIO could not decode image from byte array");
                    }
                    Image image = Image.getInstance(bufferedImage, null);
                    FSImage fsImage = new ITextFSImage(image);
                    if (cssWidth != -1 || cssHeight != -1) {
                        fsImage.scale(cssWidth, cssHeight);
                    }
                    return new ITextImageElement(fsImage);
                } catch (Exception e) {
                    e.printStackTrace();
                    return null;
                }
            }
        }
        return superFactory.createReplacedElement(layoutContext, blockBox, userAgentCallback, cssWidth, cssHeight);
    }

    @Override
    public void reset() {
        superFactory.reset();
    }

    @Override
    public void remove(org.w3c.dom.Element e) {
        superFactory.remove(e);
    }

    @Override
    public void setFormSubmissionListener(FormSubmissionListener listener) {
        superFactory.setFormSubmissionListener(listener);
    }
}