惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Securelist
C
Cybersecurity and Infrastructure Security Agency CISA
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
S
Security Affairs
Hacker News: Ask HN
Hacker News: Ask HN
L
Lohrmann on Cybersecurity
PCI Perspectives
PCI Perspectives
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
C
Cyber Attacks, Cyber Crime and Cyber Security
Recent Commits to openclaw:main
Recent Commits to openclaw:main
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
MyScale Blog
MyScale Blog
月光博客
月光博客
W
WeLiveSecurity
T
Threat Research - Cisco Blogs
Martin Fowler
Martin Fowler
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Recorded Future
Recorded Future
The GitHub Blog
The GitHub Blog
Webroot Blog
Webroot Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
TaoSecurity Blog
TaoSecurity Blog
P
Proofpoint News Feed
Google DeepMind News
Google DeepMind News
F
Full Disclosure
U
Unit 42
Jina AI
Jina AI
博客园 - 司徒正美
阮一峰的网络日志
阮一峰的网络日志
L
LINUX DO - 最新话题
宝玉的分享
宝玉的分享
大猫的无限游戏
大猫的无限游戏
The Hacker News
The Hacker News
The Last Watchdog
The Last Watchdog
T
Troy Hunt's Blog
腾讯CDC
T
Threatpost
H
Hacker News: Front Page
P
Palo Alto Networks Blog
博客园 - 聂微东
Last Week in AI
Last Week in AI
有赞技术团队
有赞技术团队
Help Net Security
Help Net Security
L
LINUX DO - 热门话题
N
News and Events Feed by Topic
人人都是产品经理
人人都是产品经理
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Spread Privacy
Spread Privacy

犀利豆的博客

《SRE google 运维解密》读书笔记 (六) 《SRE google 运维解密》读书笔记 (五) 《SRE google 运维解密》读书笔记 (四) 《SRE google 运维解密》读书笔记 (三) 《SRE google 运维解密》读书笔记 (二) 《SRE google 运维解密》读书笔记 (一) 2021 总结 终于有一个 Java 可以用的微信机器人了 Vertx入门到实战—实现钉钉机器人内网穿透代理 钉钉机器人回调内网穿透代理--使用篇 周末补习(一)trie 树 那些有趣的代码(三)--勤俭持家的 ArrayList 那些有趣的代码(二)--偏不听父母话的 Tomcat 类加载器 那些有趣的代码(一)--有点萌的 Tomcat 的线程池 从需求第三定律说起--为什么知乎的回答质量下降了 如何利用 Spring Hibernate 高级特性设计实现一个权限系统 居然有人能忘记吃饭?写个微信机器人提醒他 我的2018年总结 从 LongAdder 中窥见并发组件的设计思路 徒手撸框架--实现 RPC 远程调用 我的写作工具链 撸码的福音--变量名生成器的实现 Raft 协议学习笔记 dubbo 源码学习(一)开篇 Redis 命令的执行过程 Redis 中的事件驱动模型 Redis 数据库、键过期的实现 Redis 的基础数据结构(三)对象 Redis 的基础数据结构(二) 整数集合、跳跃表、压缩列表 Redis 的基础数据结构(一) 可变字符串、链表、字典 线程池 execute() 的工作逻辑 JAVA 中的 CAS 徒手撸框架--高并发环境下的请求合并 徒手撸框架--实现Aop 徒手撸框架--实现IoC 2017个人总结 最近遇到的几个问题集合 Redis RedLock 完美的分布式锁么? JAVA 8入门(二)流 JAVA 8入门(一)Lambda表达式 有道 Alfred Workflow 威力加强版 Kafka实现原理笔记 《交易系统:更新与跨越》读后笔记 Netty-Apns接入实现 Future研究 Hystrix入门研究 Redis实现分布式锁
Java 渲染 docx 文件,并生成 pdf 加水印
Zhengxin Diao · 2018-08-16 · via 犀利豆的博客

img

最近做了一个比较有意思的需求,实现的比较有意思。

需求

  1. 用户上传一个 docx 文件,文档中有占位符若干,识别为文档模板。
  2. 用户在前端可以将标签拖拽到模板上,替代占位符。
  3. 后端根据标签,获取标签内容,生成 pdf 文档并打上水印。

需求实现的难点

  1. 模板文件来自业务方,财务,执行等角色,不可能使用类似 (freemark、velocity、Thymeleaf) 技术常用的模板标记语言。
  2. 文档在上传后需要解析,生成 html 供前端拖拽标签,同时渲染的最终文档是 pdf 。由于生成的 pdf 是正式文件,必须要求格式严格保证。
  3. 前端如果直接使用富文本编辑器,目前开源没有比较满意的实现,同时自主开发富文本需要极高技术含量。所以不考虑富文本编辑器的可能。

1. 对 docx 文档格式的转换

一顿google以后发现了 StackOverflow 上的这个回答:Converting docx into pdf in java 使用如下的 jar 包:

1
2
3
4
5
6
7
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中增加如下配置:

1
2
3
4
5
6
7
8
9
10
11
<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>

增加配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13

@Configuration
public class ApplicationConfig {
@Autowired
private OfficeManager officeManager;
@Bean
public DocumentConverter documentConverter(){
return LocalConverter.builder()
.officeManager(officeManager)
.build();
}
}

在配置文件 application.properties 中添加:

1
2
3
4

jodconverter.local.office-home=/Applications/LibreOffice.app/Contents

jodconverter.local.enabled=true

直接使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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。注意流需要关闭,防止内存泄漏。

模板的渲染

直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88

@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);
}
}
}
}
}

private Matcher symbolMatcher(String str){
return SymbolPattern.matcher(str);
}
}

这里需要特别注意

  1. 在解析的文档中,para.getParagraphText()指的是获取段落,para.getRuns()应该指的是获取词。但是问题来了,获取到的 runs 的划分是一个谜。目前我也没有找到规律,很有可能我们的占位符被划分到了多个run中,我们并不是简单的针对 run 做正则表达的替换,而要先把所有的 runs 组合起来再进行正则替换。
  2. 在调用para.insertNewRun()的时候 run 并不会保持字体样式和字体大小需要手动获取并设置。
    由于以上两个蜜汁实现,所以就写了一坨蜜汁代码才能保证正则替换和格式正确。

test 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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需要增加:

1
2
3
4
5
6

<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itextpdf</artifactId>
<version>5.5.13</version>
</dependency>

增加水印的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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();
}

}


字体

  1. 使用文档的时候,字体也同样重要,如果你使用了 libreOffice 没有的字体,比如宋体。需要把字体文件 xxx.ttf
1
2
cp xxx.ttc /usr/share/fonts
fc-cache -fv
  1. itextpdf 不支持汉字,需要提供额外的字体:
1
2
3
4
5

String fontPath = "simsun.ttf"

BaseFont baseFont = BaseFont.createFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);

后记

整个需求挺有意思,但是在查询的时候发现中文文档的质量实在堪忧,要么极度过时,要么就是大家互相抄袭。
查询一个项目的技术文档,最好的路径应该如下:

项目官网 Getting Started == github demo > StackOverflow >> CSDN >> 百度知道

欢迎关注我的微信公众号
二维码