Java 渲染 docx 文件,并生成 pdf 加水印
最近做了一个比较有意思的需求,实现的比较有意思。
需求:
- 用户上传一个 docx 文件,文档中有占位符若干,识别为文档模板。
- 用户在前端可以将标签拖拽到模板上,替代占位符。
- 后端根据标签,获取标签内容,生成 pdf 文档并打上水印。
需求实现的难点:
- 模板文件来自业务方,财务,执行等角色,不可能使用类似 (freemark、velocity、Thymeleaf) 技术常用的模板标记语言。
- 文档在上传后需要解析,生成 html 供前端拖拽标签,同时渲染的最终文档是 pdf 。由于生成的 pdf 是正式文件,必须要求格式严格保证。
- 前端如果直接使用富文本编辑器,目前开源没有比较满意的实现,同时自主开发富文本需要极高技术含量。所以不考虑富文本编辑器的可能。
技术调研和技术选型(Java 技术栈):
1. 对 docx 文档格式的转换:
一顿google以后发现了 StackOverflow 上的这个回答:Converting docx into pdf in java 使用如下的 jar 包:
Apache POI 3.15
org.apache.poi.xwpf.converter.core-1.0.6.jar
org.apache.poi.xwpf.converter.pdf-1.0.6.jar
fr.opensagres.xdocreport.itext.extension-2.0.0.jar
itext-2.1.7.jar
ooxml-schemas-1.3.jar
实际上写了一个 Demo 测试以后发现,这套组合以及年久失修,对于复杂的 docx 文档都不能友好支持,代码不严谨,不时有 Nullpoint 的异常抛出,还有莫名的jar包冲突的错误,最致命的一个问题是,不能严格保证格式。复杂的序号会出现各种问题。 pass。
第二种思路,使用 LibreOffice, LibreOffice 提供了一套 api 可以提供给 java 程序调用。
所以使用 jodconverter 来调用 LibreOffice。之前网上搜到的教程早就已经过时。jodconverter 早就推出了 4.2 版本。最靠谱的文档还是直接看官方提供的wiki。
2. 渲染模板。
第一种思路,将 docx 装换为 html 的纯文本格式,再使用 Java 现有的模板引擎(freemark,velocity)渲染内容。但是 docx 文件装换为 html 还是会有极大的格式损失。 pass。
第二种思路。直接操作 docx 文档在 docx 文档中直接将占位符替换为内容。这样保证了格式不会损失,但是没有现成的模板引擎可以支持 docx 的渲染。需要自己实现。
3. 水印:
这个相对比较简单,直接使用 itextpdf 免费版就能解决问题。需要注意中文的问题字体,下文会逐步讲解。
关键技术实现技术实现:
jodconverter + libreoffice 的使用
jodconverter 已经提供了一套完整的spring-boot解决方案,只需要在 pom.xml中增加如下配置:
<dependency>
<groupId>org.jodconverter</groupId>
<artifactId>jodconverter-local</artifactId>
<version>4.2.0</version>
</dependenc>
<dependency>
<groupId>org.jodconverter</groupId>
<artifactId>jodconverter-spring-boot-starter</artifactId>
<version>4.2.0</version>
</dependency>
增加配置类:
@Configuration
public class ApplicationConfig {
@Autowired
private OfficeManager officeManager;
@Bean
public DocumentConverter documentConverter(){
return LocalConverter.builder()
.officeManager(officeManager)
.build();
}
}
在配置文件 application.properties 中添加:
# libreoffice 安装目录
jodconverter.local.office-home=/Applications/LibreOffice.app/Contents
# 开启jodconverter
jodconverter.local.enabled=true
直接使用:
@Autowired
private DocumentConverter documentConverter;
private byte[] docxToPDF(InputStream inputStream) {
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
documentConverter
.convert(inputStream)
.as(DefaultDocumentFormatRegistry.DOCX)
.to(byteArrayOutputStream)
.as(DefaultDocumentFormatRegistry.PDF)
.execute();
return byteArrayOutputStream.toByteArray();
} catch (OfficeException | IOException e) {
log.error("convert pdf error");
}
return null;
}
就将 docx 转换为 pdf。注意流需要关闭,防止内存泄漏。
模板的渲染:
直接看代码:
@Service
public class OfficeService{
//占位符 {}
private static final Pattern SymbolPattern = Pattern.compile("\\{(.+?)\\}", Pattern.CASE_INSENSITIVE);
public byte[] replaceSymbol(InputStream inputStream,Map<String,String> symbolMap) throws IOException {
XWPFDocument doc = new XWPFDocument(inputStream);
replaceSymbolInPara(doc,symbolMap);
replaceInTable(doc,symbolMap);
try(ByteArrayOutputStream os = new ByteArrayOutputStream()) {
doc.write(os);
return os.toByteArray();
}finally {
inputStream.close();
}
}
private int replaceSymbolInPara(XWPFDocument doc,Map<String,String> symbolMap){
XWPFParagraph para;
Iterator<XWPFParagraph> iterator = doc.getParagraphsIterator();
while(iterator.hasNext()){
para = iterator.next();
replaceInPara(para,symbolMap);
}
}
//替换正文
private void replaceInPara(XWPFParagraph para,Map<String,String> symbolMap) {
List<XWPFRun> runs;
if (symbolMatcher(para.getParagraphText()).find()) {
String text = para.getParagraphText();
Matcher matcher3 = SymbolPattern.matcher(text);
while (matcher3.find()) {
String group = matcher3.group(1);
String symbol = symbolMap.get(group);
if (StringUtils.isBlank(symbol)) {
symbol = " ";
}
text = matcher3.replaceFirst(symbol);
matcher3 = SymbolPattern.matcher(text);
}
runs = para.getRuns();
String fontFamily = runs.get(0).getFontFamily();
int fontSize = runs.get(0).getFontSize();
XWPFRun xwpfRun = para.insertNewRun(0);
xwpfRun.setFontFamily(fontFamily);
xwpfRun.setText(text);
if(fontSize > 0) {
xwpfRun.setFontSize(fontSize);
}
int max = runs.size();
for (int i = 1; i < max; i++) {
para.removeRun(1);
}
}
}
//替换表格
private void replaceInTable(XWPFDocument doc,Map<String,String> symbolMap) {
Iterator<XWPFTable> iterator = doc.getTablesIterator();
XWPFTable table;
List<XWPFTableRow> rows;
List<XWPFTableCell> cells;
List<XWPFParagraph> paras;
while (iterator.hasNext()) {
table = iterator.next();
rows = table.getRows();
for (XWPFTableRow row : rows) {
cells = row.getTableCells();
for (XWPFTableCell cell : cells) {
paras = cell.getParagraphs();
for (XWPFParagraph para : paras) {
replaceInPara(para,symbolMap);
}
}
}
}
}
}
这里需要特别注意:
- 在解析的文档中,para.getParagraphText()指的是获取段落,para.getRuns()应该指的是获取词。但是问题来了,获取到的 runs 的划分是一个谜。目前我也没有找到规律,很有可能我们的占位符被划分到了多个run中,如果我们简单的针对 run 做正则表达的替换,而要先把所有的 runs 组合起来再进行正则替换。
- 在调用para.insertNewRun()的时候 run 并不会保持字体样式和字体大小需要手动获取并设置。
- 由于以上两个蜜汁实现,所以就写了一坨蜜汁代码才能保证正则替换和格式正确。
test 方法:
@Test
public void replaceSymbol() throws IOException {
File file = new File("symbol.docx");
InputStream inputStream = new FileInputStream(file);
File outputFile = new File("out.docx");
FileOutputStream outputStream = new FileOutputStream(outputFile);
Map<String,String> map = new HashMap<>();
map.put("tableName","水果价目表");
map.put("name","苹果");
map.put("price","1.5/斤");
byte[] bytes = office.replaceSymbol(inputStream, map, );
outputStream.write(bytes);
}
replaceSymbol() 方法接受两个参数,一个是输入的docx文件数据流,另一个是占位符和内容的map。
这个方法使用前:
before
使用后:
after
增加水印:
pom.xml需要增加:
<!-- https://mvnrepository.com/artifact/com.itextpdf/itextpdf -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itextpdf</artifactId>
<version>5.5.13</version>
</dependency>
增加水印的代码:
public byte[] addWatermark(InputStream inputStream,String watermark) throws IOException, DocumentException {
PdfReader reader = new PdfReader(inputStream);
try(ByteArrayOutputStream os = new ByteArrayOutputStream()) {
PdfStamper stamper = new PdfStamper(reader, os);
int total = reader.getNumberOfPages() + 1;
PdfContentByte content;
// 设置字体
BaseFont baseFont = BaseFont.createFont("simsun.ttf", BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);
// 循环对每页插入水印
for (int i = 1; i < total; i++) {
// 水印的起始
content = stamper.getUnderContent(i);
// 开始
content.beginText();
// 设置颜色
content.setColorFill(new BaseColor(244, 244, 244));
// 设置字体及字号
content.setFontAndSize(baseFont, 50);
// 设置起始位置
content.setTextMatrix(400, 780);
for (int x = 0; x < 5; x++) {
for (int y = 0; y < 5; y++) {
content.showTextAlignedKerned(Element.ALIGN_CENTER,
watermark,
(100f + x * 350),
(40.0f + y * 150),
30);
}
}
content.endText();
}
stamper.close();
return os.toByteArray();
}finally {
reader.close();
}
}
字体:
- 使用文档的时候,字体也同样重要,如果你使用了 libreOffice 没有的字体,比如宋体。需要把字体文件 xxx.ttf
cp xxx.ttc /usr/share/fonts
fc-cache -fv
- itextpdf 不支持汉字,需要提供额外的字体:
//字体路径
String fontPath = "simsun.ttf"
//设置字体
BaseFont baseFont = BaseFont.createFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);
后记
整个需求挺有意思,但是在查询的时候发现中文文档的质量实在堪忧,要么极度过时,要么就是大家互相抄袭。
查询一个项目的技术文档,最好的路径应该如下:
项目官网 Getting Started --> github demo --> StackOverflow --> CSDN --> 百度知道
版权声明:
作者: FreeClashNode
链接: https://www.freeclashnode.com/news/article-784.htm
来源: www.freeclashnode.com
文章版权归作者所有,未经允许请勿转载。
热门文章
- 10月1日免费节点分享|18M/S,Clash节点/V2ray节点/Singbox节点/SSR节点|免费上网梯子每天更新
- 10月2日免费节点分享|22.8M/S,Singbox节点/Shadowrocket节点/Clash节点/V2ray节点|免费上网梯子每天更新
- 10月3日免费节点分享|20.1M/S,SSR节点/Singbox节点/V2ray节点/Clash节点|免费上网梯子每天更新
- 9月30日免费节点分享|19.1M/S,Clash节点/V2ray节点/SSR节点/Singbox节点|免费上网梯子每天更新
- 10月6日免费节点分享|19.2M/S,V2ray节点/Singbox节点/Shadowrocket节点/Clash节点|免费上网梯子每天更新
- 10月8日免费节点分享|22.5M/S,V2ray节点/Singbox节点/Clash节点/Shadowrocket节点|免费上网梯子每天更新
- 10月14日免费节点分享|22.5M/S,Clash节点/V2ray节点/Singbox节点/SSR节点|免费上网梯子每天更新
- 10月11日免费节点分享|18.6M/S,V2ray节点/Clash节点/SSR节点/Singbox节点|免费上网梯子每天更新
- 10月5日免费节点分享|19.9M/S,Singbox节点/Clash节点/V2ray节点/SSR节点|免费上网梯子每天更新
- 10月13日免费节点分享|22.1M/S,V2ray节点/Clash节点/Singbox节点/Shadowrocket节点|免费上网梯子每天更新
最新文章
- 探索数字世界的门户:电脑科学上网贴吧完全指南
- 10月28日免费节点分享|22.8M/S,Singbox节点/SSR节点/V2ray节点/Clash节点|免费上网梯子每天更新
- 穿越数字边界:科学上网的全面指南与深度思考
- 10月27日免费节点分享|20M/S,Shadowrocket节点/Singbox节点/V2ray节点/Clash节点|免费上网梯子每天更新
- 数字世界的破壁之旅:科学上网全指南与实用工具深度解析
- 10月26日免费节点分享|21.5M/S,V2ray节点/SSR节点/Singbox节点/Clash节点|免费上网梯子每天更新
- 跨越知识边界:浙大学子的全球学术网络探索指南
- 10月25日免费节点分享|18.1M/S,SSR节点/V2ray节点/Singbox节点/Clash节点|免费上网梯子每天更新
- 10月24日免费节点分享|20.5M/S,V2ray节点/Clash节点/Singbox节点/Shadowrocket节点|免费上网梯子每天更新
- 刘大猫科学上网全攻略:解锁全球网络的终极指南
- 10月23日免费节点分享|22.2M/S,Shadowrocket节点/Clash节点/Singbox节点/V2ray节点|免费上网梯子每天更新
- 全面解析Quantumult安卓版:功能、安装与配置教程
- 双重防护,畅享无界:AdGuard联手V2RayNG,打造极致安全的上网体验
- 10月22日免费节点分享|18.8M/S,V2ray节点/SSR节点/Singbox节点/Clash节点|免费上网梯子每天更新
- 掌握Clash白名单设置:精准控制代理流量的艺术
- 10月21日免费节点分享|22M/S,Clash节点/Singbox节点/V2ray节点/SSR节点|免费上网梯子每天更新
- 突破网络边界:技术科学上网的终极安全指南
- 10月20日免费节点分享|22.9M/S,SSR节点/V2ray节点/Clash节点/Singbox节点|免费上网梯子每天更新
- Shadowrocket 选购指南:从入门到精通,找到你的专属代理神器
- 10月19日免费节点分享|19.3M/S,Singbox节点/V2ray节点/Clash节点/Shadowrocket节点|免费上网梯子每天更新
- 深入解读:V2Ray为何比SSR更安全?一场网络隐私的全方位较量
- 10月18日免费节点分享|20.6M/S,V2ray节点/Clash节点/SSR节点/Singbox节点|免费上网梯子每天更新
- 深度指南:Shadowrocket从入门到精通——iOS用户的网络自由密钥
- 10月17日免费节点分享|18M/S,Clash节点/SSR节点/V2ray节点/Singbox节点|免费上网梯子每天更新
- 深入解析科学上网套:全方位使用指南与常见问题解答
- 10月16日免费节点分享|22M/S,Shadowrocket节点/V2ray节点/Singbox节点/Clash节点|免费上网梯子每天更新
- 10月15日免费节点分享|21.2M/S,Clash节点/SSR节点/V2ray节点/Singbox节点|免费上网梯子每天更新
- 全局掌控:安卓设备上 Clash 全局模式完整设置指南与深度解析
- 解锁全球游戏体验:育碧平台科学上网全攻略与深度解析
- 10月14日免费节点分享|22.5M/S,Clash节点/V2ray节点/Singbox节点/SSR节点|免费上网梯子每天更新
- 华为Mate 10科学上网全攻略:突破限制,畅游全球网络
- 10月13日免费节点分享|22.1M/S,V2ray节点/Clash节点/Singbox节点/Shadowrocket节点|免费上网梯子每天更新
- 10月12日免费节点分享|22.7M/S,Shadowrocket节点/V2ray节点/Singbox节点/Clash节点|免费上网梯子每天更新
- 量子ULT全方位指南:从下载配置到高阶玩法一网打尽
- 10月11日免费节点分享|18.6M/S,V2ray节点/Clash节点/SSR节点/Singbox节点|免费上网梯子每天更新
- 极路由V2ray插件完全指南:打造家庭网络的安全屏障
- 10月10日免费节点分享|18.3M/S,V2ray节点/Singbox节点/Clash节点/SSR节点|免费上网梯子每天更新
- 极速狂飙与正义追击:《警车 Clash》深度解析与高阶攻略
- 10月9日免费节点分享|22.3M/S,Singbox节点/Clash节点/Shadowrocket节点/V2ray节点|免费上网梯子每天更新
- 用hosts文件科学上网:一份从入门到实战的详细指南
- 10月8日免费节点分享|22.5M/S,V2ray节点/Singbox节点/Clash节点/Shadowrocket节点|免费上网梯子每天更新
- iOS设备上SSR科学上网全教程:从入门到精通
- 10月7日免费节点分享|23M/S,Singbox节点/Clash节点/Shadowrocket节点/V2ray节点|免费上网梯子每天更新
- 深度剖析V2Ray订阅机制:从零搭建高效、安全的科学上网环境
- 10月6日免费节点分享|19.2M/S,V2ray节点/Singbox节点/Shadowrocket节点/Clash节点|免费上网梯子每天更新
- 全面解析蚕蛹生态冲突及其多元价值
- 10月5日免费节点分享|19.9M/S,Singbox节点/Clash节点/V2ray节点/SSR节点|免费上网梯子每天更新
- 从零开始:手把手教你刷梅林固件实现安全科学上网
- 10月4日免费节点分享|20M/S,SSR节点/V2ray节点/Clash节点/Singbox节点|免费上网梯子每天更新
- 跨越边界,自由探索:一加科学上网全面指南与深度解析
归档
- 2026-10 55
- 2026-09 58
- 2026-08 63
- 2026-07 61
- 2026-06 55
- 2026-05 56
- 2026-04 51
- 2026-03 60
- 2026-02 52
- 2026-01 56
- 2025-12 59
- 2025-11 55
- 2025-10 56
- 2025-09 55
- 2025-08 49
- 2025-07 31
- 2025-06 30
- 2025-05 31
- 2025-04 30
- 2025-03 388
- 2025-02 360
- 2025-01 403
- 2024-12 403
- 2024-11 390
- 2024-10 403
- 2024-09 388
- 2024-08 402
- 2024-07 427
- 2024-06 442
- 2024-05 181
- 2024-04 33
- 2024-03 31
- 2024-02 29
- 2024-01 51
- 2023-12 52
- 2023-11 32
- 2023-10 32
- 2023-09 3