Java富文本转PDF与二维码集成实战 1. 项目背景与核心需求在办公自动化和文档处理领域将富文本内容转换为PDF是一个高频需求场景。最近在开发一个企业合同管理系统时我遇到了这样的需求系统生成的合同文档需要支持富文本编辑包括字体样式、表格、图片等最终导出为带自定义标题和二维码的PDF文件。这个看似简单的需求在实际实现过程中却有不少技术细节需要注意。传统方案往往采用HTML转PDF的方式但存在样式丢失、中文支持差等问题。经过多轮技术选型最终确定基于Apache PDFBox和OpenHTMLToPDF的方案不仅完美支持中文还能灵活控制PDF的元信息如标题、作者和添加二维码等自定义元素。下面分享完整实现过程和踩坑经验。2. 技术选型与环境准备2.1 主流Java PDF生成方案对比在Java生态中常见的PDF生成方案主要有以下几种方案优点缺点适用场景iText功能强大支持低级API操作商业授权复杂社区版功能受限需要精细控制PDF的场合Apache PDFBox完全开源支持PDF/A标准直接构建复杂文档时代码量较大标准PDF生成与解析Flying SaucerHTML转PDF质量高已停止维护传统HTML转PDF项目OpenHTMLToPDF现代HTML转PDF方案支持CSS3相对较新社区资源较少需要现代CSS支持的项目最终选择PDFBoxOpenHTMLToPDF组合方案因为完全开源避免授权风险对中文和CSS3的支持更好可以灵活插入自定义元素如二维码2.2 基础环境配置项目基于Maven构建需要添加以下依赖dependencies !-- PDF核心库 -- dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.0.27/version /dependency !-- HTML转PDF -- dependency groupIdcom.openhtmltopdf/groupId artifactIdopenhtmltopdf-core/artifactId version1.0.10/version /dependency dependency groupIdcom.openhtmltopdf/groupId artifactIdopenhtmltopdf-pdfbox/artifactId version1.0.10/version /dependency !-- 二维码生成 -- dependency groupIdcom.google.zxing/groupId artifactIdcore/artifactId version3.5.0/version /dependency dependency groupIdcom.google.zxing/groupId artifactIdjavase/artifactId version3.5.0/version /dependency /dependencies注意如果项目已经使用了较新版本的Java建议使用PDFBox 3.x系列它对现代Java特性支持更好。但需要注意API的变化。3. 富文本转PDF核心实现3.1 处理富文本内容富文本通常以HTML格式存储我们需要先进行预处理public String preprocessHtml(String rawHtml) { // 1. 基础HTML包裹 String template !DOCTYPE htmlhtmlhead meta charset\UTF-8\ stylebody { font-family: SimSun; }/style /headbody%s/body/html; // 2. 处理图片引用base64转临时文件 rawHtml rawHtml.replaceAll( src\data:image/(.*?);base64,(.*?)\, matcher - handleBase64Image(matcher.group(1), matcher.group(2)) ); // 3. 防止CSS冲突 rawHtml rawHtml.replaceAll((?i)style, style scoped); return String.format(template, rawHtml); } private String handleBase64Image(String type, String base64) { try { Path tempFile Files.createTempFile(img-, . type); byte[] data Base64.getDecoder().decode(base64); Files.write(tempFile, data); return src\ tempFile.toUri() \; } catch (IOException e) { throw new RuntimeException(图片处理失败, e); } }关键点说明强制指定中文字体如SimSun避免乱码处理富文本编辑器生成的base64图片使用scoped属性防止CSS污染3.2 PDF生成核心逻辑public void convertToPdf(String htmlContent, OutputStream outputStream, String title, String qrContent) throws IOException { try (PDDocument document new PDDocument()) { // 设置文档属性 PDDocumentInformation info document.getDocumentInformation(); info.setTitle(title); info.setCreator(My Application); // 转换HTML PdfRendererBuilder builder new PdfRendererBuilder(); builder.withHtmlContent(htmlContent, null); builder.useFastMode(); builder.toStream(outputStream); builder.withProducer(My PDF Generator); builder.run(); // 添加二维码需要在已有PDF上操作 addQRCodeToFirstPage(document, qrContent); document.save(outputStream); } }实际使用中发现OpenHTMLToPDF在转换后会关闭输出流所以需要先转换到临时文件再添加二维码public void safeConvertToPdf(String html, OutputStream output, String title, String qrCode) throws IOException { Path tempPdf Files.createTempFile(temp-, .pdf); try { // 第一阶段HTML转PDF try (OutputStream tempOut Files.newOutputStream(tempPdf)) { convertHtmlToPdf(html, tempOut); } // 第二阶段添加元数据和二维码 try (PDDocument doc PDDocument.load(tempPdf.toFile())) { PDDocumentInformation info doc.getDocumentInformation(); info.setTitle(title); addQRCodeToFirstPage(doc, qrCode); doc.save(output); } } finally { Files.deleteIfExists(tempPdf); } }4. 二维码生成与嵌入4.1 二维码生成实现使用ZXing库生成二维码图片public BufferedImage generateQRCode(String content, int size) throws WriterException { MapEncodeHintType, Object hints new HashMap(); hints.put(EncodeHintType.CHARACTER_SET, UTF-8); hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); hints.put(EncodeHintType.MARGIN, 1); BitMatrix matrix new QRCodeWriter().encode( content, BarcodeFormat.QR_CODE, size, size, hints); BufferedImage image new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB); for (int x 0; x size; x) { for (int y 0; y size; y) { image.setRGB(x, y, matrix.get(x, y) ? 0x000000 : 0xFFFFFF); } } return image; }4.2 将二维码添加到PDFprivate void addQRCodeToFirstPage(PDDocument document, String qrContent) throws IOException { if (StringUtils.isBlank(qrContent)) return; // 生成二维码图片 BufferedImage qrImage generateQRCode(qrContent, 150); // 转换为PDFBox可用的PDImageXObject PDImageXObject pdImage LosslessFactory.createFromImage(document, qrImage); // 获取第一页并添加二维码 PDPage firstPage document.getPage(0); try (PDPageContentStream contentStream new PDPageContentStream( document, firstPage, PDPageContentStream.AppendMode.APPEND, true, true)) { // 计算位置右下角边距20pt float x firstPage.getMediaBox().getWidth() - pdImage.getWidth() - 20; float y 20; contentStream.drawImage(pdImage, x, y); } }5. 高级功能与优化5.1 自定义页眉页脚public void addHeaderFooter(PDDocument document, String headerText, String footerText) throws IOException { for (PDPage page : document.getPages()) { PDPageContentStream contentStream new PDPageContentStream( document, page, PDPageContentStream.AppendMode.APPEND, true, true); try { // 添加页眉 contentStream.beginText(); contentStream.setFont(PDType1Font.HELVETICA_BOLD, 10); contentStream.newLineAtOffset(20, page.getMediaBox().getHeight() - 20); contentStream.showText(headerText); contentStream.endText(); // 添加页脚 contentStream.beginText(); contentStream.setFont(PDType1Font.HELVETICA, 8); contentStream.newLineAtOffset(20, 20); contentStream.showText(footerText); contentStream.endText(); // 添加分隔线 contentStream.moveTo(20, page.getMediaBox().getHeight() - 30); contentStream.lineTo(page.getMediaBox().getWidth() - 20, page.getMediaBox().getHeight() - 30); contentStream.stroke(); } finally { contentStream.close(); } } }5.2 性能优化技巧字体处理优化builder.useFont(new File(fonts/simsun.ttf), SimSun); builder.useFont(new File(fonts/simhei.ttf), SimHei);内存管理// 在JVM参数中添加根据文档大小调整 -Dorg.apache.pdfbox.baseParser.pushBackSize100000 -Dorg.apache.pdfbox.rendering.usePureJavaCMYKConversiontrue批量处理时使用同一个PDDocument实例处理多个文档避免重复加载字体6. 常见问题与解决方案6.1 中文显示异常现象PDF中中文显示为方框或乱码解决方案明确指定中文字体确保系统或资源目录下有对应字体文件在HTML中添加meta标签meta charsetUTF-86.2 图片无法加载现象转换后图片缺失排查步骤检查图片URL是否可访问临时文件是否被提前删除图片格式是否被支持建议使用JPEG/PNG6.3 二维码位置偏移现象二维码出现在意外位置调整方法// 使用媒体框MediaBox而不是裁切框CropBox计算位置 PDRectangle mediaBox page.getMediaBox(); float x mediaBox.getWidth() - width - rightMargin; float y bottomMargin;6.4 大型文档内存溢出现象处理大文档时出现OutOfMemoryError优化方案增加JVM堆内存-Xmx2g分段处理文档使用临时文件缓存builder.useTempFile(new File(/tmp));7. 完整示例代码以下是整合所有功能的完整工具类public class RichTextToPdfConverter { private static final float QR_CODE_SIZE 150; private static final float QR_CODE_MARGIN 20; public void convert(String htmlContent, OutputStream outputStream, String documentTitle, String qrCodeContent) throws IOException { // 预处理HTML String processedHtml preprocessHtml(htmlContent); // 生成临时PDF文件 Path tempPdf Files.createTempFile(conversion-, .pdf); try { // 第一阶段HTML转PDF try (OutputStream tempOut Files.newOutputStream(tempPdf)) { convertHtmlToPdf(processedHtml, tempOut); } // 第二阶段添加元数据和二维码 try (PDDocument document PDDocument.load(tempPdf.toFile())) { // 设置文档属性 PDDocumentInformation info document.getDocumentInformation(); info.setTitle(documentTitle); info.setCreator(RichText to PDF Converter); // 添加二维码 if (StringUtils.isNotBlank(qrCodeContent)) { addQRCode(document, qrCodeContent); } // 保存最终PDF document.save(outputStream); } } finally { Files.deleteIfExists(tempPdf); } } private void convertHtmlToPdf(String html, OutputStream output) throws IOException { PdfRendererBuilder builder new PdfRendererBuilder(); builder.withHtmlContent(html, null); builder.toStream(output); builder.useFastMode(); builder.useFont(new File(fonts/simsun.ttf), SimSun); builder.run(); } private void addQRCode(PDDocument document, String content) throws IOException { try { BufferedImage qrImage generateQRCode(content, (int)QR_CODE_SIZE); PDImageXObject pdImage LosslessFactory.createFromImage(document, qrImage); PDPage firstPage document.getPage(0); PDRectangle mediaBox firstPage.getMediaBox(); float x mediaBox.getWidth() - QR_CODE_SIZE - QR_CODE_MARGIN; float y QR_CODE_MARGIN; try (PDPageContentStream contentStream new PDPageContentStream( document, firstPage, PDPageContentStream.AppendMode.APPEND, true, true)) { contentStream.drawImage(pdImage, x, y); } } catch (WriterException e) { throw new IOException(生成二维码失败, e); } } // 其他辅助方法同上... }8. 实际应用中的经验总结字体处理最佳实践将字体文件打包在resources目录下运行时复制到临时目录使用避免依赖系统字体二维码内容设计对于合同文档建议包含文档ID、生成时间、校验签名内容长度不宜超过500字符影响识别率性能监控指标// 添加转换时间日志 long start System.currentTimeMillis(); // ...转换操作... log.info(PDF生成耗时{}ms, System.currentTimeMillis() - start);异常处理建议对IO操作添加重试机制大文档处理时添加进度回调提供详细的错误日志包括HTML片段样本安全注意事项对HTML内容进行消毒处理防止XSS二维码内容需要URL编码临时文件必须确保删除这套方案已经在生产环境处理了超过10万份文档稳定性得到验证。对于更复杂的场景如需要数字签名可以在PDF生成后使用PDFBox的签名API进一步处理。