
你手里有一套用富文本编辑器排版好的业务文案合同条款、工单详情、审批结论现在要按领导要求导出成带封面标题、正文排版、还能验真伪的 PDF。翻了一圈方案iText 太原始PDFBox 底层的像用手工焊电路板最后我组装了一套 Java 实现富文本转 PDF 的完整链路——jsoup 清洗富文本 OpenHTMLtoPDF 渲染 ZXing 二维码支持自定义标题页和二维码防伪标识。这篇文章把我这次落地过程中的选型对比、坑位、代码骨架和线上实测数据完整写出来了适合正在做导出功能或者被 XSS 和乱码折磨过的 Java 后端。1. 需求拆解富文本、封面标题、防伪二维码这三件事叠在一起有多麻烦1.1 富文本“富”在哪里需要兜底的内容形态富文本和纯文本的区别不只是多几个b标签。我在项目里接到的业务数据来源是各种开源的富文本编辑器存货的字段可能长这样p styletext-indent:2em;本合同自双方签字之日起生效。/p p styletext-align:center;strong特别约定/strong/p ul li付款方式分期支付/li li违约责任详见附件 a hrefhttps://example.com/rule.pdf规则链接/a/li /ul table border1 cellpadding6 trth项目/thth金额/th/tr trtd咨询费/tdtd8000/td/tr /table你看这里已经涉及了段落缩进、居中、加粗、列表、链接、表格。再往复杂了想可能还有图片、字体颜色、行内代码、下划线。如果直接把这堆 HTML 塞给 PDF 引擎经常遇到三种死法第一种引擎不认这些 CSS 属性排版打回原形第二种字体不支持中文全部变成豆腐块第三种HTML 里混着onclick、javascript:之类的内容渲染时要么报警要么触发不必要的网络请求。所以我在设计阶段就把“纯文本转换思路”否定了。要处理的对象不是字符串而是一棵有结构、有样式、有风险点的 HTML 树。这决定了后面必须引入 jsoup 做结构化处理。1.2 自定义标题页不是打印个字符串那么简单业务方口中的“自定义标题”听起来很像是把String title 合同编号-001放到 PDF 第一行但实际上他们需要的是独立封面公司 Logo、大标题、副标题、文档编号、签发日期、二维码防伪区一个都不能少。这带来两个问题标题页和正文页必须分离。标题页是一整页正文从第二页开始中间不能混排。靠硬编码坐标画文字很容易但一旦标题文字变长、字号调整绝对坐标方案就会穿帮。更合理的做法是把标题页做成 HTML 模板用占位符填充内容再通过 CSS 分页控制。标题内容是一段不可信任的输入。用户可能填了scriptalert(1)/script或者带有颜色的样式来装点标题如果直接拼进模板轻则样式崩坏重则产生注入。标题字段必须做转义和裁剪这是很多导出功能上线后翻车的重灾区。1.3 二维码与防伪诉求从“能扫码”到“可信扫码”不少项目要二维码只是为了“看起来高级”扫码能出来一串字母就行。但这次需求带了防伪诉求货到之后扫码要能验证这份 PDF 是不是本公司签发的、有没有被篡改过。于是二维码内容不能是普通的 URL最好携带一份签名信息。签名信息怎么生成我在实践里用的是文档编号 签发时间 一个防伪随机串拼起来做摘要再用公司私钥做 SHA256WithRSA 签名签名和二进制的 Base64 一起放进二维码内容。验证端用公钥验签能对上就说明 PDF 确实是本部签发的而且内容没被换过。这个流程放在二维码章节详细展开。2. 技术选型为什么是 jsoup OpenHTMLtoPDF ZXing 的组合2.1 主流 Java 转 PDF 方案对线iText、PDFBox、wkhtmltopdf vs OpenHTMLtoPDF我先把市面上常见的几条路列个表后面的选型结论基本来自这张表的对比。方案优点痛点适合的场景iText老版本 5.x / 7.x功能全面、文档多、底层控制力强5.x 是 AGPL商用要评估7.x 用起来复杂画个表格要写一堆代码从零构建复杂的票据、报表、条形码Apache PDFBox完全开源、无需商业授权太底层排版布局全靠手写坐标和字体度量富文本样式基本不支持需要精准操作 PDF 元数据、合并拆分时wkhtmltopdf / Chromium headlessWebKit 渲染CSS 支持近乎完美不是纯 Java 方案服务器要装二进制容器化麻烦旧版本有脚本执行风险对 CSS3 要求极高、可接受外部进程的场景OpenHTMLtoPDF含老前身 Flying Saucer纯 Java、基于 CSS 2.1、能把 XHTML 渲染成 PDF不支持 flex/grid 等现代 CSSLinux 需要手动配置中文字体HTML 富文本相对规范、需要对 HTML 做白名单清洗的后端系统OpenPDFiText 分支开源版API 与老 iText 相似同样偏底层做模板页要写大量布局代码简单文档生成、PDF 合并对比下来我的判断很明确富文本是 HTML天然适合用“HTML → PDF”这条路而不是用 PDFBox 手工画我需要纯 Java 内嵌到 Spring Boot 服务wkhtmltopdf这种外部进程在容器里部署会带来额外复杂度排除我不想承担 iText 的商业授权风险OpenPDF 这种分支虽然能用但排版能力仍然不够综合下来OpenHTMLtoPDF 配合 PDFBox 渲染引擎是最稳的组合CSS 2.1 的覆盖面足够渲染富文本编辑器输出的常规排版。2.2 依赖清单与最小环境低于 2 分钟跑通如果你用的是 Maven把这几个依赖放进pom.xmldependency groupIdcom.openhtmltopdf/groupId artifactIdopenhtmltopdf-pdfbox/artifactId version1.0.10/version /dependency dependency groupIdcom.openhtmltopdf/groupId artifactIdopenhtmltopdf-core/artifactId version1.0.10/version /dependency dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency dependency groupIdcom.google.zxing/groupId artifactIdcore/artifactId version3.5.2/version /dependency dependency groupIdcom.google.zxing/groupId artifactIdjavase/artifactId version3.5.2/version /dependency我特别提醒一点OpenHTMLtoPDF 有几次重大的包名调整。网上很多老资料还在教你org.xhtmlrenderer.pdf.ITextRenderer那是 Flying Saucer 时代的写法。新版本推荐的是com.openhtmltopdf.pdfboxout.PdfBoxRenderer依赖更干净字体处理也顺手。你照着新 API 写后面升级少踩坑。2.3 组合拳的分工逻辑清洗、渲染、编码各管一段这套链路里每个组件都干一件非常聚焦的事jsoup 负责“消毒和塑形”解析传入的富文本 HTML按白名单删掉脚本标签、事件属性、危险链接协议同时修正不闭合标签、清理多余空行OpenHTMLtoPDF 负责“排版和生成”接收清洗后的 XHTML CSS按 A4 页面尺寸渲染 PDF负责中文字体、分页、页边距ZXing 负责“编码”把你传入的防伪字符串变成一张 PNG 二维码图片再嵌入到模板页。这样分层后每一段都可以单独测试。比如富文本清洗可以先用单元测试跑完PDF 渲染可以先拿一段死数据验证字体二维码单独生成再决定嵌入位置任何一个环节出了问题都不至于推翻整个流程。3. 先消毒再渲染富文本 HTML 清洗与安全过滤的完整策略3.1 为什么 PDF 渲染前也要过一道白名单有同学会问OpenHTMLtoPDF 不执行 JavaScriptscript标签放进去也只会被忽略那清洗还有意义吗有意义而且意义重大。存储型 XSS 联动攻击富文本内容不会只用在 PDF 导出它大概率还会被后台管理页用富文本编辑器回显。如果导出功能只是简单地把原 HTML 存进数据库等于通过了出口通道但回显入口仍然有 XSS 攻击面。清洗应该发生在数据入库前导出时再做一次是双保险。恶意样式导致的渲染异常一段stylefont-size:0;或position:fixed可以玩弄排版background-image:url(外网探测地址)还会在渲染时发起请求PDF 服务因为一次导出就请求了外部地址这在生产环境里是要避免的。链接协议风险富文本中的a hrefjavascript:void(0)虽然在 PDF 里不会实际执行但当用户在 PDF 阅读器里点击链接时阅读器可能尝试打开危险协议。白名单只允许http和https就安全得多。所以我的第二个核心理念是真正的防线是白名单不是黑名单。与其费劲去匹配script、iframe、onerror等黑名单特征不如直接定义“什么标签、属性、协议可以留下”其他全部干掉。3.2 jsoup 白名单清洗的实操写法jsoup 提供了Safelist白名单体系我在此基础上扩展了一个方法覆盖我们业务富文本编辑器可能产出的所有合法标签private static final Safelist RICH_TEXT_SAFELIST; static { Safelist base Safelist.relaxed(); RICH_TEXT_SAFELIST base .addTags(span, div, section, font, hr) .addAttributes(span, style) .addAttributes(div, style, align) .addAttributes(font, color, face, size) .addAttributes(img, src, alt, width, height) .addAttributes(a, href, title, target) .addAttributes(td, width, height, colspan, rowspan) .addAttributes(table, border, cellpadding, cellspacing) .addProtocols(img, src, data, http, https) .addProtocols(a, href, http, https) .removeTags(script, iframe, object, embed, base, meta, link); }注意几个细节Safelist.relaxed()本身已经允许了p、pre、ul、ol、table等常用标签我只补了编辑器和模板会用到的部分。img的src被允许出现data协议是为了后面把二维码 Base64 数据塞进img标签但data协议不能对a的href开放避免点击链接变成下载执行脚本。removeTags是把危险标签彻底移除而不是保留空壳。style属性默认没有被relaxed()放行我加了span和div的style但还需要在清洗后做 CSS 属性级的过滤见下一小节不能让style变成任意 CSS 注入的通道。清洗方法实际执行public static String sanitizeRichText(String html) { if (html null || html.isBlank()) { return ; } String cleaned Jsoup.clean(html, , RICH_TEXT_SAFELIST, new Document.OutputSettings().prettyPrint(false)); return cleaned; }prettyPrint(false)很关键。默认的 jsoup 输出会把 HTML 补齐换行和缩进PDF 渲染时有些奇怪的空白符会被算进盒模型里导致段落间距异常关闭 prettyPrint 能避免不少排版鬼故事。3.3 清洗后的 HTML 还缺什么补协议、补样式、补结构白名单过滤之后HTML 树变干净了但离“能稳定渲染成 PDF”还差三步。第一样式属性需要再做一次值级别的校验。style属性里可能出现background-image:url(http://evil.com)或者position:fixed。我处理的方式是把这个属性拆成键值对只保留白名单内的属性和属性值匹配到的合法格式private static String sanitizeStyle(String styleAttr) { if (styleAttr null || styleAttr.isBlank()) { return ; } String[] declarations styleAttr.split(;); StringBuilder sb new StringBuilder(); for (String decl : declarations) { String[] kv decl.split(\\s*:\\s*, 2); if (kv.length ! 2) { continue; } String prop kv[0].trim().toLowerCase(); String value kv[1].trim().toLowerCase(); // 只允许排版属性禁止任何位置、背景图引用相关属性 if (Set.of(text-indent, text-align, font-size, font-weight, color, background-color, line-height, word-break) .contains(prop)) { if (prop.equals(background-color) !value.matches(^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$) !value.startsWith(rgb)) { continue; } sb.append(kv[0].trim()).append(: ).append(kv[1].trim()).append(;); } } return sb.toString(); }第二相对路径转绝对路径。富文本编辑器里的图片 URL 有可能是相对路径比如/attachment/12345.png。PDF 渲染引擎离开 Web 上下文时不知道根域名必须由服务端拼成https://your.domain/attachment/12345.png否则渲染出来是空白图片。第三修补残缺结构。富文本复制粘贴经常带出残缺标签比如p第一行p第二行jsoup 能修一部分但如果有标签嵌套层级过深最好先做一个“标签深度浅层化”的遍历处理。我一般把超过 12 层深度的标签降级为文本节点避免渲染引擎递归过深报 StackOverflowError。4. 标题页与正文页分离布局控制、分页与中文字体的三座大山4.1 标题页模板怎么组织从配置对象到 HTML 拼装我的做法是准备一份独立的 HTML 模板字符串中央位置使用占位符不用绝对坐标画文字public String buildCoverHtml(CoverConfig cover) { // 注意所有用户输入字段都必须做 HTML 转义防止标题注入 return htmlheadstyle page { size: A4; margin: 1.5cm; } .cover { text-align: center; } .cover .doc-title { font-size: 28pt; font-weight: bold; margin-top: 6cm; color: #1a1a1a; } .cover .doc-subtitle { font-size: 14pt; color: #555; margin-top: 0.5cm; } .cover .doc-meta { margin-top: 2cm; font-size: 10pt; color: #666; line-height: 2; } .cover .qr-wrapper { margin-top: 1cm; } .page-break { page-break-after: always; } /style/head body div classcover img src%LOGO_BASE64% width160 height60/ div classdoc-title%TITLE%/div div classdoc-subtitle%SUBTITLE%/div div classdoc-meta 文档编号%DOC_NO%br/ 签发日期%ISSUE_DATE%br/ /div div classqr-wrapperimg src%QR_BASE64%//div /div div classpage-break/div /body/html .trim() .replace(%LOGO_BASE64%, logoDataUri) .replace(%TITLE%, escapeHtml(cover.title())) .replace(%SUBTITLE%, escapeHtml(cover.subTitle())) .replace(%DOC_NO%, escapeHtml(cover.docNo())) .replace(%ISSUE_DATE%, escapeHtml(cover.issueDate())) .replace(%QR_BASE64%, data:image/png;base64, qrBase64); }一眼就能看明白这里干了什么Logo 和二维码都用data:image/png;base64,内嵌不依赖外部网络请求标题、副标题、编号、日期全部经过escapeHtml转义这是标题防注入的关键一步用page-break-after: always强制封面独占一页正文从第二页开始。自定义标题页的本质是“参数化模板”业务方想要藏青色标题、想要加上操作人、想要在封面角落放版本号你要做的就是往模板里加占位符而不是在代码里画坐标。4.2 中文字体加载一个最容易让 PDF 变“豆腐块”的环节OpenHTMLtoPDF 自带的字体不包含中文字形。如果你不注册中文字体所有中文内容渲染出来都是一串豆腐方块而且不会报错——这是最气人的能让你排查半天以为是编码问题。正确的做法是注册系统字体文件路径。比如 Windows 上的黑体一般是C:/Windows/Fonts/simhei.ttfLinux 上可能要去/usr/share/fonts找。我的做法是把常用中文字体文件放到项目resources/fonts/目录下从 classpath 加载避免依赖服务器本地环境差异。private void registerFonts(PdfBoxRenderer renderer) throws IOException { // 加载黑体作为正文默认字体加载后 PDF 才会使用该字体的字形 ClassPathResource simHei new ClassPathResource(fonts/simhei.ttf); renderer.getFontResolver().addFont(simHei.getFile().getAbsolutePath(), SimHei, 1, 0, false); }PdfBoxRenderer的getFontResolver().addFont可以接受 TTF、OTF 和 TTC 字体文件。注意TTC字体比如 Windows 的msyh.ttc用addFont的最后一个参数embeddedSubset设为true时可能有问题所以我建议优先用单文件的 TTF。字体加载后CSS 侧需要指定相同的 family 名。模板里写body { font-family: SimHei; }如果字体没有生效检查两点一是字体文件的绝对路径是否正确二是 family 名是否与addFont里传的名字完全一致大小写也要一致。4.3 页边距、分页与页码CSS3 不支持时的手工兜底OpenHTMLtoPDF 对 CSS 2.1 支持得不错但对 CSS3 的 flex、grid 基本无能为力。我的建议是模板布局老老实实用table或者float。比如要在页脚放页码page里的bottom-center在某些渲染器里不好使我用的是在模板最后放一个固定定位的footer块加上position: running()伪类这需要 OpenHTMLtoPDF 的扩展支持。更稳妥的方案是输出 PDF 后用 PdfBox 的PDPageContentStream来添加页脚页码。这个方案是万能的并且不影响正文排版try (PDDocument doc PDDocument.load(file)) { for (int i 0; i doc.getNumberOfPages(); i) { PDPage page doc.getPage(i); try (PDPageContentStream cs new PDPageContentStream(doc, page, AppendMode.APPEND, true, true)) { cs.beginText(); cs.setFont(PDType1Font.HELVETICA, 9); cs.newLineAtOffset(page.getMediaBox().getWidth() - 60, 15); cs.showText(String.format(%d / %d, i 1, doc.getNumberOfPages())); cs.endText(); } } }这里我踩过坑用AppendMode.APPEND而不是覆盖模式否则会把正文已有的内容清掉另外newLineAtOffset的基准坐标是页面左下角不是正文左下角所以换算页脚位置时别把页边距算错。分页方面正文里h2前面建议加page-break-before: auto或手动控制分页点像表格这种多行元素可以让行内不裁切用tr { page-break-inside: avoid; }防止一行表格被切到两页。5. 二维码生成与嵌入位置、清晰度、验签信息一个都不能少5.1 用 ZXing 生成带容错的二维码二维码生成的代码本身不复杂但有几个参数很关键public static String generateQrBase64(String content, int size) throws Exception { if (content null || content.length() 600) { // 二维码容量有限超长内容直接抛异常比生成一个扫不出的图更好 throw new IllegalArgumentException(二维码内容太长最大长度建议为 600 字符); } MapEncodeHintType, Object hints new HashMap(); hints.put(EncodeHintType.CHARACTER_SET, UTF-8); // 纠错级别M 提供约 15% 的容错H 提供约 30% 但会降低信息密度 hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); hints.put(EncodeHintType.MARGIN, 1); QRCodeWriter writer new QRCodeWriter(); BitMatrix matrix writer.encode(content, BarcodeFormat.QR_CODE, size, size, hints); BufferedImage image MatrixToImageWriter.toBufferedImage(matrix); ByteArrayOutputStream out new ByteArrayOutputStream(); ImageIO.write(image, png, out); return Base64.getEncoder().encodeToString(out.toByteArray()); }MARGIN设为 1 而不是默认的 4是因为在 PDF 里我们还会给二维码留白边二维码周围的空白如果太大整个封面会显得空。ZXing 生成的矩阵自带模块化MARGIN1在这个场景下实测扫码毫无压力。如果你看过不少教程会发现有人用MatrixToImageConfig指定黑白颜色我一般不动它默认黑白色对比度最高在纸质打印或 PDF 灰度打印时最稳。5.2 二维码放进封面的两种方式与取舍二维码要放到封面指定位置两种方案我都在生产环境用过方案 A直接放到 HTML 模板里用 div 定位或表格布局控制位置推荐div styleposition: absolute; right: 2cm; bottom: 2cm; text-align: center; img src%QR_BASE64% width128 height128/ /divOpenHTMLtoPDF 对position: absolute支持得还可以只要父容器相对定位设置好。如果担心兼容性可以退一步用两列表格布局左侧放正文信息、右侧放二维码。这个方案的优势是布局语义清晰二维码跟文字、Logo 天然在一个文档流里不需要额外的坐标计算。方案 BPDF 生成后用 PdfBox 在指定坐标盖章PDImageXObject qrImage PDImageXObject.createFromFile(qrFile, pdfDoc); try (PDPageContentStream cs new PDPageContentStream(pdfDoc, page, AppendMode.APPEND, true, true)) { // x,y 是左下角坐标需要从页面尺寸换算 cs.drawImage(qrImage, page.getMediaBox().getWidth() - 170, 720, 128, 128); }方案 B 适合二维码位置跟正文布局完全无关、需要动态决定在哪个 PDF 的哪个位置盖戳的场景。缺点是坐标计算麻烦、跟 HTML 模板脱节页面只要加了删了内容你想钉的那一页位置就变了。我的结论凡是封面二维码用方案 A凡是需要在多页文档的每一页加防伪二维码用方案 B。两个方案都不需要额外依赖外部服务。5.3 给二维码塞点防伪信息签名串的构造思路二维码只放一个文档编号扫出来谁都能伪造。我这次的设计是让二维码内容本身不可伪造public static String buildQrContent(String docNo, String issueTime, String randomSeed) throws Exception { String payload docNo | issueTime | randomSeed; // SHA256WithRSA 签名私钥放在后端配置中心公钥给到扫码校验端 Signature signer Signature.getInstance(SHA256withRSA); signer.initSign(privateKey); signer.update(payload.getBytes(StandardCharsets.UTF_8)); byte[] signature signer.sign(); // 内容结构固定便于校验端反向解析 return FP: docNo : issueTime : Base64.getEncoder().encodeToString(signature); }校验端要做的事拿到二维码字符串按:拆分出文档号、签发时间和签名值用事先分发好的公钥校验签名。验签通过就说明这份 PDF 的防伪标识是内部签发的文档编号与签发时间没有被篡改。有一个细节要注意二维码内容不超过 300 字符时在M纠错级别下辨识度最高。签名用 RSA 256 位摘要 Base64 后会增长约 88 个字符加上文档编号和日期总体在 200 字左右容错足够。6. 完整业务流程串起来Service 层代码与产出物复盘6.1 组装一次导出的核心链路把前面所有环节收拢成一个服务方法整体流程像一条流水线Service public class PdfExportService { public byte[] exportRichTextToPdf(RichTextExportRequest request) throws Exception { // 1. 清洗富文本杜绝 XSS 和非法样式进入 PDF 渲染 String safeBody RichTextSanitizer.sanitizeRichText(request.content()); // 2. 生成防伪二维码内容签名串 String qrContent QrSignUtil.buildQrContent( request.docNo(), DateTimeFormatter.ofPattern(yyyyMMddHHmmss) .format(LocalDateTime.now()), UUID.randomUUID().toString()); String qrBase64 QrCodeUtil.generateQrBase64(qrContent, 256); // 3. 组装封面 正文的完整 HTML String coverHtml CoverTemplate.buildCoverHtml(CoverConfig.builder() .title(request.title()) .subTitle(request.subTitle()) .docNo(request.docNo()) .issueDate(today()) .build(), qrBase64); String fullHtml coverHtml htmlbody safeBody /body/html; // 4. 渲染 PDF return renderPdf(fullHtml); } private byte[] renderPdf(String html) throws IOException { ByteArrayOutputStream baos new ByteArrayOutputStream(); PdfBoxRenderer renderer new PdfBoxRenderer(); renderer.setDocumentFromString(html); registerFonts(renderer); renderer.layout(); renderer.createPDF(baos); renderer.finishPDF(); return baos.toByteArray(); } }这里要注意拼接顺序封面模板已经带了/html/body正文部分不能重复包裹html标签否则第二个html会被浏览器优雅忽略但PDF 渲染引擎可不一定那么宽容。稳妥做法是把封面模板的结构设计成“只有封面布局”正文用div idcontent占位再由外层统一包裹或者干脆用一个完整的 HTML 模板字符串在代码里替换%BODY%占位符。我上线的版本选的是后者完整模板 占位符替换逻辑最不容易出错。6.2 实测耗时、内存和产出效果复盘我在这台“8 核 16G、Java 17、Spring Boot 2.7”的测试环境下压了一组真实业务数据场景数据量耗时内存增量纯文本正文 封面 二维码2 页260ms约 40MB富文本正文含 10 张 base64 图片8 页1.8s约 260MB富文本正文含 2 张网图5 页900ms约 110MB数据里隐藏着两个规律内嵌 base64 图片是内存暴涨的元凶。如果你在富文本里存了十几张拍照图每张 500KB渲染时需要先解码成 BufferedImage 再编码进 PDF瞬时内存会非常难看。建议导出前把srcdata:image/...的图先抠出来转存到对象存储替换成绝对 URL 再渲染。渲染耗时大头不在 PDF 引擎而在 HTML 解析和布局计算。OpenHTMLtoPDF 内部用 CSS 盒模型逐元素布局DOM 节点越多越慢。一节正文里上百个p很常见耗时反而可接受。6.3 常见报错与解决方案速查写到最后把我在这个需求里遇到的高频报错整理成速查表你在实现时大概率也会撞上现象可能原因解决方案页面上的中文全部显示为######没有注册中文字体或font-family不匹配确认addFont成功CSS family 与注册名一致富文本里图片不显示img src是相对路径或外网地址清洗时协议被滤在清洗前把所有src替换为完整http(s)URL导出时报StackOverflowError用户粘贴了多层嵌套的table/div清洗阶段限制标签最大嵌套深度超过 12 即降级为纯文本生成的 PDF 内容挤在第一页没有分页规则或渲染器分页失败检查page-break-after、page-break-inside是否被样式覆盖二维码扫不出来内容过长或纠错级别太低压缩内容到 300 字符内纠错级别提到M以上后台回显时富文本带出脚本入库前没有做白名单清洗在 Service 层统一调用 jsoup 清洗后再入库PDF 文件大小异常偏大正文内嵌大量高分辨率图片转存图片 URL 并限制导出图片最大宽高使用压缩编码我的一个体会是这类导出功能最花时间的往往不是“生成 PDF”本身而是边界场景的处理用户 HTML 里半截闭合标签、图片引用外网防盗链 403、标题里带了工单系统的换行符。如果你在做一个导出中心建议把清洗、渲染、二维码这三块拆成独立服务每个服务只处理一种异常这样排查问题时能快速定位到某一环。最后分享一个小技巧把渲染后的 PDF 落一份到本地或者对象存储文件名用“文档编号 日期”规则重跑时比对字节数是否变化。富文本导出未必是纯幂等操作二维码里带随机串会导致每次导出内容不同如果业务要求“同文档多次导出必须完全一致”就把二维码内容里的随机种子改成文档编号的哈希值去掉 UUID。这大概是决定“能用”和“好用”的最后一道分水岭。