Java 渲染 docx 文件,并生成 pdf 加水印

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

需求:

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

需求实现的难点:

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

技术调研和技术选型(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);
                    }
                }
            }
        }
    }
}

这里需要特别注意

  1. 在解析的文档中,para.getParagraphText()指的是获取段落,para.getRuns()应该指的是获取词。但是问题来了,获取到的 runs 的划分是一个谜。目前我也没有找到规律,很有可能我们的占位符被划分到了多个run中,如果我们简单的针对 run 做正则表达的替换,而要先把所有的 runs 组合起来再进行正则替换。
  2. 在调用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();
        }

    }

字体:

  1. 使用文档的时候,字体也同样重要,如果你使用了 libreOffice 没有的字体,比如宋体。需要把字体文件 xxx.ttf
cp xxx.ttc /usr/share/fonts
fc-cache -fv
  1. itextpdf 不支持汉字,需要提供额外的字体:
//字体路径
String fontPath = "simsun.ttf"
//设置字体
BaseFont baseFont = BaseFont.createFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);

后记

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

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

原文发布于微信公众号 - 犀利豆的技术空间(xilidou1)

原文发表时间:2018-08-16

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏何俊林

Android Multimedia框架总结(九)Stagefright框架之数据处理及到OMXCodec过程

不知不觉到第九篇了,感觉还有好多好多没有写,路漫漫其修远兮 ,吾将上下而求索。先说福利吧,此前在关于我, ? 曾说过,不定期搞活动,vip,书啥的,都可以有,...

2496
来自专栏技术/开源

Enum引发的血案,反思

前几天公司产品更新版本,更新完后不少用户反应原先保存的report的一些表在新版本打开后设置突然变了,本来选的第六个,现在打开变成第四个了。领导要求赶紧查出原因...

1985
来自专栏专注研发

【2018】笔试题笔记

3.对关键字{10,20,8,25,35,6,18,30,5,15,28}序列进行希尔排序,取增量d =5时,排序结果为( {6,18,8,5,15,10,...

1814
来自专栏向治洪

android 加载图片oom若干方案小结

本文根据网上提供的一些技术方案加上自己实际开发中遇到的情况小结。 众所周知,每个Android应用程序在运行时都有一定的内存限制,限制大小一般为16MB或2...

2028
来自专栏CDA数据分析师

Python数据科学计算库的安装和numpy简单

前言 如何使用Python进行科学计算和数据分析,这里我们就要用到Python的科学计算库,今天来分享一下如何安装Python的数据科学计算库。 数据科学计算库...

30810
来自专栏水击三千

UML学习-状态图

1.状态图概述 状态图(Statechart Diagram)主要用于描述一个对象在其生存期间的动态行为,表现为一个对象所经历的状态序列,引起状态转移的事件(E...

28410
来自专栏张善友的专栏

使用Metrics.NET 构建 ASP.NET MVC 应用程序的性能指标

通常我们需要监测ASP.NET MVC 或 Web API 的应用程序的性能时,通常采用的是自定义性能计数器,性能计数器会引发无休止的运维问题(损坏的计数器、权...

2168
来自专栏happyJared

Elasticsearch地理坐标类型(Geo-point)在Spring Data ES中的常见使用问题整理解答

  下文整理的几个问答,本人在实际应用中亲身经历或解决过的,主要涉及Elasticsearch地理坐标类型(Geo-point)在Java应用中的一些特殊使用场...

4171
来自专栏牛客网

考点总结:互联网校招技术岗都考些什么?数据结构算法游戏 + 场景c++面向对象javaJVMSpringandroid数据库计网线程安全linux前端询问面试官

数据结构 红黑树 pk 平衡二叉树 hash表处理冲突的方法 算法 手写 最长无重复字符子串 链表的增、删、查、逆序 数组实现队列,要求可以动态扩展,保证较高的...

3707
来自专栏全栈之路

关于Canvas保存为图片

但是在webapp该方法是不行的,默认是不支持的。一种方法是在android的java代码写js接口,而一个纯webapp,确是很难做到(其实也不是),只不过找...

1.1K1

扫码关注云+社区

领取腾讯云代金券